Introduction
A messy test suite. Login flows duplicated across hundreds of tests. Environment setup scattered throughout the codebase. Test data that worked locally but failed in CI.
Does any of this sound familiar?
As web applications grow more complex, maintaining clean, efficient, and scalable test code becomes increasingly challenging. Playwright—a powerful end-to-end testing framework—offers a solution through its fixture system. This guide will walk you through advanced techniques for leveraging Playwright fixtures to build a robust and maintainable test architecture.
What Are Playwright Fixtures?
Fixtures in Playwright allow you to share data or objects between tests, set up preconditions, and manage test resources efficiently. They help reduce code duplication and improve test organisation by providing reusable setup and teardown functions that can be customised for different test needs.
Playwright fixtures can initialise essential resources—such as authenticated user sessions, browser contexts, or specific environment variables—before tests begin and clean them up afterwards. This modular approach ensures that each test runs with a consistent and isolated setup, making the test suite more maintainable, scalable, and reliable across various environments.
Let’s take a look at different types of Playwright fixtures and how to create them.
1. Page Object Fixtures
Page Object Models (POMs) are a design pattern that creates a layer of abstraction between test code and page-specific code. Below is an example of how to structure Page Object fixtures in Playwright:
// pages/login.page.ts import { Page } from '@playwright/test'; export class LoginPage { constructor(private page: Page) {} async login(username: string, password: string) { await this.page.fill('#username', username); await this.page.fill('#password', password); await this.page.click('#login-button'); } } // pages/dashboard.page.ts import { Page } from '@playwright/test'; export class DashboardPage { constructor(private page: Page) {} async getUserName() { return this.page.textContent('.user-name'); } } // fixtures.ts import { test as base } from '@playwright/test'; import { LoginPage } from './pages/login.page'; import { DashboardPage } from './pages/dashboard.page'; export const test = base.extend<{ loginPage: LoginPage; dashboardPage: DashboardPage; }>({ loginPage: async ({ page }, use) => { await use(new LoginPage(page)); }, dashboardPage: async ({ page }, use) => { await use(new DashboardPage(page)); }, });
Benefits of Page Object Fixtures
– Enhanced Reusability: Page Object Fixtures allow you to define reusable methods for interacting with page elements, reducing redundancy across tests and making your test suite more efficient.
– Improved Maintainability: By centralising page logic, you only need to update the Page Object when there are changes to the page structure. This reduces the effort needed to maintain the tests as the application evolves.
– Scalability: As the test suite grows, Page Object Fixtures scale with it. New pages and interactions can be added easily by creating new Page Objects, keeping the test suite organised.
Best Practices When Working With Page Object Fixtures
– Break down your page objects into logical components. For example, instead of having one large Page Object for the entire page, create separate objects for the header, footer, and main content sections.
– Each Page Object should have a single responsibility. Avoid mixing page interactions with complex business logic.
– Where applicable, make your Page Object methods flexible by accepting parameters. This allows you to reuse the same method for different scenarios (e.g., clicking a button based on different identifiers).
– Keep your Page Objects stateless to avoid test interdependencies. If necessary, ensure that any state changes are reset before each test to ensure consistency.
Real-World Application
In a large e-commerce application, you could create a Page Object for the checkout process. This Page Object might include methods to add items to the cart, apply a discount, and complete the purchase. The process is tested consistently across various tests (e.g., guest checkout, registered user checkout) without having to repeat the same setup for each test case.
2. API Class Fixtures
API classes can be used to interact directly with backend services. Here’s how to create API class fixtures:
// api/user.api.ts import { APIRequestContext } from '@playwright/test'; export class UserAPI { constructor(private request: APIRequestContext) {} async createUser(userData: any) { return this.request.post('/api/users', { data: userData }); } } // api/product.api.ts import { APIRequestContext } from '@playwright/test'; export class ProductAPI { constructor(private request: APIRequestContext) {} async getProducts() { return this.request.get('/api/products'); } } // fixtures.ts import { test as base } from '@playwright/test'; import { UserAPI } from './api/user.api'; import { ProductAPI } from './api/product.api'; export const test = base.extend<{ userAPI: UserAPI; productAPI: ProductAPI; }>({ userAPI: async ({ request }, use) => { await use(new UserAPI(request)); }, productAPI: async ({ request }, use) => { await use(new ProductAPI(request)); }, });
Benefits of API Class Fixtures
– Efficiency: API clients can be initialised and configured once per test class, instead of reinitialising them for each individual test.
– Resource Optimisation: Resource consumption is minimised by sharing API client instances across multiple tests within the same class.
– Consistency: Since the same API client is used throughout the test class, it ensures consistent behaviour when interacting with external systems.
Best Practices When Working With API Class Fixtures
– Define the API client and related setup logic in dedicated fixtures, so that test code can focus on assertions and workflows, not the complexity of API setup.
– Include cleanup logic to avoid issues with session persistence, rate limits, or resource exhaustion.
– For tests that don’t need actual API interaction, mock the necessary responses to speed up the test execution.
Real-World Application
In a logistics platform with complex backend interactions, you could use an API fixture to handle shipping requests. You may include methods to create shipments, track orders, and update shipment statuses. With API fixtures, you could test the system’s core shipping functionality across multiple scenarios, such as domestic and international shipments, without reinitialising API clients for each test case.
3. Helper Fixtures at Worker Scope
Worker-scoped fixtures in Playwright are a powerful feature that allows you to share resources across multiple test files within a single worker process. These fixtures are particularly useful for operations that are expensive to set up but can be reused across multiple tests, such as database connections or test data generators.
Here’s how to create and use worker-scoped helper fixtures:
// helpers/database.helper.ts import { Pool } from 'pg'; export class DatabaseHelper { private pool: Pool; async connect() { this.pool = new Pool({ user: process.env.DB_USER, host: process.env.DB_HOST, database: process.env.DB_NAME, password: process.env.DB_PASSWORD, port: parseInt(process.env.DB_PORT || '5432'), }); } async query(sql: string, params: any[] = []) { if (!this.pool) { throw new Error('Database not connected. Call connect() first.'); } const client = await this.pool.connect(); try { const result = await client.query(sql, params); return result.rows; } finally { client.release(); } } async disconnect() { if (this.pool) { await this.pool.end(); } } } // helpers/test-data-generator.ts import { faker } from '@faker-js/faker'; export class TestDataGenerator { async init() { // Any initialization logic here console.log('TestDataGenerator initialized'); } generateUser() { return { name: faker.person.fullName(), email: faker.internet.email(), password: faker.internet.password(), }; } generateProduct() { return { name: faker.commerce.productName(), price: parseFloat(faker.commerce.price()), category: faker.commerce.department(), }; } } // fixtures.ts import { test as base } from '@playwright/test'; import { DatabaseHelper } from './helpers/database.helper'; import { TestDataGenerator } from './helpers/test-data-generator'; export const test = base.extend< {}, { dbHelper: DatabaseHelper; testDataGen: TestDataGenerator; } >({ dbHelper: [async ({}, use) => { const dbHelper = new DatabaseHelper(); await dbHelper.connect(); await use(dbHelper); await dbHelper.disconnect(); }, { scope: 'worker' }], testDataGen: [async ({}, use) => { const testDataGen = new TestDataGenerator(); await testDataGen.init(); await use(testDataGen); }, { scope: 'worker' }], });
Benefits of Worker-Scoped Fixtures
– Efficiency: Expensive setup operations (such as database connections) are executed once per worker, rather than for each individual test.
– Resource Sharing: Multiple tests within the same worker can share the same resources, optimizing resource usage and reducing consumption.
– Consistency: Multiple tests within the same worker can share the same resources, reducing overall resource consumption.
– Performance: By reusing connections and initialised objects, tests can run faster compared to setting up these resources before each test. Fixtures should be torn down after use.
Best Practices When Working With Worker-Scoped Fixtures
– Use worker scope for fixtures that are expensive to set up but can be safely shared between tests.
– Ensure that worker-scoped fixtures are stateless or can be reset between tests to prevent test interdependencies.
– Be mindful of resource limits. While sharing resources can be efficient, it may lead to resource exhaustion if not properly managed.
– Use environment variables or configuration files to manage connection strings and other sensitive data.
Potential Pitfalls to Watch Out For
– Test Isolation: Ensure that tests using worker-scoped fixtures don’t interfere with each other by modifying the shared state.
– Resource Leaks: Properly manage resources in the fixture’s teardown phase to prevent leaks.
Here’s an example of how you might use these worker-scoped fixtures in a test:
// user.spec.ts import { test } from './fixtures'; import { expect } from '@playwright/test'; test.describe('User management', () => { test('list users', async ({ page, dbHelper }) => { // The database is already connected and seeded with test data await page.goto('/users'); const userCount = await page.locator('.user-item').count(); expect(userCount).toBeGreaterThan(0); }); test('create new user', async ({ page, dbHelper }) => { await page.goto('/users/new'); await page.fill('#name', 'New User'); await page.fill('#email', 'newuser@example.com'); await page.click('#submit'); // Verify the user was created in the database const result = await dbHelper.client.query('SELECT * FROM users WHERE email = $1', ['newuser@example.com']); expect(result.rows.length).toBe(1); }); });
Real-World Application
In a large-scale application, you might use a worker-scoped fixture to set up a complex test environment. This could involve starting multiple services, populating a database with a large amount of test data, or performing time-consuming authentication processes. By doing this once per worker, you can significantly reduce the overall runtime of your test suite.
4. Optional Data Fixtures
Optional data fixtures provide a way to define default test data that can be overridden in specific tests. This flexibility allows you to maintain a consistent baseline for your tests while still accommodating special cases.
Benefits of Optional Data Fixtures
– Default Test Data: Provide default test data, reducing the need to set up data in individual tests.
– Easy Overrides: Allow easy overriding of data for specific test cases.
– Improved Readability: Separating test data from test logic improves the readability of the test.
– Efficient Data Management: Enable easy management of different data scenarios across your test suite.
Let’s expand on our previous example and create a more comprehensive optional data fixture:
// types/user.ts export interface User { username: string; password: string; email: string; role: 'admin' | 'user'; } // fixtures.ts import { test as base } from '@playwright/test'; import { User } from './types/user'; export const test = base.extend<{ testUser?: User; }>({ testUser: [async ({}, use) => { await use({ username: 'defaultuser', password: 'defaultpass123', email: 'default@example.com', role: 'user' }); }, { option: true }], }); Now, let’s use this fixture in our tests: // user.spec.ts import { test } from './fixtures'; import { expect } from '@playwright/test'; test.describe('User functionality', () => { test('login with default user', async ({ page, testUser }) => { await page.goto('/login'); await page.fill('#username', testUser.username); await page.fill('#password', testUser.password); await page.click('#login-button'); expect(page.url()).toContain('/dashboard'); }); test('admin user can access admin panel', async ({ page, testUser }) => { test.use({ testUser: { username: 'adminuser', password: 'adminpass123', email: 'admin@example.com', role: 'admin' } }); await page.goto('/login'); await page.fill('#username', testUser.username); await page.fill('#password', testUser.password); await page.click('#login-button'); await page.click('#admin-panel'); expect(page.url()).toContain('/admin'); }); });
Best Practices When Working With Optional Data Fixtures
– Use optional fixtures for data that is commonly used across tests but may need variation.
– Keep the default data simple and generic. Use overrides for specific scenarios.
– Consider creating multiple optional fixtures for different data categories (e.g., testUser, testProduct, testOrder).
– Use TypeScript interfaces to ensure type safety for your test data.
– When overriding fixtures, specify only the properties that need to be changed. Playwright will merge the overrides with the defaults.
Real-World Application
In an e-commerce application, you might have different user types (guest, registered, premium) and product types (physical, digital, subscription). You could create optional fixtures for each, allowing you to easily test various scenarios, such as a premium user purchasing a subscription product or a guest user buying a physical item.
5. Defining TestFixtures and WorkerFixtures Types
Typed fixtures leverage TypeScript’s type system to provide better autocomplete, type checking, and an overall improved developer experience when working with Playwright tests.
Benefits of Typed Fixtures
– Static Type-Checking: Improve code completeness and reduce errors through TypeScript’s static type-checking.
– IDE Support: Enhance IDE support with better autocomplete and refactoring capabilities.
– Clear Documentation: Serve as documentation, making it clear what properties and methods are available on each fixture.
– Easy Composition: Allow for easy composition of complex test setups through type intersection.
Let’s create a more comprehensive setup with typed fixtures:
// types.ts import { LoginPage, ProductPage, CheckoutPage } from './pages'; import { UserAPI, ProductAPI, OrderAPI } from './api'; import { DatabaseHelper } from './helpers/database.helper'; import { User, Product, Order } from './models'; export interface PageFixtures { loginPage: LoginPage; productPage: ProductPage; checkoutPage: CheckoutPage; } export interface APIFixtures { userAPI: UserAPI; productAPI: ProductAPI; orderAPI: OrderAPI; } export interface HelperFixtures { dbHelper: DatabaseHelper; } export interface DataFixtures { testUser?: User; testProduct?: Product; testOrder?: Order; } export interface TestFixtures extends PageFixtures, APIFixtures, DataFixtures {} export interface WorkerFixtures extends HelperFixtures {} // basetest.ts import { test as base } from '@playwright/test'; import { TestFixtures, WorkerFixtures } from './types'; export const test = base.extend<TestFixtures & WorkerFixtures>({ // Implement your fixtures here }); // playwright.config.ts import { defineConfig } from '@playwright/test'; import { TestFixtures, WorkerFixtures } from './types'; export default defineConfig<TestFixtures, WorkerFixtures>({ use: { baseURL: 'http://localhost:3000', testUser: { username: 'defaultuser', password: 'defaultpass123', email: 'default@example.com', role: 'user' }, // Other default fixture values }, // ... other config options }); Now, when writing tests, you get full type support: // checkout.spec.ts import { test } from './basetest'; import { expect } from '@playwright/test'; test('complete checkout process', async ({ page, loginPage, productPage, checkoutPage, testUser, testProduct, orderAPI }) => { await loginPage.login(testUser.username, testUser.password); await productPage.addToCart(testProduct.id); await checkoutPage.completeCheckout(); const latestOrder = await orderAPI.getLatestOrderForUser(testUser.id); expect(latestOrder.status).toBe('completed'); });
Best Practices When Working With Typed Fixtures
– Define clear and separate interfaces for different types of fixtures (e.g., page, API, data).
– Use type intersection to compose complex fixture setups.
– Leverage TypeScript’s utility types (like Partial<T> or Pick<T>) when defining optional or subset fixtures.
– Keep your type definitions in sync with your actual implementations.
– Use strict TypeScript settings to get the most benefit from type checking.
Real-World Application
In a large-scale application, you might have dozens of page objects, API clients, and data models. By using typed fixtures, you can ensure that all parts of your test suite work together correctly. For example, you could create a complex end-to-end test that simulates a user journey across multiple pages, interacts with various APIs, and verifies the results in the database— all with full type safety and autocomplete support.
Combining Different Types of Fixtures
One of the most powerful aspects of Playwright fixtures is the ability to combine different types to create comprehensive test setups. Here’s an example that brings together various fixture types:
// fixtures.ts import { test as base } from '@playwright/test'; import { LoginPage, DashboardPage } from './pages'; import { UserAPI, ProductAPI } from './api'; import { DatabaseHelper } from './helpers/database.helper'; import { User, Product } from './types'; type TestFixtures = { loginPage: LoginPage; dashboardPage: DashboardPage; userAPI: UserAPI; productAPI: ProductAPI; testUser?: User; testProduct?: Product; }; type WorkerFixtures = { dbHelper: DatabaseHelper; }; export const test = base.extend<TestFixtures, WorkerFixtures>({ // Page object fixtures loginPage: async ({ page }, use) => { await use(new LoginPage(page)); }, dashboardPage: async ({ page }, use) => { await use(new DashboardPage(page)); }, // API fixtures userAPI: async ({ request }, use) => { await use(new UserAPI(request)); }, productAPI: async ({ request }, use) => { await use(new ProductAPI(request)); }, // Optional data fixtures testUser: [async ({}, use) => { await use({ id: '1', username: 'testuser', email: 'test@example.com' }); }, { option: true }], testProduct: [async ({}, use) => { await use({ id: '1', name: 'Test Product', price: 9.99 }); }, { option: true }], // Worker-scoped helper fixture dbHelper: [async ({}, use) => { const helper = new DatabaseHelper(); await helper.connect(); await helper.resetDatabase(); await use(helper); await helper.disconnect(); }, { scope: 'worker' }], }); Now you can write highly comprehensive tests: // e2e.spec.ts import { test } from './fixtures'; import { expect } from '@playwright/test'; test('user can purchase a product', async ({ loginPage, dashboardPage, userAPI, productAPI, testUser, testProduct, dbHelper }) => { // Create a new user const user = await userAPI.createUser(testUser); // Log in await loginPage.login(user.username, 'password123'); // Add product to cart await dashboardPage.addToCart(testProduct.id); // Complete purchase await dashboardPage.completePurchase(); // Verify purchase in database const dbOrder = await dbHelper.getLatestOrderForUser(user.id); expect(dbOrder.productId).toBe(testProduct.id); // Verify product stock updated const updatedProduct = await productAPI.getProduct(testProduct.id); expect(updatedProduct.stock).toBe(testProduct.stock - 1); });
Bonus: Merging Test and Worker Fixtures
Now, let’s merge our test and worker fixtures:
// fixtures.ts import { test as base, mergeTests } from '@playwright/test'; import { TestFixtures, WorkerFixtures } from './types'; const testFixtures = base.extend<TestFixtures>({ // ... test fixtures implementation }); const workerFixtures = base.extend<WorkerFixtures>({ // ... worker fixtures implementation }); export const test = mergeTests(testFixtures, workerFixtures);
Extending basetest with TestFixture and WorkerFixture Types
To provide proper typing for our tests, we can extend the base test:
// basetest.ts import { test as baseTest } from './fixtures.ts'; import { TestFixtures, WorkerFixtures } from './types'; export const test = baseTest.extend<TestFixtures, WorkerFixtures>({});
Tips to Use Playwright Fixtures Effectively
Here’s how you can make the most of Playwright fixtures to keep your test suite efficient, maintainable, and scalable:
Modularize Your Fixtures: Create separate fixtures for different concerns (e.g., pages, APIs, data) to keep your test code organized and maintainable.
Use the Appropriate Scope: Use test-scoped fixtures for most cases, and reserve worker-scoped fixtures for truly expensive setup operations.
Leverage TypeScript: Use typed fixtures to improve code completeness, reduce errors, and enhance the developer experience.
Balance Flexibility and Simplicity: Use optional fixtures to provide default data, but don’t overcomplicate your setup. Aim for a good balance between flexibility and ease of use.
Keep Fixtures Focused: Each fixture should have a single responsibility. If a fixture is doing too much, consider breaking it into smaller, more focused fixtures.
Use Composition: Combine different types of fixtures to create comprehensive test setups that cover all aspects of your application.
Maintain Consistency: Use consistent naming conventions and structures across your fixtures to make your test code more readable and maintainable.
Document Your Fixtures: Provide clear documentation for your fixtures, especially for complex setups or when working in larger teams.
Regular Refactoring: As your test suite grows, regularly review and refactor your fixtures to ensure they remain efficient and effective.
Test Your Fixtures: For complex fixtures, consider writing tests for the fixtures themselves to ensure they behave as expected.
Conclusion
Playwright fixtures provide a robust foundation for creating maintainable, scalable test suites. By understanding and implementing different fixture types, leveraging TypeScript for type safety, and following the best practices given above, you can build a testing framework that grows with your application.
Remember to regularly review and optimise your fixtures, maintain clear documentation, and encourage collaboration among team members. With these tools and practices in place, you’ll be well-equipped to handle complex testing scenarios effectively.
Seeking expert guidance to integrate Playwright into your testing strategy? CAW goes beyond consultation by owning your entire testing automation suite. Schedule a consultation today to learn how we can help improve your testing workflow and deliver higher-quality software.
FAQs
What are Playwright fixtures?
Playwright fixtures allow you to share data, manage test resources, and set up preconditions between tests, which helps reduce code duplication and keeps test code organised and maintainable.
How to effectively combine different fixture types in Playwright?
Combining different fixture types (such as Page Objects, API clients, and data fixtures) allows you to create modular, comprehensive test setups that cover multiple aspects of an application. Each fixture can handle specific responsibilities within a test suite, enabling the code to stay organised.
How can Playwright fixtures support scalable test architecture?
Playwright fixtures support scalable test architecture by modularising and centralising setup logic. This approach allows for a flexible testing framework that can easily adapt as your application grows in complexity.