The intermediate track taught custom fixtures: setup before use, teardown after. This lecture is about the four capabilities that turn fixtures into an architecture, and the judgement each one requires.
Capability one: scope.
A fixture is either test-scoped (created for every test, the default) or worker-scoped (created once per worker process, shared by every test that worker runs).
export const test = base.extend<TestFixtures, WorkerFixtures>({
contact: async ({ api }, use) => {
const created = await api.createContact(uniqueContact());
await use(created);
await api.deleteContact(created.id);
},
adminToken: [async ({}, use) => {
const token = await fetchAdminToken();
await use(token);
}, { scope: 'worker' }],
});
Note the type parameters: test fixtures first, worker fixtures second. Getting these the wrong way round is a common and confusing error.
⚠️ Worker scope is a sharing decision, not a caching decision.
Anything worker-scoped is shared by every test in that worker. A read-only token is fine. A record that tests modify is a shared-state bug that will fail differently depending on how many workers you run, which makes it maddening to reproduce.
The rule: worker scope for things you read, test scope for things you change.
Capability two: automatic fixtures.
An automatic fixture runs for every test whether the test asks for it or not:
timezone: [async ({}, use) => {
process.env.TZ = 'UTC';
await use();
}, { auto: true }],
Useful for cross-cutting concerns: environment setup, per-test logging, attaching diagnostics on failure. Use sparingly. An automatic fixture is invisible at the call site, so a new engineer reading a spec cannot see that it runs. Every one you add is a small tax on comprehension.
Capability three: option fixtures.
These are configurable from the config file, which is how you build a framework other teams tune without editing your code:
export const test = base.extend<{ userRole: string }>({
userRole: ['standard', { option: true }],
authedPage: async ({ browser, userRole }, use) => {
const context = await browser.newContext({ storageState: `.auth/${userRole}.json` });
await use(await context.newPage());
await context.close();
},
});
Then in config:
projects: [
{ name: 'admin', use: { userRole: 'admin' } },
{ name: 'standard', use: { userRole: 'standard' } },
],
The same tests now run as different roles, chosen by project. This composition of option fixtures with projects is the most powerful pattern in Playwright and the least used.
Capability four: overriding built-ins.
You can redefine page, context, or any built-in fixture:
page: async ({ page }, use) => {
page.on('console', (msg) => {
if (msg.type() === 'error') console.log('[browser error]', msg.text());
});
await use(page);
},
Every test now logs browser errors, with no test changing. This is the right tool for genuine cross-cutting concerns.
It carries the same warning as overwriting a Cypress command: a new engineer reads the Playwright docs for page, and your project has changed what it means. Only do this for behaviour that must apply everywhere, and document it where a new joiner will find it.
The judgement that matters most. Every fixture you add is available to every test, forever, and shows up in autocomplete. A framework with forty fixtures is as hard to learn as no framework at all.
The check I apply before adding one: would three or more specs use this, and does it need setup or teardown? If not, a plain function is clearer, cheaper and easier to find.
Remember this: scope decides sharing, options make it configurable, automatic fixtures are invisible so use them rarely, and every fixture is a permanent addition to the vocabulary.
Reference: Advanced fixtures.