Section 4 · End-to-End with Playwright
Fixtures and Test Isolation
20 min3 min readFill in the blanks
Lesson at a glance
Keep end-to-end tests independent with Playwright’s isolated browser contexts, custom fixtures that create and clean up their own data, saved login state and targeted network routing.
- 1Fresh context for every test
- 2Fixtures: setup and cleanup in one place
- 3Logging in once
- 4Routing: controlling what the server says
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
The most common reason an end-to-end suite becomes untrustworthy isn’t timing; it’s shared state. Test 12 passes alone and fails in the full run because test 7 renamed the group it uses. Or a test passes only on Mondays because the seed data has a “this week” expense. Isolation is the cure, and Playwright gives you most of it for free.
Fresh context for every test
Each test receives its own browser context: an incognito-like profile with its own cookies, localStorage, sessionStorage and cache. Opening a context is cheap (milliseconds, not a new browser), so Playwright creates a new one per test. That’s why you can run with fullyParallel: true across several workers: two tests can’t see each other’s logins or saved drafts.
The browser side is isolated. Your backend is not. If two tests edit the same group on the same server, they collide. The rest of this lesson is about isolating data.
Fixtures: setup and cleanup in one place
You’ve been using fixtures already: page in async ({ page }) => … is one. Playwright also ships context, browser, browserName and request (an HTTP client that shares baseURL). You define your own with test.extend:
import { test as base, expect } from '@playwright/test';
type Group = { id: string; name: string };
export const test = base.extend<{ group: Group }>({
group: async ({ request }, use) => {
// Setup: create a fresh group through the API
const response = await request.post('/api/groups', {
data: { name: `Trip ${crypto.randomUUID().slice(0, 8)}`, members: ['Ana', 'Ben'] },
});
expect(response.ok()).toBeTruthy();
const group: Group = await response.json();
await use(group); // the test runs here
// Teardown: runs after the test, pass or fail
await request.delete(`/api/groups/${group.id}`);
},
});
export { expect };import { test, expect } from './fixtures';
test('settling up clears every balance', async ({ page, group }) => {
await page.goto(`/groups/${group.id}`);
// …add an expense, click Settle up…
await expect(page.getByRole('status')).toHaveText('Everyone is settled up');
});Three properties make this better than a beforeEach:
- On demand. Only tests that list
groupin their parameters create one. Tests that don’t need it pay nothing. - Setup and teardown live together, around
await use(...), so cleanup can’t drift out of sync with setup. - Composable. A
groupfixture can depend onrequest; anexpensefixture could depend ongroup. Playwright resolves the order.
Creating data through the API rather than clicking through the UI keeps each test focused on one flow and saves seconds per test. One test clicks through “create a group”; the other fifty get a group from the fixture.
Logging in once
If Splitwise-lite requires login, clicking through the form in every test wastes time and hammers your auth server. Playwright’s pattern is a setup project: one test logs in and saves the browser’s storage state to a file, and the real projects declare a dependency on it and start already logged in:
// …inside defineConfig
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],The setup test ends with await page.context().storageState({ path: 'playwright/.auth/user.json' }). Add playwright/.auth to .gitignore: it contains real session cookies.
Routing: controlling what the server says
Some states are hard to produce with a real backend: a 500 on save, a slow response, an empty group from a legacy account. page.route intercepts requests from the page and lets you answer them:
test('explains a failed save', async ({ page, group }) => {
await page.route('**/api/expenses', (route) =>
route.fulfill({ status: 500, json: { error: 'Database unavailable' } }),
);
await page.goto(`/groups/${group.id}`);
await page.getByLabel('Description').fill('Dinner');
await page.getByLabel('Amount').fill('30');
await page.getByRole('button', { name: 'Add expense' }).click();
await expect(page.getByRole('alert')).toHaveText('Could not save the expense. Try again.');
});Use routing for the rare and the broken. If you find yourself routing every request, you’ve rebuilt a component test in a slower tool; let the happy paths hit the real backend, because that integration is what end-to-end tests are for.
Isolation also makes failures cheaper to debug. When a test fails, you can rerun that single test with npx playwright test -g "settling up" and get the same result, because nothing it depends on lives outside it. With hidden dependencies, the single rerun passes and the full run fails, and you’re left bisecting the suite to find which neighbour broke it.
In the exercise you complete a fixture that creates and deletes a group. Next lesson: what to do when a test fails anyway.
Key takeaways
- Every Playwright test gets a fresh browser context (its own cookies, storage and cache), so tests can run in parallel and in any order.
- A fixture created with
test.extendsets something up, hands it to the test withuse(), and tears it down afterwards, only for tests that ask for it. - Each test should create the data it needs (ideally through the API) instead of depending on shared seed data or on another test having run first.
- Log in once in a setup project, save the storage state, and reuse it, rather than clicking through the login form in every test.
- Use
page.routeto simulate rare server responses, and the real backend for the happy paths.