Page Objects & Fixtures
Page object classes, custom fixtures and reusing a logged-in state with storageState.
Page objects in Playwright
The Page Object Model works naturally in Playwright: a class holds the page's locators and actions, and tests describe behaviour.
// pages/TodoPage.ts
import { type Locator, type Page, expect } from '@playwright/test';
export class TodoPage {
readonly newTodo: Locator;
readonly items: Locator;
readonly count: Locator;
constructor(readonly page: Page) {
this.newTodo = page.getByPlaceholder('What needs to be done?');
this.items = page.getByTestId('todo-item');
this.count = page.getByTestId('todo-count');
}
async goto() {
await this.page.goto('https://demo.playwright.dev/todomvc');
}
async add(...titles: string[]) {
for (const title of titles) {
await this.newTodo.fill(title);
await this.newTodo.press('Enter');
}
}
item(title: string): Locator {
return this.items.filter({ hasText: title });
}
async complete(title: string) {
await this.item(title).getByRole('checkbox').check();
}
async remove(title: string) {
await this.item(title).hover();
await this.item(title).getByRole('button', { name: 'Delete' }).click();
}
async expectRemaining(n: number) {
await expect(this.count).toContainText(`${n} item${n === 1 ? '' : 's'} left`);
}
}
// tests/todo-pom.spec.ts
import { test, expect } from '@playwright/test';
import { TodoPage } from '../pages/TodoPage';
test('tracks remaining items', async ({ page }) => {
const todos = new TodoPage(page);
await todos.goto();
await todos.add('Write tests', 'Fix bug', 'Deploy');
await todos.complete('Fix bug');
await todos.expectRemaining(2);
await expect(todos.item('Fix bug')).toHaveClass(/completed/);
});
test('deletes an item', async ({ page }) => {
const todos = new TodoPage(page);
await todos.goto();
await todos.add('Temporary task');
await todos.remove('Temporary task');
await expect(todos.items).toHaveCount(0);
});
Locators are created once in the constructor; because locators are lazy, they always find the current element.
Fixtures: Playwright's superpower
Fixtures provide everything a test needs — and clean it up afterwards. You've already used built-in ones: page, context, browser, request, browserName. You can define your own with test.extend, so tests receive ready-made page objects:
// fixtures.ts
import { test as base } from '@playwright/test';
import { TodoPage } from './pages/TodoPage';
type Fixtures = {
todoPage: TodoPage;
};
export const test = base.extend<Fixtures>({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await todoPage.add('Seeded item'); // setup
await use(todoPage); // the test runs here
// teardown (if needed) goes after use()
},
});
export { expect } from '@playwright/test';
// tests/todo-fixture.spec.ts
import { test, expect } from '../fixtures';
test('starts with one seeded item', async ({ todoPage }) => {
await expect(todoPage.items).toHaveCount(1);
});
test('can add a second item', async ({ todoPage }) => {
await todoPage.add('Another item');
await todoPage.expectRemaining(2);
});
Fixtures are created only when a test asks for them, and each test gets its own copy — no shared state.
Reusing a logged-in state with storageState
Logging in through the UI before every test is slow. Instead, log in once in a setup project, save cookies and local storage to a file, and load that file in every test.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('https://the-internet.herokuapp.com/login');
await page.getByLabel('Username').fill('tomsmith');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD ?? 'SuperSecretPassword!');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL(/\/secure$/);
await page.context().storageState({ path: authFile });
});
// playwright.config.ts (projects section)
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
// tests/secure.spec.ts — already logged in
import { test, expect } from '@playwright/test';
test('secure area is available without logging in again', async ({ page }) => {
await page.goto('https://the-internet.herokuapp.com/secure');
await expect(page.getByRole('heading', { name: 'Secure Area' })).toBeVisible();
});
Add playwright/.auth to .gitignore — it contains session cookies.
Hooks and grouping
test.describe('Cart', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/shop');
});
test('adds an item', async ({ page }) => { /* ... */ });
test.skip('coupon stacking', async () => {}); // skip with a reason in a ticket
test.fixme('broken on Safari', async () => {}); // known issue, not run
});
Prefer fixtures over beforeEach when setup is shared across files. Next: API Testing & Network Mocking.