Day 7: Fixtures and Page Object Model
What You Will Learn Today
- Playwright's built-in fixtures: page, browser, context, request, browserName
- How fixtures provide test isolation
- Creating custom fixtures with test.extend()
- Fixture scope: test vs worker
- Fixture composition and dependencies
- The Page Object Model (POM) design pattern
- Creating POM classes and integrating them with fixtures
- Organizing fixtures and page objects in a project
- DRY vs WET in test code
What Are Fixtures?
Fixtures are a mechanism for providing the environment and data required for test execution. In Playwright, you receive fixtures as arguments to your test function, and the framework automatically manages resources like browsers and pages.
flowchart TB
subgraph Fixture["Role of Fixtures"]
Setup["Setup\nPrepare resources"]
Test["Test Execution"]
Teardown["Teardown\nClean up resources"]
Setup --> Test --> Teardown
end
subgraph Isolation["Test Isolation"]
T1["Test 1\nIts own page"]
T2["Test 2\nIts own page"]
T3["Test 3\nIts own page"]
end
style Fixture fill:#3b82f6,color:#fff
style Isolation fill:#22c55e,color:#fff
Built-in Fixtures
Playwright provides several built-in fixtures that are ready to use out of the box.
page
The most commonly used fixture. A new browser context and page are created for each test.
import { test, expect } from '@playwright/test'
test('verify page title', async ({ page }) => {
await page.goto('https://example.com')
await expect(page).toHaveTitle('Example Domain')
})
browser
Access the browser instance directly. Useful when you need to create multiple contexts manually.
test('operate with multiple contexts', async ({ browser }) => {
const context1 = await browser.newContext()
const context2 = await browser.newContext()
const page1 = await context1.newPage()
const page2 = await context2.newPage()
// Simultaneous operations in different sessions
await page1.goto('https://example.com')
await page2.goto('https://example.com')
await context1.close()
await context2.close()
})
context
Access the current browser context. Handy for manipulating cookies and storage.
test('set cookies before test', async ({ context, page }) => {
await context.addCookies([{
name: 'session',
value: 'abc123',
domain: 'example.com',
path: '/',
}])
await page.goto('https://example.com/dashboard')
await expect(page.locator('.user-name')).toBeVisible()
})
request
Send API requests without a browser.
test('fetch data from API', async ({ request }) => {
const response = await request.get('https://api.example.com/users')
expect(response.ok()).toBeTruthy()
const users = await response.json()
expect(users.length).toBeGreaterThan(0)
})
browserName
Get the name of the currently running browser. Useful for browser-specific logic.
test('browser-specific behavior', async ({ page, browserName }) => {
test.skip(browserName === 'webkit', 'Not supported on WebKit')
await page.goto('https://example.com')
// Chromium/Firefox-specific tests
})
Built-in Fixtures Summary
| Fixture | Scope | Description |
|---|---|---|
| page | test | Isolated page per test |
| browser | worker | Shared browser instance |
| context | test | Browser context per test |
| request | test | API request context |
| browserName | worker | Name of the running browser |
Test Isolation Through Fixtures
Each test receives its own independent fixtures, preventing interference between tests.
// Test 1 and Test 2 each have their own page
test('search for products', async ({ page }) => {
await page.goto('https://shop.example.com')
await page.fill('#search', 'keyboard')
await page.click('button[type="submit"]')
// This page's state does not affect Test 2
})
test('add to cart', async ({ page }) => {
await page.goto('https://shop.example.com/product/1')
await page.click('#add-to-cart')
// Completely independent from Test 1's page
})
Creating Custom Fixtures
Use test.extend() to define your own fixtures.
Basic Custom Fixture
// fixtures.ts
import { test as base } from '@playwright/test'
// Type definitions for custom fixtures
type MyFixtures = {
todoPage: Page
}
export const test = base.extend<MyFixtures>({
todoPage: async ({ page }, use) => {
// Setup: navigate and prepare data
await page.goto('https://demo.playwright.dev/todomvc/')
await page.fill('.new-todo', 'Buy groceries')
await page.press('.new-todo', 'Enter')
await page.fill('.new-todo', 'Clean house')
await page.press('.new-todo', 'Enter')
// Provide the fixture to the test
await use(page)
// Teardown: cleanup runs after use()
},
})
export { expect } from '@playwright/test'
// tests/todo.spec.ts
import { test, expect } from '../fixtures'
test('displays 2 TODO items', async ({ todoPage }) => {
const items = todoPage.locator('.todo-list li')
await expect(items).toHaveCount(2)
})
test('mark TODO as completed', async ({ todoPage }) => {
await todoPage.locator('.todo-list li').first().locator('.toggle').click()
const completed = todoPage.locator('.todo-list li.completed')
await expect(completed).toHaveCount(1)
})
Authenticated User Fixture
import { test as base, Page } from '@playwright/test'
type AuthFixtures = {
authenticatedPage: Page
}
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Log in via the UI
await page.goto('/login')
await page.fill('#email', 'user@example.com')
await page.fill('#password', 'password123')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
await use(page)
// Logout during teardown
await page.goto('/logout')
},
})
Fixture Scope
Fixtures have two possible scopes.
Test Scope (Default)
Setup and teardown run for every test.
export const test = base.extend<{ tempDir: string }>({
tempDir: async ({}, use) => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-'))
await use(dir)
await fs.rm(dir, { recursive: true })
},
})
Worker Scope
Setup runs once per worker process. Ideal for expensive initialization.
import { test as base } from '@playwright/test'
type WorkerFixtures = {
apiServer: string
}
export const test = base.extend<{}, WorkerFixtures>({
apiServer: [async ({}, use) => {
// Start a server (once per worker)
const server = await startTestServer()
await use(server.url)
await server.close()
}, { scope: 'worker' }],
})
| Scope | Setup Frequency | Use Case |
|---|---|---|
| test | Every test | Page operations, test data |
| worker | Once per worker | Server startup, DB connections |
Fixture Composition and Dependencies
Fixtures can depend on other fixtures. Playwright resolves the dependency graph automatically.
import { test as base, Page } from '@playwright/test'
type Fixtures = {
dbConnection: DatabaseConnection
seedData: TestData
adminPage: Page
}
export const test = base.extend<Fixtures>({
// Foundation fixture
dbConnection: async ({}, use) => {
const db = await connectToTestDB()
await use(db)
await db.close()
},
// Depends on dbConnection
seedData: async ({ dbConnection }, use) => {
const data = await dbConnection.seed({
users: [{ name: 'Admin', role: 'admin' }],
products: [{ name: 'Widget', price: 100 }],
})
await use(data)
await dbConnection.cleanup()
},
// Depends on seedData and page
adminPage: async ({ page, seedData }, use) => {
await page.goto('/login')
await page.fill('#email', seedData.users[0].email)
await page.fill('#password', 'password')
await page.click('button[type="submit"]')
await use(page)
},
})
flowchart TB
subgraph Dependencies["Fixture Dependency Graph"]
DB["dbConnection"]
Seed["seedData"]
Admin["adminPage"]
Page["page (built-in)"]
DB --> Seed
Seed --> Admin
Page --> Admin
end
style Dependencies fill:#8b5cf6,color:#fff
Page Object Model (POM)
The Page Object Model is a design pattern that encapsulates page UI operations into classes. It hides implementation details from test code and improves maintainability.
flowchart LR
subgraph Without["Without POM"]
T1["Test 1\nHardcoded selectors"]
T2["Test 2\nHardcoded selectors"]
T3["Test 3\nHardcoded selectors"]
end
subgraph With["With POM"]
POM["LoginPage\nCentralized selectors"]
TA["Test 1"]
TB["Test 2"]
TC["Test 3"]
POM --> TA
POM --> TB
POM --> TC
end
style Without fill:#ef4444,color:#fff
style With fill:#22c55e,color:#fff
Creating POM Classes
Basic POM Class
// pages/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.locator('#email')
this.passwordInput = page.locator('#password')
this.submitButton = page.locator('button[type="submit"]')
this.errorMessage = page.locator('.error-message')
}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
async expectError(message: string) {
await expect(this.errorMessage).toHaveText(message)
}
}
Multiple Page Objects
// pages/dashboard-page.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class DashboardPage {
readonly page: Page
readonly welcomeMessage: Locator
readonly navMenu: Locator
readonly logoutButton: Locator
constructor(page: Page) {
this.page = page
this.welcomeMessage = page.locator('.welcome')
this.navMenu = page.locator('nav')
this.logoutButton = page.locator('#logout')
}
async expectWelcome(name: string) {
await expect(this.welcomeMessage).toContainText(name)
}
async navigateTo(section: string) {
await this.navMenu.getByRole('link', { name: section }).click()
}
async logout() {
await this.logoutButton.click()
}
}
Using POM in Tests
// tests/login.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from '../pages/login-page'
import { DashboardPage } from '../pages/dashboard-page'
test('can log in successfully', async ({ page }) => {
const loginPage = new LoginPage(page)
const dashboardPage = new DashboardPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'password123')
await dashboardPage.expectWelcome('User')
})
test('shows error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('wrong@example.com', 'wrong')
await loginPage.expectError('Invalid credentials')
})
Integrating POM with Fixtures
Providing POM instances as fixtures makes test code even more concise.
// fixtures.ts
import { test as base } from '@playwright/test'
import { LoginPage } from './pages/login-page'
import { DashboardPage } from './pages/dashboard-page'
type Pages = {
loginPage: LoginPage
dashboardPage: DashboardPage
}
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page))
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page))
},
})
export { expect } from '@playwright/test'
// tests/login.spec.ts
import { test, expect } from '../fixtures'
test('can log in successfully', async ({ loginPage, dashboardPage }) => {
await loginPage.goto()
await loginPage.login('user@example.com', 'password123')
await dashboardPage.expectWelcome('User')
})
Project Structure
Here is the recommended directory layout for organizing fixtures and page objects.
project/
βββ playwright.config.ts
βββ fixtures/
β βββ index.ts # Main fixture definitions
β βββ auth.fixtures.ts # Authentication fixtures
β βββ db.fixtures.ts # Database fixtures
βββ pages/
β βββ login-page.ts
β βββ dashboard-page.ts
β βββ settings-page.ts
β βββ index.ts # Re-export all POMs
βββ tests/
β βββ auth/
β β βββ login.spec.ts
β β βββ register.spec.ts
β βββ dashboard/
β β βββ dashboard.spec.ts
β βββ settings/
β βββ settings.spec.ts
βββ test-data/
βββ users.json
Merging Fixtures
// fixtures/index.ts
import { mergeTests } from '@playwright/test'
import { test as authTest } from './auth.fixtures'
import { test as dbTest } from './db.fixtures'
export const test = mergeTests(authTest, dbTest)
export { expect } from '@playwright/test'
DRY vs WET in Test Code
In test code, pursuing DRY (Don't Repeat Yourself) too aggressively can actually reduce readability. Sometimes a bit of repetition makes tests easier to understand.
When DRY Works Well
// Good: Centralized selector management via POM
export class ProductPage {
readonly addToCartButton: Locator
constructor(page: Page) {
// Only one place to update when the selector changes
this.addToCartButton = page.locator('[data-testid="add-to-cart"]')
}
}
When WET (Write Everything Twice) Is Appropriate
// Good: Each test is self-contained and clearly communicates intent
test('new user can create an account', async ({ page }) => {
await page.goto('/register')
await page.fill('#name', 'Alice')
await page.fill('#email', 'alice@example.com')
await page.fill('#password', 'StrongPass123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/welcome')
})
test('registration fails with existing email', async ({ page }) => {
await page.goto('/register')
await page.fill('#name', 'Bob')
await page.fill('#email', 'existing@example.com')
await page.fill('#password', 'StrongPass123')
await page.click('button[type="submit"]')
await expect(page.locator('.error')).toHaveText('Email already exists')
})
Decision Guide
| Aspect | DRY (Shared) | WET (Duplicated) |
|---|---|---|
| Selector management | Centralize in POM | - |
| Setup logic | Share via fixtures | - |
| Test steps | - | Write explicitly in each test |
| Assertions | - | Write test-specific checks |
| Helper functions | Share if repeated 3+ times | Allow duplication for 1-2 uses |
Test code is "executable documentation." The most important quality is that reading a single test tells you exactly what is being tested.
Summary
| Concept | Description |
|---|---|
| Built-in fixtures | page, browser, context, request, browserName |
| test.extend() | API for defining custom fixtures |
| Test scope | Setup and teardown run per test |
| Worker scope | Initialization runs once per worker |
| Fixture composition | Declare dependencies between fixtures declaratively |
| Page Object Model | Encapsulate page UI operations in classes |
| POM + Fixtures | Provide POM as fixtures for concise tests |
Key Takeaways
- Fixtures guarantee test isolation and automate setup/teardown
- Use
test.extend()to create custom fixtures and manage preconditions declaratively - POM centralizes selectors and operations, making tests resilient to UI changes
- Combining POM with fixtures produces the most maintainable test code
- Balance DRY and WET, always prioritizing test readability
Practice Exercises
Exercise 1: Basic
Create a custom fixture called homePage that depends on the page fixture and provides a page that has already navigated to a specified URL.
Exercise 2: Intermediate
Create the following POM classes for an e-commerce site:
ProductListPage- product list operations (search, filter, select product)ProductDetailPage- product detail operations (add to cart, change quantity)CartPage- cart operations (change quantity, remove, verify total)
Challenge Exercise
Integrate the POM classes above as fixtures and implement the following test scenario: "Search for a product, add it to the cart, and verify the total amount in the cart." Also include a worker-scoped fixture to manage the test API server URL.
References
Next up: On Day 8, we will explore "Debugging and Tracing." You will learn how to use the Playwright Inspector and Trace Viewer to efficiently identify the root cause of test failures.