Playwright Fixtures vs Hooks: Same Setup Built Both Ways
Playwright fixtures vs hooks compared with one setup built both ways: lifecycle, dependency composition, cleanup, when to use each, and the real trade-offs.

Last updated 2026-10-16 · 16 min read · By SoftwareTestPilot
Most Playwright suites start with hooks. You add a beforeEach that opens the login page, an afterEach that deletes test data, and it works. A few months later the same block is copied into twenty spec files, one of them forgot the cleanup, and a beforeAll shares a variable that breaks the moment you turn on parallel workers. That is usually when someone asks whether the team should be using fixtures instead.
This article compares Playwright fixtures vs hooks by building the same setup both ways: a signed-in user, a product created through the API, and a cart page ready for the test. You will see how each approach handles lifecycle, dependencies between setup steps, cleanup, and failure. Then we look at where each one genuinely fits, because the honest answer is not "always use fixtures".
If fixtures are new to you, our Playwright fixtures deep dive covers the API in more detail. This guide focuses on the decision between the two styles.
The examples use a recent Playwright Test release with TypeScript and a fictional shop at a local base URL. The code was reviewed against the official documentation but not executed against a live application, so treat URLs, selectors and API endpoints as placeholders.
What hooks and fixtures are
Hooks are functions you register on test that run at fixed points: test.beforeEach and test.afterEach run around every test in their scope, and test.beforeAll and test.afterAll run once per worker for that scope. The scope is the file, or the test.describe block they are declared in. Hooks do not return anything to the test. If the test needs a value created in a hook, you store it in a variable both can see.
Fixtures are named dependencies. You define them with test.extend, and a test receives them by listing their names in its first argument, exactly like the built-in page, context and request. A fixture function does its setup, hands the value to the test with await use(value), and runs its teardown after use returns. Playwright only builds a fixture when something asks for it.
That one difference, pushed versus pulled, explains almost everything else in this comparison. Hooks push setup onto every test in a scope whether it needs it or not. Fixtures are pulled by the tests that declare them, and the runner works out what else has to be built first.
The setup built with hooks
Here is the scenario: each test needs a signed-in shopper, a fresh product created through the API so tests do not fight over shared data, and the cart page opened. After the test, the product must be deleted. A typical hooks version looks like this.
// tests/cart.hooks.spec.ts
import { test, expect, type APIRequestContext } from '@playwright/test';
let api: APIRequestContext;
let productId: string;
test.beforeAll(async ({ playwright }) => {
api = await playwright.request.newContext({
baseURL: 'http://localhost:3000',
extraHTTPHeaders: { Authorization: 'Bearer ' + process.env.API_TOKEN },
});
});
test.afterAll(async () => {
await api.dispose();
});
test.beforeEach(async ({ page }) => {
const res = await api.post('/api/products', { data: { name: 'Test mug', price: 499 } });
productId = (await res.json()).id;
await page.goto('/login');
await page.getByLabel('Email').fill('shopper@example.com');
await page.getByLabel('Password').fill(process.env.SHOPPER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.goto('/products/' + productId);
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.goto('/cart');
});
test.afterEach(async () => {
await api.delete('/api/products/' + productId);
});
test('shows the added product', async ({ page }) => {
await expect(page.getByRole('row', { name: /Test mug/ })).toBeVisible();
});
test('removes the product', async ({ page }) => {
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('Your cart is empty')).toBeVisible();
});
This is readable and it works for one file. Notice what it relies on. api and productId are module-level variables written by hooks and read by tests. The cleanup in afterEach depends on beforeEach having set productId; if product creation failed halfway, afterEach will try to delete undefined. And the moment a second spec needs a signed-in cart, you either copy this block or move it into a helper that every file must remember to call.
The same setup built with fixtures
Now the same behaviour as fixtures. Each concern becomes one named function: an API client, a product, a signed-in page and a ready cart. They live in one shared file that every spec imports instead of @playwright/test.
// fixtures/shop.ts
import { test as base, expect, type APIRequestContext, type Page } from '@playwright/test';
type Product = { id: string; name: string; price: number };
type TestFixtures = {
product: Product;
shopperPage: Page;
cartPage: Page;
};
type WorkerFixtures = {
api: APIRequestContext;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
// Worker-scoped: one API client per worker, disposed when the worker ends.
api: [async ({ playwright }, use) => {
const api = await playwright.request.newContext({
baseURL: 'http://localhost:3000',
extraHTTPHeaders: { Authorization: 'Bearer ' + process.env.API_TOKEN },
});
await use(api);
await api.dispose();
}, { scope: 'worker' }],
// Test-scoped: a fresh product per test, deleted after it.
product: async ({ api }, use) => {
const res = await api.post('/api/products', { data: { name: 'Test mug', price: 499 } });
const product = (await res.json()) as Product;
await use(product);
await api.delete('/api/products/' + product.id);
},
shopperPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('shopper@example.com');
await page.getByLabel('Password').fill(process.env.SHOPPER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await use(page);
},
cartPage: async ({ shopperPage, product }, use) => {
await shopperPage.goto('/products/' + product.id);
await shopperPage.getByRole('button', { name: 'Add to cart' }).click();
await shopperPage.goto('/cart');
await use(shopperPage);
},
});
export { expect };
// tests/cart.fixtures.spec.ts
import { test, expect } from '../fixtures/shop';
test('shows the added product', async ({ cartPage, product }) => {
await expect(cartPage.getByRole('row', { name: new RegExp(product.name) })).toBeVisible();
});
test('removes the product', async ({ cartPage }) => {
await cartPage.getByRole('button', { name: 'Remove' }).click();
await expect(cartPage.getByText('Your cart is empty')).toBeVisible();
});
The spec file is now only tests. There are no shared variables: the test receives product as a typed value instead of reading a module-level productId. Product deletion sits two lines below product creation, so a reviewer sees both together. Any other spec that needs a ready cart imports the same test and asks for cartPage. A spec that only needs the API asks for api and never opens a browser page at all.
Lifecycle: when each one runs
The two approaches run code at similar moments, but they decide whether to run very differently.
Hooks follow the structure of the file. For each test, Playwright runs every beforeEach from the outermost scope inwards, then the test, then every afterEach from the innermost scope outwards. beforeAll runs before the first test of its scope in a given worker, and afterAll after the last one. A test that does not need the hook's work still gets it. You can skip work inside a hook with conditions on testInfo, but that logic grows awkward fast.
Fixtures follow the dependency graph. When a test asks for cartPage, Playwright sees that it needs shopperPage and product, that product needs api, and that shopperPage needs the built-in page. It builds them in an order that satisfies those needs, runs the test, then tears them down in reverse. A test that asks only for product never triggers the login steps.
Scope works on both sides. Test-scoped fixtures are rebuilt for every test, much like beforeEach. Worker-scoped fixtures are built once per worker process and shared by the tests that run in it, which is what most people reach for beforeAll to do. The difference is that the worker fixture is tied to the worker that runs it, so it behaves correctly when tests are spread across workers or a failed test causes Playwright to start a fresh worker.
There is also automatic behaviour. A fixture declared with { auto: true } runs for every test even if no test names it, which gives you hook-like "always run" semantics while keeping setup and teardown together. Use it for genuinely global needs, such as attaching console logs to failed tests, not for setup that only some tests use.
export const test = base.extend<{ consoleLog: void }>({
consoleLog: [async ({ page }, use, testInfo) => {
const lines: string[] = [];
page.on('console', (msg) => lines.push(msg.type() + ': ' + msg.text()));
await use();
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('console', { body: lines.join('\n'), contentType: 'text/plain' });
}
}, { auto: true }],
});Dependency composition
This is where fixtures pull clearly ahead once a suite grows. Real setup is rarely one step. A ready cart needs a signed-in user and a product; a signed-in user may need an account created through the API; an admin test may need two users at once.
With hooks, ordering is expressed by position in the file and by nesting describe blocks. Data flows between steps through shared variables. To reuse a chain in another file you either copy it or wrap it in plain helper functions, and then every file must call them in the right order and handle cleanup itself. Nothing in the language tells you that afterEach depends on a variable set three hooks earlier.
With fixtures, a dependency is a parameter. cartPage lists shopperPage and product, and that is the whole contract. You can override one link of the chain for a group of tests without touching the rest. For example, a spec that tests discounts can supply a different product:
// tests/discount.spec.ts
import { test as shopTest, expect } from '../fixtures/shop';
const test = shopTest.extend({
product: async ({ api }, use) => {
const res = await api.post('/api/products', { data: { name: 'Sale mug', price: 999, discount: 20 } });
const product = await res.json();
await use(product);
await api.delete('/api/products/' + product.id);
},
});
test('applies 20% discount', async ({ cartPage }) => {
await expect(cartPage.getByTestId('cart-total')).toHaveText('₹799.20');
});
cartPage is unchanged, but it now receives the sale product, because fixtures resolve by name in the nearest test that defines them. Doing the same with hooks means duplicating the whole setup or adding flags to a shared helper. You can also use test.use({ ... }) to pass option fixtures, such as a user role or locale, into the same chain per file or per describe block.
Composition has a cost. A deep chain of five or six fixtures can make it hard to see what a test actually sets up. Keep each fixture focused on one concern, give it a name that says what the test receives, and keep the shared fixture file short enough to read in one sitting. Our Playwright framework setup with TypeScript guide shows one way to organise that file in a larger project.
Cleanup and failure handling
Cleanup is the most practical reason teams switch. Leftover test data causes the hardest flaky failures: duplicate-name errors, counts that are off by one, and tests that pass alone but fail in a full run.
With hooks, teardown lives in a separate function. Playwright does run afterEach and afterAll even when a test fails, which is good. The weak point is the link between setup and teardown. If beforeEach fails after creating the product but before setting the variable, the product leaks. If someone adds a second resource to beforeEach and forgets afterEach, it leaks quietly. If beforeEach throws on the first line, afterEach still runs and may act on stale values from the previous test, because module variables keep their old value.
With fixtures, teardown is the code after await use() in the same function. It runs after the test whether the test passed or failed. If setup throws before use, there is nothing to tear down for that fixture, and fixtures that were already built are still torn down in reverse order. The value is a local variable, so there is no stale state from an earlier test. When cleanup itself might fail, wrap the whole fixture body so a failed delete does not hide the real test failure:
product: async ({ api }, use) => {
const res = await api.post('/api/products', { data: { name: 'Test mug', price: 499 } });
expect(res.ok()).toBeTruthy();
const product = await res.json();
try {
await use(product);
} finally {
const del = await api.delete('/api/products/' + product.id);
if (!del.ok()) console.warn('cleanup failed for product', product.id, del.status());
}
},
Strictly speaking, the try/finally is optional here because Playwright already runs the code after use when the test fails. It is useful as a clear signal to readers that cleanup is intentional, and it protects you if you later add steps between use and the delete.
Retries also favour fixtures. When a test fails and is retried, Playwright starts a new worker, and worker-scoped fixtures are rebuilt there. A beforeAll that writes to a module variable also re-runs in the new worker, but code that assumed "this ran once for the whole suite" can now create data twice. Treating the worker as the unit of sharing, which is what worker fixtures do by design, avoids that surprise.
Side-by-side comparison
| Aspect | Hooks | Fixtures |
|---|---|---|
| Runs when | For every test in the file or describe block | Only when a test or another fixture requests it (or auto: true) |
| Passing values to tests | Shared variables in the module | Typed parameter on the test function |
| Reuse across files | Copy the block or call helpers from each file | Import one extended test |
| Dependencies between steps | Implied by order and nesting | Declared as parameters; resolved by the runner |
| Cleanup | Separate afterEach/afterAll | Code after await use() in the same function |
| Once-per-worker setup | beforeAll plus module variables | { scope: 'worker' } |
| Overriding for a group of tests | New hooks or flags in helpers | test.extend override or test.use options |
| Learning curve | Very low; familiar from other runners | Moderate; use() and scopes take practice |
| Best for | Small, file-local steps | Shared, dependent or resource-owning setup |
When hooks are the better choice
Hooks are not a beginner mistake. They are the right tool in several situations, and replacing them with fixtures there only adds indirection.
- File-local navigation. If every test in one spec starts on the same page and no other file needs that step, a one-line
beforeEachwithpage.gotois the clearest code you can write. - Steps that need no value and no cleanup. Dismissing a cookie banner or setting a viewport for a single file does not benefit from a named fixture.
- Small suites and prototypes. When you have three spec files and one person maintaining them, hooks keep everything visible in the file you are reading.
- Grouping inside a file. A
beforeEachinside adescribeblock reads naturally for "these four tests all start with the filter panel open".
A useful rule: if the hook is short, used in one file, and neither returns a value nor creates something that must be deleted, keep it as a hook. Hooks and fixtures also work together. A spec can import your fixture-based test and still declare a beforeEach that receives fixtures, for example test.beforeEach(async ({ cartPage }) => ...).
When fixtures are the better choice
Move setup into a fixture when any of these are true:
- More than one file needs it. Signed-in users, API clients and seeded data are almost always shared.
- It creates something that must be removed. Users, orders, products, uploaded files and feature flags belong in fixtures so teardown sits next to setup.
- It depends on other setup. If step B needs the output of step A, declare that as a dependency rather than relying on hook order.
- Only some tests need it. An expensive setup step should not run for tests that do not use it.
- You reach for
beforeAllto share an expensive resource. A worker-scoped fixture expresses the same intent and behaves predictably with parallel workers and retries. - Different groups need variations. Option fixtures and overrides let you change a role, a locale or a dataset without copying setup.
For signed-in state specifically, Playwright also supports saving browser storage once and reusing it. That pairs well with fixtures: a setup project logs in once and saves storageState, and test fixtures start already authenticated instead of filling the login form each time. Our page object model with TypeScript guide shows how fixtures can also hand tests ready-made page objects instead of raw pages.
Learn practical Playwright patterns in the Playwright course on SoftwareTestPilot Academy.
Migrating from hooks to fixtures step by step
You do not need to rewrite a suite in one pull request. A gradual path keeps every step reviewable:
- Create one shared fixture file that re-exports
testandexpectfrombase.extend, with no fixtures yet. Point new specs at it. - Pick the most copied hook, usually login or API client creation, and turn it into a fixture. Replace the hook in the files that used it.
- Move data creation next, pairing each create call with its delete after
use. Remove the matchingafterEachblocks. - Replace
beforeAllsharing with worker-scoped fixtures, and delete module-level variables as you go. Run the suite with several workers to confirm nothing relied on shared state. - Leave file-local hooks alone where they are short and clear.
After each step, run the affected specs with --repeat-each and multiple workers. Isolation problems that were hidden by a fixed order usually show up quickly that way. If you use TypeScript, also run tsc --noEmit in CI; Playwright strips types when it runs tests, so a wrongly typed fixture will not stop the run on its own.
Honest trade-offs
Fixtures solve real problems, but they are not free.
- Indirection. A test that asks for
cartPagehides five steps behind one name. New team members must open the fixture file to understand what happens. Good names and a short fixture file reduce this, but do not remove it. - Debugging setup. When a fixture fails, the error points to the fixture, not the test. The trace viewer and the fixture names in the HTML report help, but expect a small learning curve.
- Over-abstraction. It is easy to turn every two-line step into a fixture. If a fixture is used by one test, inline it.
- Hooks are universal. Anyone who has used Jest, Mocha or JUnit can read hooks immediately, which matters on mixed-experience teams.
On the other side, hooks carry their own long-term costs: copied setup across files, shared mutable variables, teardown that drifts from setup, and setup that runs for tests that never use it. In a suite of a handful of files those costs are small. In a suite of hundreds of tests run in parallel in CI, they become the main source of flaky failures.
A practical balance for most teams is: shared, dependent or resource-owning setup in fixtures; short file-local steps in hooks; and no module-level variables written by hooks and read by tests.
How to explain this in an interview
Interviewers often ask "fixtures or hooks?" to see whether you understand isolation, not to hear a preference. A strong answer is short and concrete:
Hooks run around every test in a file or describe block, and share data through variables. Fixtures are named dependencies that a test requests; Playwright builds them on demand, resolves their dependencies, and runs teardown after
use. I keep short file-local steps as hooks, and move shared setup, test data with cleanup and once-per-worker resources into fixtures, because they stay correct with parallel workers and retries.
Be ready to write a fixture from memory, explain test versus worker scope, and say what happens if use is never called (the test hangs until timeout). For more practice, see our Playwright TypeScript interview questions and the Playwright interview questions hub.
Summary
Both approaches can produce the same signed-in cart. The difference shows up as a suite grows. Hooks push the same setup onto every test in a scope and pass values through shared variables, which is simple and fine for local steps. Fixtures are pulled by name, compose through declared dependencies, keep teardown beside setup, and scale cleanly across files, workers and retries. Start with hooks where they are clear, move setup into fixtures when it is shared or owns a resource, and keep the fixture file small enough that anyone can read it.
For a deeper look at the fixture API itself, read the Playwright fixtures deep dive, then put both patterns into a real project with the framework setup guide.
Learn practical Playwright patterns in the Playwright course on SoftwareTestPilot Academy.
Key takeaways
- 1Hooks run for every test in their scope; fixtures run only when a test (or another fixture) asks for them by name.
- 2A fixture keeps setup and teardown in one function around await use(), so cleanup cannot drift away from the setup it undoes.
- 3Fixtures compose: one fixture can depend on others, and Playwright resolves the order and tears down in reverse.
- 4Worker-scoped fixtures replace most beforeAll code and stay correct when tests run in parallel or are retried.
- 5Hooks remain the clearest choice for small, file-local steps such as opening one page before every test in a spec.
Common mistakes to avoid
- Sharing state between beforeAll and tests through module-level let variables, which breaks under parallel workers and retries.
- Putting cleanup in afterEach far from the setup, then forgetting to update it when setup changes.
- Creating an auto fixture for something only a few tests need, so every test pays the cost.
- Forgetting to call await use(...) in a fixture, which makes the test hang until timeout.
- Copying the same beforeEach into many spec files instead of moving it into a shared fixture.
References
- 1Playwright: Fixtures— playwright.dev
- 2Playwright: Test hooks (API)— playwright.dev
- 3Playwright: Parallelism— playwright.dev
- 4Playwright: Test retries— playwright.dev
