Fixtures, Hooks and Logging In Once
On this page
Every test so far has asked for { page }. That object is a fixture: a value the runner sets up before the test, hands to it, and tears down afterwards. Fixtures are how Playwright Test shares setup between tests without shared mutable state, and they are also the cleanest way to solve the problem every real suite hits early: logging in.
The built-in fixtures#
| Fixture | Scope | What you get |
|---|---|---|
page |
test | a new page in a new browser context |
context |
test | the browser context that page belongs to |
browser |
worker | the browser process, shared by tests in a worker |
browserName |
worker |
'chromium', 'firefox' or 'webkit'
|
request |
test | an API client for HTTP calls without a browser |
Fixtures are created lazily. A test that only asks for request never launches a browser.
Hooks#
Hooks are familiar from other runners and still useful for simple, file-local setup:
import { test, expect } from '@playwright/test';
test.describe('todo filters', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc/');
});
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
console.log(`${testInfo.title} failed on ${page.url()}`);
}
});
test('starts empty', async ({ page }) => {
await expect(page.getByTestId('todo-item')).toHaveCount(0);
});
});
beforeAll and afterAll also exist. Note that they run once per worker, not once per run. With four workers, a beforeAll that seeds a database row runs up to four times. Keep expensive one-time setup out of beforeAll; the setup projects later in this chapter are the right tool.
A beforeAll inserts one seed row into a shared database. With four workers you find four copies of the row. Why?
Each worker is a separate process that runs the file's hooks itself, so the hook runs up to once per worker. Truly one-time setup belongs in a setup project.
Writing your own fixture#
Hooks get repetitive once five files need the same "open the app and prepare a helper" code. A custom fixture packages that setup once. Create tests/fixtures.ts:
import { test as base, expect, type Locator, type Page } from '@playwright/test';
class TodoApp {
readonly newTodo: Locator;
readonly items: Locator;
constructor(readonly page: Page) {
this.newTodo = page.getByPlaceholder('What needs to be done?');
this.items = page.getByTestId('todo-item');
}
async add(...titles: string[]) {
for (const title of titles) {
await this.newTodo.fill(title);
await this.newTodo.press('Enter');
}
}
}
export const test = base.extend<{ todoApp: TodoApp }>({
todoApp: async ({ page }, use) => {
await page.goto('https://demo.playwright.dev/todomvc/');
await use(new TodoApp(page));
// anything after use() is teardown, and runs even if the test failed
},
});
export { expect };
Tests import test from this file instead of from @playwright/test:
import { test, expect } from './fixtures';
test('filters active items', async ({ todoApp, page }) => {
await todoApp.add('Buy milk', 'Walk the dog');
await todoApp.items
.filter({ hasText: 'Buy milk' })
.getByRole('checkbox', { name: 'Toggle Todo' })
.check();
await page.getByRole('link', { name: 'Active' }).click();
await expect(todoApp.items).toHaveText(['Walk the dog']);
});
Why this beats a beforeEach:
- The setup only runs for tests that ask for
todoApp. - Setup and teardown sit next to each other, around
use(). - Fixtures can depend on other fixtures (
todoAppdepends onpage), and the runner works out the order. - TypeScript knows the type of
todoApp, so you get autocompletion in every test.
TodoApp here is a small page object. If you want to design those properly, with components and navigation between pages, the Page Object Model course covers the pattern in depth; the fixture shown here is the standard way to hand those objects to Playwright tests.
Two fixture options you will meet in other codebases: { scope: 'worker' } creates the value once per worker process (for example a test account per worker), and { auto: true } runs the fixture for every test even if no test names it, which is handy for global logging or for marking test results somewhere.
Your todoApp fixture creates a todo list through the API and should delete it after the test, even when the test fails. Where does the delete call go?
Code after use() is the fixture's teardown and runs even if the test failed. Keeping setup and teardown together is the point of a fixture; code before use() would delete the list before the test sees it.
Logging in once with a setup project#
Logging in through the UI at the start of every test is slow and puts your login form on the critical path of every test. Playwright's answer is to log in once, save the browser context's cookies and local storage to a file, and start every test from that saved state.
The building blocks are a setup project and storageState. First, a setup file, tests/auth.setup.ts:
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('log in once', async ({ page }) => {
await page.goto('https://the-internet.herokuapp.com/login');
await page.getByLabel('Username').fill(process.env.APP_USER ?? 'tomsmith');
await page.getByLabel('Password').fill(process.env.APP_PASSWORD ?? 'SuperSecretPassword!');
await page.getByRole('button', { name: 'Login' }).click();
// Wait until login has really finished before saving cookies
await expect(page.getByRole('heading', { name: 'Secure Area', exact: true })).toBeVisible();
await page.context().storageState({ path: authFile });
});
(The fallback credentials are the public ones printed on that demo page. For your own app, read them from environment variables and never commit them.)
Then wire it into playwright.config.ts:
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
dependencies: ['setup'] guarantees the setup project finishes before any browser project starts. Dependent projects only run once every setup test has passed, so a broken login stops the run instead of producing a hundred confusing failures. Unlike the older globalSetup function, a setup project shows up in the HTML report and can record a trace, so a broken login is easy to debug.
Now a test simply starts logged in:
import { test, expect } from '@playwright/test';
test('secure area opens without logging in again', async ({ page }) => {
await page.goto('https://the-internet.herokuapp.com/secure');
await expect(page.getByRole('link', { name: 'Logout' })).toBeVisible();
});
test.describe('logged out', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('secure area redirects to login', async ({ page }) => {
await page.goto('https://the-internet.herokuapp.com/secure');
await expect(page).toHaveURL(/\/login$/);
});
});
test.use overrides an option for one file or describe block; here it gives the logged-out tests an empty state.
Three practical notes:
- The generated
.gitignorealready excludesplaywright/.auth/. Keep it that way; the file holds live session cookies. - If tests change server-side state for the user (settings, a cart), parallel tests sharing one account will collide. Use one account per worker (a worker-scoped fixture) or per role.
- To run a single spec without re-running login,
npx playwright test --no-depsskips dependencies and uses the state file already on disk.
Someone breaks the login form. The browser projects declare dependencies: ['setup']. What does the run look like?
Dependent projects only run once every setup test has passed, so you get one clear failure instead of a wall of confusing ones. Reusing the old state file only happens when you pass --no-deps yourself.
If the application under test is only reachable from your machine or private network, the browser still needs a route to it once you move to a cloud grid; our local website testing course explains how a tunnel handles that.
The suite now has reusable setup and an authenticated starting point. The next chapter is about what to do when one of these tests fails.