Playwright interview question
How do Playwright fixtures work? Build a custom fixture with test.extend.
Short answer
Fixtures are on-demand setup and teardown units injected by name into tests. Create them with test.extend: code before await use(value) is setup, after it is teardown. They can be test- or worker-scoped, depend on other fixtures, run automatically with auto: true, and replace beforeEach hooks and base classes.
Answer
A fixture is a named piece of setup and teardown that Playwright Test creates only when a test asks for it, by destructuring it from the test’s first argument. page, context, browser and request are built-in fixtures. You define your own with test.extend, which returns a new test function that knows about them. Fixtures replace most beforeEach/afterEach hooks and base-class inheritance, with better encapsulation and reuse.
Anatomy of a fixture
// fixtures.ts
import { test as base, expect, type Page } from '@playwright/test';
class CartPage {
constructor(private page: Page) {}
async open() { await this.page.goto('/cart'); }
items() { return this.page.getByRole('listitem'); }
}
type TestFixtures = {
cartPage: CartPage;
seededProduct: { id: string; name: string };
};
export const test = base.extend<TestFixtures>({
cartPage: async ({ page }, use) => {
await use(new CartPage(page));
},
seededProduct: async ({ request }, use) => {
// setup
const res = await request.post('/api/products', { data: { name: `Lamp ${Date.now()}` } });
expect(res.ok()).toBeTruthy();
const product = await res.json();
await use(product); // the test runs here
// teardown: runs even if the test failed
await request.delete(`/api/products/${product.id}`);
},
});
export { expect };
// cart.spec.ts
import { test, expect } from './fixtures';
test('added product shows in cart', async ({ page, cartPage, seededProduct }) => {
await page.goto(`/products/${seededProduct.id}`);
await page.getByRole('button', { name: 'Add to cart' }).click();
await cartPage.open();
await expect(cartPage.items()).toHaveText([seededProduct.name]);
});
Key mechanics:
- Everything before
await use(value)is setup; everything after is teardown. - Fixtures can depend on other fixtures (
seededProductusesrequest). Playwright builds the dependency graph and tears down in reverse order. - A test that does not mention
seededProductnever creates it. Hooks, by contrast, run for every test in scope.
Worker-scoped fixtures
Expensive resources that can be shared across tests in one worker process use { scope: 'worker' }:
type WorkerFixtures = { workerAccount: { email: string; password: string } };
export const test = base.extend<{}, WorkerFixtures>({
workerAccount: [async ({}, use, workerInfo) => {
const email = `qa+w${workerInfo.workerIndex}-${Date.now()}@example.com`;
// create the account once per worker via API...
await use({ email, password: 'S3cure!pass' });
// delete it when the worker shuts down
}, { scope: 'worker' }],
});
Worker fixtures are typed in the second generic parameter of extend. Using workerIndex keeps data unique across parallel workers.
Other options
{ auto: true }: runs for every test even if not requested, useful for attaching logs on failure or failing on console errors.- Option fixtures (
[defaultValue, { option: true }]) can be overridden per project in the config or withtest.use(). mergeTests(a, b)combines fixture sets from different modules.{ box: true }hides a helper fixture’s steps from the report.
Why fixtures beat hooks and base classes
- Explicit dependencies: the test signature shows what it needs.
- Encapsulated teardown next to setup, so cleanup is not forgotten.
- Lazy: no wasted setup for tests that do not need it.
- Composable: page objects, API clients, test data and auth can be layered without deep inheritance.
What interviewers look for
- Correct
test.extendsyntax withuse()and teardown. - Test versus worker scope, and typing via the generics.
- Understanding that fixtures are lazy and composable.
- Real uses: page objects, seeded data with cleanup, per-worker accounts.
A strong answer covers
- Defines fixtures with test.extend and injects by name
- Setup before use(), teardown after
- Test vs worker scope
- Lazy: only fixtures a test requests are created; fixtures can depend on each other
- Advantages over beforeEach/base classes; auto fixtures, mergeTests, page objects