Learn Playwright in 10 DaysDay 7: Fixtures and Page Object Model
books.chapter 7Learn Playwright in 10 Days

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

  1. Fixtures guarantee test isolation and automate setup/teardown
  2. Use test.extend() to create custom fixtures and manage preconditions declaratively
  3. POM centralizes selectors and operations, making tests resilient to UI changes
  4. Combining POM with fixtures produces the most maintainable test code
  5. 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.