Advanced Playwright Patterns: Page Object Model, Fixtures, and Test Architecture

Master advanced Playwright testing patterns including Page Object Model, custom fixtures, test data management, and scalable test architecture for complex applications.

Harry Tran
8 min read ยท 1461 words

#Advanced Playwright Patterns: Page Object Model, Fixtures, and Test Architecture

As your Playwright test suite grows, maintaining clean, scalable, and reusable test code becomes crucial. This guide explores advanced patterns and architectural approaches that will help you build robust test automation frameworks.

#The Page Object Model (POM)

The Page Object Model is a design pattern that creates an object repository for web UI elements. Instead of hardcoding selectors throughout your tests, POM encapsulates page interactions in reusable classes.

#Basic Page Object Structure

javascript
// pages/BasePage.js
export class BasePage {
constructor(page) {
this.page = page
}
async goto(path = '') {
await this.page.goto(path)
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle')
}
async takeScreenshot(name) {
await this.page.screenshot({ path: `screenshots/${name}.png` })
}
}

#Implementing Specific Page Objects

javascript
// pages/LoginPage.js
import { BasePage } from './BasePage'
export class LoginPage extends BasePage {
constructor(page) {
super(page)
this.emailInput = page.locator('[data-testid="email"]')
this.passwordInput = page.locator('[data-testid="password"]')
this.submitButton = page.locator('[data-testid="submit"]')
this.errorMessage = page.locator('[data-testid="error"]')
this.forgotPasswordLink = page.locator('text=Forgot Password?')
}
async login(email, password) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
async loginAsAdmin() {
await this.login('admin@example.com', 'admin123')
}
async expectLoginError(message) {
await expect(this.errorMessage).toBeVisible()
await expect(this.errorMessage).toContainText(message)
}
async goto() {
await super.goto('/login')
await this.waitForPageLoad()
}
}

#Complex Page Interactions

javascript
// pages/DashboardPage.js
export class DashboardPage extends BasePage {
constructor(page) {
super(page)
this.userMenu = page.locator('[data-testid="user-menu"]')
this.searchInput = page.locator('[data-testid="search"]')
this.notificationBell = page.locator('[data-testid="notifications"]')
this.sidebarToggle = page.locator('[data-testid="sidebar-toggle"]')
}
async searchFor(query) {
await this.searchInput.fill(query)
await this.searchInput.press('Enter')
// Wait for search results to load
await this.page.waitForResponse(
response => response.url().includes('/api/search') && response.status() === 200
)
}
async openUserMenu() {
await this.userMenu.click()
await this.page.waitForSelector('[data-testid="user-dropdown"]', { state: 'visible' })
}
async logout() {
await this.openUserMenu()
await this.page.click('[data-testid="logout"]')
await this.page.waitForURL('/login')
}
async getNotificationCount() {
const count = await this.notificationBell.getAttribute('data-count')
return parseInt(count) || 0
}
}

#Advanced Fixtures Pattern

Fixtures provide reusable setup and teardown logic. They're perfect for authentication, test data creation, and complex initialization scenarios.

#Authentication Fixture

javascript
// fixtures/auth.js
import { test as base, expect } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
import { DashboardPage } from '../pages/DashboardPage'
export const test = base.extend({
// Authenticated user fixture
authenticatedPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.loginAsAdmin()
// Verify login succeeded
const dashboard = new DashboardPage(page)
await expect(dashboard.userMenu).toBeVisible()
await use(page)
},
// Different user roles
managerPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('manager@example.com', 'manager123')
await use(page)
},
userPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'user123')
await use(page)
},
})
export { expect } from '@playwright/test'

#Database Fixture

javascript
// fixtures/database.js
import { test as base } from './auth'
import { DatabaseHelper } from '../helpers/DatabaseHelper'
export const test = base.extend({
db: async ({}, use) => {
const db = new DatabaseHelper()
await db.connect()
await use(db)
await db.disconnect()
},
// Test data fixture with cleanup
testData: async ({ db }, use) => {
const testUsers = []
const testCompanies = []
const createUser = async userData => {
const user = await db.createUser(userData)
testUsers.push(user.id)
return user
}
const createCompany = async companyData => {
const company = await db.createCompany(companyData)
testCompanies.push(company.id)
return company
}
await use({ createUser, createCompany })
// Cleanup
for (const userId of testUsers) {
await db.deleteUser(userId)
}
for (const companyId of testCompanies) {
await db.deleteCompany(companyId)
}
},
})

#Component-Based Page Objects

For applications with reusable components, create component objects:

javascript
// components/SearchComponent.js
export class SearchComponent {
constructor(page, selector) {
this.page = page
this.container = page.locator(selector)
this.input = this.container.locator('input[type="search"]')
this.suggestions = this.container.locator('[data-testid="suggestions"]')
this.clearButton = this.container.locator('[data-testid="clear"]')
}
async search(query) {
await this.input.fill(query)
await this.input.press('Enter')
}
async selectSuggestion(text) {
await this.input.fill(text.substring(0, 3)) // Trigger suggestions
await this.page.waitForSelector('[data-testid="suggestions"] li')
await this.container.locator(`text=${text}`).click()
}
async clear() {
await this.clearButton.click()
}
}
// pages/ProductsPage.js
import { SearchComponent } from '../components/SearchComponent'
export class ProductsPage extends BasePage {
constructor(page) {
super(page)
this.search = new SearchComponent(page, '[data-testid="product-search"]')
this.filters = new FilterComponent(page, '[data-testid="product-filters"]')
this.productGrid = page.locator('[data-testid="product-grid"]')
}
async searchProducts(query) {
await this.search.search(query)
await this.page.waitForResponse('/api/products/search')
}
}

#Test Data Management

#Data Builders Pattern

javascript
// builders/UserBuilder.js
export class UserBuilder {
constructor() {
this.userData = {
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
role: 'user',
}
}
withFirstName(firstName) {
this.userData.firstName = firstName
return this
}
withLastName(lastName) {
this.userData.lastName = lastName
return this
}
withEmail(email) {
this.userData.email = email
return this
}
withRole(role) {
this.userData.role = role
return this
}
asAdmin() {
this.userData.role = 'admin'
this.userData.email = 'admin@example.com'
return this
}
build() {
return { ...this.userData }
}
}
// Usage in tests
const adminUser = new UserBuilder().asAdmin().withFirstName('Jane').build()

#Data Factories

javascript
// factories/TestDataFactory.js
export class TestDataFactory {
static createUser(overrides = {}) {
return {
firstName: 'Test',
lastName: 'User',
email: `user${Date.now()}@example.com`,
password: 'password123',
role: 'user',
...overrides,
}
}
static createCompany(overrides = {}) {
return {
name: `Test Company ${Date.now()}`,
industry: 'Technology',
size: '50-200',
...overrides,
}
}
static createProduct(overrides = {}) {
return {
name: `Test Product ${Date.now()}`,
price: 99.99,
category: 'Electronics',
inStock: true,
...overrides,
}
}
}

#API Integration Patterns

#API Helper Classes

javascript
// helpers/ApiHelper.js
export class ApiHelper {
constructor(request) {
this.request = request
this.baseURL = process.env.API_BASE_URL || 'http://localhost:3000/api'
}
async createUser(userData) {
const response = await this.request.post(`${this.baseURL}/users`, {
data: userData,
})
return response.json()
}
async getUser(id) {
const response = await this.request.get(`${this.baseURL}/users/${id}`)
return response.json()
}
async updateUser(id, updates) {
const response = await this.request.patch(`${this.baseURL}/users/${id}`, {
data: updates,
})
return response.json()
}
async deleteUser(id) {
await this.request.delete(`${this.baseURL}/users/${id}`)
}
}

#Mixed API and UI Testing

javascript
// tests/user-management.spec.js
import { test, expect } from '../fixtures/auth'
import { ApiHelper } from '../helpers/ApiHelper'
import { UsersPage } from '../pages/UsersPage'
test.describe('User Management', () => {
test('create user via API and verify in UI', async ({ authenticatedPage, request }) => {
const api = new ApiHelper(request)
const usersPage = new UsersPage(authenticatedPage)
// Create user via API
const newUser = await api.createUser({
firstName: 'Jane',
lastName: 'Smith',
email: 'jane.smith@example.com',
})
// Verify user appears in UI
await usersPage.goto()
await usersPage.searchUser(newUser.email)
await expect(usersPage.getUserRow(newUser.email)).toBeVisible()
// Cleanup
await api.deleteUser(newUser.id)
})
})

#Configuration Management

#Environment-based Configuration

javascript
// config/TestConfig.js
export class TestConfig {
static get environment() {
return process.env.TEST_ENV || 'development'
}
static get baseURL() {
const urls = {
development: 'http://localhost:3000',
staging: 'https://staging.example.com',
production: 'https://example.com',
}
return urls[this.environment]
}
static get apiURL() {
return `${this.baseURL}/api`
}
static get testUsers() {
return {
admin: {
email: process.env.ADMIN_EMAIL || 'admin@example.com',
password: process.env.ADMIN_PASSWORD || 'admin123',
},
user: {
email: process.env.USER_EMAIL || 'user@example.com',
password: process.env.USER_PASSWORD || 'user123',
},
}
}
}

#Error Handling and Retry Logic

#Custom Error Handling

javascript
// helpers/TestHelper.js
export class TestHelper {
static async retryAsync(fn, maxRetries = 3, delay = 1000) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error) {
if (i === maxRetries - 1) throw error
await this.sleep(delay * (i + 1)) // Exponential backoff
}
}
}
static async waitForElement(page, selector, options = {}) {
const { timeout = 10000, state = 'visible' } = options
try {
await page.waitForSelector(selector, { state, timeout })
} catch (error) {
// Take screenshot on failure for debugging
await page.screenshot({
path: `debug-screenshots/${Date.now()}-element-wait-failure.png`,
})
throw new Error(`Element ${selector} not found in ${state} state within ${timeout}ms`)
}
}
static sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
}

#Parallel Testing Strategies

#Worker-specific Test Data

javascript
// fixtures/worker-data.js
import { test as base } from '@playwright/test'
export const test = base.extend({
workerStorageState: async ({}, use, workerInfo) => {
// Create unique storage state per worker
const storageState = `storage-states/worker-${workerInfo.workerIndex}.json`
await use(storageState)
},
isolatedUser: async ({ request }, use, workerInfo) => {
// Create user specific to this worker
const userEmail = `test-user-${workerInfo.workerIndex}@example.com`
const response = await request.post('/api/users', {
data: {
email: userEmail,
password: 'password123',
firstName: 'Test',
lastName: `User${workerInfo.workerIndex}`,
},
})
const user = await response.json()
await use(user)
// Cleanup
await request.delete(`/api/users/${user.id}`)
},
})

#Conclusion

Advanced Playwright patterns help you build maintainable, scalable test automation frameworks. Key takeaways:

#Architecture Benefits:

  • Page Object Model - Encapsulates UI interactions and reduces code duplication
  • Custom Fixtures - Provide reusable setup/teardown logic
  • Component Objects - Enable testing of reusable UI components
  • Data Builders - Create flexible test data generation

#Best Practices:

  • Separate concerns between pages, components, and test logic
  • Use fixtures for complex setup scenarios
  • Implement proper error handling and retry mechanisms
  • Design for parallel test execution
  • Maintain environment-specific configurations

#Testing Strategy:

  • Combine API and UI testing for comprehensive coverage
  • Use data factories for consistent test data
  • Implement proper cleanup to avoid test pollution
  • Design tests to be independent and parallelizable

With these patterns, you can build robust test automation frameworks that scale with your application and provide reliable feedback on your software quality.