Test Data and Isolation
Seed state before a run, intercept calls with page.route, and give each test data it owns so a shared environment cannot flake it.
await page.route('**/api/projects', route => route.fulfill({ json: [] }))A test that reads data has to know what the data is. There are two honest ways to arrange that: create it through the API before the test runs, or intercept the request and answer it yourself.
Seed what you are about to assert on
Have the test environment expose an endpoint that creates a record, and call it from the test with Playwright's request fixture. Make the data unique per worker, for example by appending testInfo.parallelIndex to a name, so two workers running at once cannot collide. Reset state in the test that created it, not in a global teardown that may never run.
Intercept the network
page.route(url, handler) catches matching requests before they leave the browser. route.fulfill({ json }) answers with your payload and route.abort('failed') simulates a dead connection, which is how you test an error panel without breaking a server. In Cypress the same job is cy.intercept('GET', '/api/projects', { fixture: 'projects.json' }).as('projects') followed by cy.wait('@projects'). Stub third parties and failure paths, but keep at least one test against the real API so a contract change still fails somewhere.
Shared environments are where flakiness comes from
Two pipelines against one staging database means one test deletes the project another is asserting on. The symptom is a failure that passes on retry. The fix is ownership: per-worker accounts, data created by the test that needs it, and no test asserting on a row it did not create.
Example
// tests/e2e/projects.spec.ts
import { test, expect } from '@playwright/test'
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', async route => {
await route.fulfill({
json: [
{ id: 1, name: 'Harbour survey', status: 'active' },
{ id: 2, name: 'Tide tables', status: 'archived' },
],
})
})
})
test('the list shows only active projects by default', async ({ page }) => {
await page.goto('/projects')
const rows = page.getByRole('row')
await expect(rows.filter({ hasText: 'Harbour survey' })).toBeVisible()
await expect(rows.filter({ hasText: 'Tide tables' })).toHaveCount(0)
})
test('a failed request shows the error panel', async ({ page }) => {
await page.route('**/api/projects', route => route.abort('failed'))
await page.goto('/projects')
await expect(page.getByRole('alert')).toContainText('Could not load projects')
})When to use it
- Render a list test against a fixed payload instead of whatever staging happens to hold.
- Test the error panel by aborting the request rather than taking the API down.
- Stop two CI pipelines from deleting each other's test records.
- Skip the sign-in form in every test but the one that is about signing in.
More examples
Seeding through the API, one record per worker
The request fixture issues HTTP calls outside the page, using the same baseURL as page.goto, so setup costs one request instead of a click-through. parallelIndex is the index of the worker process, so each worker writes its own record and no two tests fight over one row. Reach for this when the thing under test is the real round trip; reach for page.route when you only need the screen to render a known shape.
// tests/e2e/projects-seeded.spec.ts
import { test, expect } from '@playwright/test'
test('a project created over the API appears in the list', async ({
page,
request,
}, testInfo) => {
const name = `Harbour survey ${testInfo.parallelIndex}`
const created = await request.post('/test/projects', { data: { name } })
expect(created.ok()).toBe(true)
await page.goto('/projects')
await expect(page.getByRole('link', { name })).toBeVisible()
})Cypress: fixture for the happy path, status code for the sad one
The fixture lives in cypress/fixtures/projects.json, which keeps a long payload out of the spec and lets several specs share one shape. Both Cypress and Playwright match the most recently registered handler first, which is why the intercept inside the second test overrides the one from beforeEach. cy.wait('@failed') makes the test wait for the request itself rather than for the panel, so a never-sent request fails with a clear message.
// cypress/e2e/projects.cy.js
describe('project list', () => {
beforeEach(() => {
cy.intercept('GET', '**/api/projects', { fixture: 'projects.json' }).as('projects')
})
it('renders the stubbed projects', () => {
cy.visit('/projects')
cy.wait('@projects')
cy.contains('a', 'Harbour survey').should('be.visible')
})
it('shows the error panel on a 500', () => {
cy.intercept('GET', '**/api/projects', { statusCode: 500, body: {} }).as('failed')
cy.visit('/projects')
cy.wait('@failed')
cy.get('[role=alert]').should('contain.text', 'Could not load projects')
})
})Sign in once, reuse the session everywhere
storageState writes the cookies and localStorage of the signed-in context to a file. The chromium project depends on setup, so the sign-in runs once and every other test starts already authenticated, which removes a form fill from twenty tests. Keep playwright/.auth out of version control, and keep one test that still signs in through the form, because this path no longer covers it.
// tests/e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
const authFile = 'playwright/.auth/user.json'
setup('sign in once', async ({ page }) => {
await page.goto('/sign-in')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('correct-horse')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByRole('heading', { name: 'Your projects' })).toBeVisible()
await page.context().storageState({ path: authFile })
})
// playwright.config.ts, inside defineConfig:
// projects: [
// { name: 'setup', testMatch: /.*\.setup\.ts/ },
// {
// name: 'chromium',
// use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
// dependencies: ['setup'],
// },
// ]
Discussion