A shared fixture gets a new required dependency. Two weeks later a team on the other side of the organisation has 200 failing tests and no idea why. Nobody was careless. The shared layer had no owner and no contract.
At senior level, repository structure is not about tidiness. It answers one question: who may change what, without asking?
The shape that works.
tests/
payments/ owned by the payments team
accounts/
checkout/
lib/
core/ shared, strictest review
fixtures/
clients/ API clients
data/ factories
pages/ shared page objects
payments/ team-local, that team changes freely
playwright.config.ts
playwright/
config/
base.ts
payments.ts
Team folders are owned outright. No review needed from anyone else.
lib/core can break every team, so it gets the strictest review. Enforce it with CODEOWNERS rather than a wiki page, because a convention nobody enforces lasts about six weeks.
Team-local libraries are the pressure valve. Without them everything gets pushed into core, and core becomes a landfill.
The extended test object is the architectural centre.
This is the Playwright-specific piece and it deserves care. Every spec imports test from your own module, not from @playwright/test:
// lib/core/fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import type { Fixtures } from './types';
export const test = base.extend<Fixtures>({
api: async ({ request }, use) => { await use(new ApiClient(request)); },
contactsPage: async ({ page }, use) => { await use(new ContactsPage(page)); },
});
export { expect };
That single export is what makes the framework coherent. It is also a chokepoint: adding a fixture there affects everyone. Which is exactly why it belongs in core with strict review.
Layered fixtures for team extension. A team that needs its own fixtures extends yours rather than the base:
// lib/payments/fixtures.ts
import { test as coreTest } from '../core/fixtures';
export const test = coreTest.extend<PaymentFixtures>({
paymentMethod: async ({ api }, use) => {
const method = await api.createPaymentMethod();
await use(method);
await api.deletePaymentMethod(method.id);
},
});
Now payments specs get core fixtures plus their own, and no other team is affected. This layering is the main reason Playwright scales well across teams, and it is underused.
Config composition. Config is TypeScript, so it composes like any module:
// playwright/config/base.ts
export const baseConfig: PlaywrightTestConfig = {
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['blob'], ['github']] : [['html']],
use: { baseURL: process.env.BASE_URL, trace: 'on-first-retry' },
};
// playwright/config/payments.ts
export default defineConfig({ ...baseConfig, testDir: '../../tests/payments' });
A payments engineer runs only payments tests and gets feedback in three minutes instead of forty. That single change does more for adoption than any amount of documentation.
The decisions to make early, because retrofitting them is expensive:
What is core? Auth, API clients, data factories, and the selector policy. If these start in a team folder they get copied, and then you have four incompatible logins.
Publish core as a package, or keep it in the repo? A versioned internal package gives teams a changelog and the ability to upgrade on their own schedule. It also adds release overhead. Above roughly five teams the version boundary usually pays for itself.
Write down the selector policy. Not the selectors, the policy. Without it, eight teams invent eight conventions and cross-team tests become impossible.
Remember this: the extended test object is your framework's front door. Layer it per team, own the core strictly, and split config so teams get fast feedback.
Reference: Test fixtures.