Effective Utilization of Playwright Fixtures: A Comprehensive Guide

Ravi Kiran Vemula
10 min readSep 9, 2024

--

Maintaining clean, efficient, and scalable test code becomes increasingly challenging as web applications become more complex. 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 create a robust and maintainable test architecture.

Introduction to Playwright Fixtures

Fixtures in Playwright allow you to share data or objects between tests, set up preconditions, and manage test resources efficiently. They also help reduce code duplication and improve test organization.

1. Creating Page Object Fixtures

Page Object Models (POMs) are a design pattern that creates a layer of abstraction between test code and page-specific code. Let’s create some page object fixtures:

// 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));
},
});

2. Creating API Class Fixtures

API classes can be used to interact with backend services directly. 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));
},
});

3. Creating 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.

Let’s explore 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' }],
});

Explanation and Best Practices

Worker-scoped fixtures provide several advantages:

  1. Efficiency: Expensive setup operations (like database connections) are performed once per worker, rather than for every test.
  2. Resource Sharing: Multiple tests within the same worker can share the same resources, reducing overall resource consumption.
  3. Consistency: All tests within a worker use the same instance of the fixture, ensuring consistent state and behaviour.
  4. Performance: By reusing connections and initialized objects, tests can run faster than setting up these resources for each test. The fixtures should be teardown 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 also lead to resource exhaustion if not managed properly.
  • 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);
});
});

Best Practices

  1. Use worker-scoped fixtures for truly expensive operations that benefit from being shared across tests.
  2. Ensure that the fixture cleans up after itself to prevent test pollution.
  3. Make the fixture resilient to failures, implementing proper error handling and logging.
  4. Consider using transactions for database operations to easily roll back changes after each test.
  5. Use environment variables or configuration files to manage connection strings and other sensitive data.

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 up 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. Creating 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 have a consistent baseline for your tests while still accommodating special cases.

Optional data fixtures offer several benefits:

  1. Provide default test data, reducing the need to set up data in individual tests
  2. Allow easy overriding of data for specific test cases
  3. Improve test readability by separating test data from test logic
  4. 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

  1. Use optional fixtures for data that is commonly used across tests but may need variation.
  2. Keep the default data simple and generic. Use overrides for specific scenarios.
  3. Consider creating multiple optional fixtures for different data categories (e.g., testUser, testProduct, testOrder).
  4. Use TypeScript interfaces to ensure type safety for your test data.
  5. When overriding fixtures, only specify the properties that need to be changed. The playwright will merge the overrides with the defaults.

Real-World Scenario

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 like 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 overall developer experience when working with Playwright tests.

Typed fixtures offer several advantages:

  1. Improve code completeness and reduce errors through TypeScript’s static type-checking
  2. Enhance IDE support with better autocomplete and refactoring capabilities
  3. Serve as documentation, making it clear what properties and methods are available on each fixture
  4. 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

  1. Define clear and separate interfaces for different types of fixtures (page, API, data, etc.).
  2. Use type intersection to compose complex fixture setups.
  3. Leverage TypeScript’s utility types (like Partial<T> or Pick<T>) when defining optional or subset fixtures.
  4. Keep your type definitions in sync with your actual implementations.
  5. 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>({});

Conclusion: Best Practices for Effective Use of Playwright Fixtures

  1. Modularize your fixtures: Create separate fixtures for different concerns (pages, APIs, data, etc.) to keep your test code organized and maintainable.
  2. Use the appropriate scope: Use test-scoped fixtures for most cases, and reserve worker-scoped fixtures for truly expensive setup operations.
  3. Leverage TypeScript: Use typed fixtures to improve code completeness, reduce errors, and enhance the developer experience.
  4. Balance flexibility and simplicity: Use optional fixtures to provide default data, but don’t over-complicate your setup. Aim for a good balance between flexibility and ease of use.
  5. 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.
  6. Use composition: Combine different types of fixtures to create comprehensive test setups that cover all aspects of your application.
  7. Maintain consistency: Use consistent naming conventions and structure across your fixtures to make your test code more readable and maintainable.
  8. Document your fixtures: Provide clear documentation for your fixtures, especially for complex setups or when working in larger teams.
  9. Regular refactoring: As your test suite grows, regularly review and refactor your fixtures to ensure they remain efficient and effective.
  10. Test your fixtures: For complex fixtures, consider writing tests for the fixtures themselves to ensure they behave as expected.

By following these practices and leveraging the full power of Playwright fixtures, you can create a robust, maintainable, and efficient test suite that grows with your application.

Happy Testing!

Feel free to connect with me on Linkedin

--

--

Ravi Kiran Vemula

With over a decade of experience in software quality assurance and testing. I leverage tools like Cypress, Playwright, and K6 to detect issues consistently.