As your Playwright test suite expands, managing setup and teardown logic efficiently becomes crucial. While test.beforeEach
and test.afterEach
are useful for simple scenarios, they can quickly lead to repetitive code or overly complex conditional logic when dealing with varied test requirements, shared resources, or unique states for different test groups.
Playwright fixtures, defined using test.extend()
, offer a powerful and elegant solution. They allow you to create modular, reusable, and on-demand setup and teardown units that can significantly clean up your test code and make your automation suite more robust and maintainable.
This post will dive deep into advanced fixture patterns and use cases, showing you how to leverage them to supercharge your Playwright testing workflow.
1. Understanding Playwright Fixtures
At its core, a Playwright fixture is a piece of code that runs before (setup) and potentially after (teardown) your test, providing some value or state that your test can use. You define fixtures by extending the base test
object, typically in a central fixture file (e.g., my-fixtures.ts
).
Key Concepts:
test.extend()
: The function used to create new fixtures or override existing ones.- Fixture Function: An
async
function that takes two arguments:- An object with already defined fixtures (like
page
,context
, or your own custom fixtures that this new fixture might depend on). - A
use
function. The fixture setup code runs beforeawait use(fixtureValue)
, and the teardown code runs after it.
- An object with already defined fixtures (like
- Fixture Value: The value that
await use(fixtureValue)
passes to the test or to other fixtures that depend on it.
Basic Example: A Simple Data Fixture
Let's start building our central fixture file, my-fixtures.ts
:
// my-fixtures.ts
import { test as baseTest, expect, Page, APIRequestContext, request } from '@playwright/test';
// Define the type for our first custom fixture
type MyBaseFixtures = {
testUser: { username: string; email: string };
};
// Extend the base Playwright test with our custom fixture(s)
export const test = baseTest.extend<MyBaseFixtures>({
testUser: async ({}, use) => {
// Setup: This could involve loading from a file, an API, or just defining static data
console.log('Setting up testUser fixture');
const userData = { username: 'testUser123', email: 'user@example.com' };
// Provide the value to the test
await use(userData);
// Teardown (optional): Runs after the test finishes using this fixture
// For example, if we created a user in a DB during setup, we might delete it here.
console.log('Tearing down testUser fixture (if needed)');
},
});
// Re-export expect so tests can import it from this file
export { expect };
How to use this in a test file (e.g., user-profile.spec.ts
):
// user-profile.spec.ts
import { test, expect } from './my-fixtures'; // Import our custom test and expect
test('should display username on profile page', async ({ page, testUser }) => {
console.log(`Test User: ${testUser.username}, Email: ${testUser.email}`);
await page.goto(`/profile/${testUser.username}`);
// Example: await expect(page.locator('.username-display')).toHaveText(testUser.username);
});
Benefits of Fixtures:
- Reusability: Define setup/teardown once, use in many tests.
- Readability: Test code becomes cleaner, focusing on logic rather than setup details.
- On-Demand Execution: Fixtures are only run if a test requests them (or if they are
auto: true
). - Composability: Fixtures can depend on other fixtures.
- Encapsulation: Setup and teardown logic are kept together, making it easier to manage.
2. Worker Scope vs. Test Scope Fixtures
Fixtures can have different scopes, controlling how often their setup and teardown logic runs:
scope: 'test'
(Default): The fixture is set up before each test that uses it and torn down after that test finishes. This is suitable for state that should be isolated per test (e.g., a specific page instance, a temporary user account for that test like ourtestUser
above).scope: 'worker'
: The fixture is set up once per worker process before any tests in that worker run, and torn down after all tests in that worker have completed. This is ideal for expensive resources that can be shared across multiple tests running in the same worker process (e.g., a database connection, a global API client, logging into a shared service once).
Example: Adding a Worker-Scoped API Client to my-fixtures.ts
We'll extend the test
object we previously defined in my-fixtures.ts
:
// my-fixtures.ts (continued)
// ... (MyBaseFixtures and the first 'test' definition with testUser remain the same)
// import { test as baseTest, expect, Page, APIRequestContext, request } from '@playwright/test';
// type MyBaseFixtures = { testUser: { username: string; email: string }; };
// export const test = baseTest.extend<MyBaseFixtures>({ /* ...testUser definition... */ });
// Define types for new fixtures being added
type WorkerSharedFixtures = {
apiClient: APIRequestContext;
};
// Extend the PREVIOUSLY defined 'test' object, not baseTest again
// This way, 'testUser' is still available alongside 'apiClient'
export const test = test.extend<WorkerSharedFixtures>({
apiClient: [
async ({}, use) => {
// Setup once per worker
console.log('Setting up worker-scoped apiClient');
const workerApiToken = process.env.WORKER_API_TOKEN || 'default-worker-token';
const client = await request.newContext({
baseURL: 'https://api.yourapp.com',
extraHTTPHeaders: {
'Authorization': `Bearer ${workerApiToken}`,
},
});
await use(client);
// Teardown once per worker, after all tests in the worker have finished
console.log('Disposing worker-scoped apiClient');
await client.dispose();
},
{ scope: 'worker' }, // Specify the scope here
],
});
// export { expect }; // Already exported
How to use this in a test file (e.g., api-tests.spec.ts
):
// api-tests.spec.ts
import { test, expect } from './my-fixtures';
test('should fetch user data using worker-scoped apiClient', async ({ apiClient, testUser }) => {
// Both apiClient (worker-scoped) and testUser (test-scoped) are available
const response = await apiClient.get(`/users/${testUser.username}`);
expect(response.ok()).toBeTruthy();
const userData = await response.json();
expect(userData.email).toBe(testUser.email);
});
test('another API test using the same apiClient instance', async ({ apiClient }) => {
// This test (if run in the same worker) will reuse the apiClient instance
const response = await apiClient.get('/items/all');
expect(response.ok()).toBeTruthy();
});
Note the array syntax for specifying options like scope: [async ({}, use) => {...}, { scope: 'worker' }]
.
3. Auto-Use Fixtures
Sometimes, you have a fixture that needs to run for every single test that uses your custom test
object, without explicitly declaring it in each test function. This is where auto: true
comes in handy.
- How to define: Add
{ auto: true }
to the fixture options. - Use cases:
- Global logging setup.
- Starting a mock server or a required background service (if not handled by
webServer
inplaywright.config.ts
). - Ensuring a user is always logged out before each test (unless a specific login fixture is used).
- Setting up global event listeners or mocks (
page.route()
). This can replace globalbeforeEach
hooks for such setups.
Example: Adding an Auto-Use Logger to my-fixtures.ts
Continuing to extend our test
object in my-fixtures.ts
:
// my-fixtures.ts (continued)
// ... (previous types and 'test' definition with testUser and apiClient)
// Define the type for the auto-use fixture (void if it doesn't pass a value via use())
type AutoUseFixtures = {
logger: void;
};
// Extend the existing 'test' object that already has testUser and apiClient
export const test = test.extend<AutoUseFixtures>({
logger: [
async ({}, use, testInfo) => {
console.log(`[LOGGER] Starting test: ${testInfo.title}`);
await use(); // No value needed, just runs the setup/teardown around the test
console.log(`[LOGGER] Finished test: ${testInfo.title} with status: ${testInfo.status}`);
},
{ auto: true }, // Specify auto: true here
],
});
// export { expect }; // Still exported
How it behaves (no explicit use needed in test files):
// any-test.spec.ts
import { test, expect } from './my-fixtures';
// The logger fixture will automatically run before and after this test
test('some functionality test', async ({ page, testUser }) => {
await page.goto('/');
console.log(`Executing test logic for ${testUser.username}`);
// ... test assertions ...
});
Auto-use fixtures with scope: 'worker'
can act as global beforeAll
/afterAll
hooks for all tests within a worker (across all files using that test
object).
4. Dependent Fixtures (Chaining Fixtures)
One of the most powerful features is that fixtures can depend on other fixtures. Playwright resolves this dependency tree automatically. The dependent fixture receives the values of the fixtures it depends on.
Example: Adding an adminLoggedInPage
to my-fixtures.ts
This fixture will depend on the built-in page
fixture and a new adminUser
data fixture.
// my-fixtures.ts (continued)
// ... (previous types and 'test' definition with testUser, apiClient)
// Define types for the new admin-related fixtures
type AdminFixtures = {
adminUser: { username: string; passwordForLogin: string; role: 'admin' };
adminLoggedInPage: Page; // This fixture will provide a Page object
};
// Extend the existing 'test' object
export const test = test.extend<AdminFixtures>({
adminUser: async ({}, use) => {
// In a real scenario, this might fetch from a DB or a secure vault
console.log('Setting up adminUser fixture');
await use({ username: 'superAdmin', passwordForLogin: 'SecurePassword123!', role: 'admin' });
},
adminLoggedInPage: async ({ page, adminUser, expect }, use) => {
// This fixture uses the built-in 'page' fixture and our custom 'adminUser' fixture.
// 'expect' is also available if re-exported from my-fixtures.ts.
console.log(`Logging in as admin: ${adminUser.username}`);
await page.goto('/login');
await page.locator('#username').fill(adminUser.username);
await page.locator('#password').fill(adminUser.passwordForLogin);
await page.locator('button[type="submit"]').click();
// Verify login was successful before providing the page to the test
await expect(page).toHaveURL('/admin/dashboard', { timeout: 5000 }); // Add a reasonable timeout
console.log('Admin login successful, dashboard loaded');
await use(page); // Provide the now logged-in page object to the test
// Teardown (optional): Runs after the test using adminLoggedInPage finishes
console.log(`Logging out admin: ${adminUser.username}`);
// Example: await page.getByRole('button', { name: 'Logout Admin' }).click();
// await expect(page).toHaveURL('/login');
},
});
// export { expect }; // Still exported
How to use this in a test file (e.g., admin-panel.spec.ts
):
// admin-panel.spec.ts
import { test, expect } from './my-fixtures';
test.describe('Admin Panel Access', () => {
// The adminLoggedInPage fixture handles login before this test runs.
// 'page' here refers to the page object returned by adminLoggedInPage, already logged in.
test('admin should be able to access user management', async ({ adminLoggedInPage }) => {
await adminLoggedInPage.goto('/admin/users');
await expect(adminLoggedInPage.locator('h1')).toHaveText('User Management');
// Note: we use adminLoggedInPage as the 'page' object for interactions.
});
test('admin should see their username on the dashboard', async ({ adminLoggedInPage, adminUser }) => {
// If the adminLoggedInPage fixture doesn't navigate away from dashboard, this would work.
// Or navigate explicitly: await adminLoggedInPage.goto('/admin/dashboard');
await expect(adminLoggedInPage.locator('#admin-username-display')).toHaveText(adminUser.username);
});
});
This creates a clean, declarative way to build up complex test contexts.
5. Parameterizing Fixtures
While Playwright doesn't have direct fixture parameterization in the same way some other test frameworks do (e.g., pytest's params
on fixtures themselves), you can achieve similar effects for varying test setups:
- Multiple Fixtures: Create different, specifically named fixtures for different states (e.g.,
editorUserPage
,viewerUserPage
). Tests then pick the fixture that provides the desired state. - Environment Variables: Fixtures can read
process.env
variables to alter their behavior or the data they provide. This is often used in conjunction with Playwright Projects. - Fixture Overrides per Project: In
playwright.config.ts
, you can define projects that override specific fixtures. This is Playwright's idiomatic way to provide different fixture values or implementations for different test runs (e.g., differentbaseURL
for apage
fixture, or a differentuser
object for aloggedInUser
fixture across staging vs. production-like environments).
6. Advanced Fixture Use Cases & Patterns
Let's explore some common scenarios where advanced fixtures shine:
Managing Test Data
-
Fixture to load data from JSON/CSV (example for
my-fixtures.ts
):To load test data from a JSON file, you would first create your data file:
// test-data/productsData.json // (Place this file in your project, e.g., in a 'test-data' directory relative to 'my-fixtures.ts') [ { "id": 1, "name": "Laptop", "price": 1200 }, { "id": 2, "name": "Mouse", "price": 25 } ]
Then, add the fixture to your
my-fixtures.ts
file. Remember to importfs
andpath
:// my-fixtures.ts (continued) // Add these imports at the top of your my-fixtures.ts if not already present: // import * as fs from 'fs'; // import * as path from 'path'; // Define the type for the products type Product = { id: number; name: string; price: number }; // Define the type for the fixture that will provide the product list type ProductFixtures = { productList: Product[] }; // Assuming 'test' is your already extended test object from previous examples // (e.g., it already includes testUser, apiClient, logger, adminUser, adminLoggedInPage) export const test = test.extend<ProductFixtures>({ productList: [async ({}, use) => { // Construct the absolute path to the data file // __dirname is the directory of the current module (my-fixtures.ts) const filePath = path.join(__dirname, '../test-data/productsData.json'); // Adjust path as necessary console.log(`[Fixture] Loading product data from: ${filePath}`); const products: Product[] = JSON.parse(fs.readFileSync(filePath, 'utf-8')); await use(products); }, { scope: 'worker' }], // Worker scope if data is static and read-only for all tests in a worker });
Usage in a test file (e.g.,
product-listing.spec.ts
):// product-listing.spec.ts // import { test, expect } from './my-fixtures'; // // test('should display products from fixture', async ({ productList, page }) => { // console.log('Products loaded:', productList); // await page.goto('/products'); // for (const product of productList) { // await expect(page.locator(`text=${product.name}`)).toBeVisible(); // } // });
-
Fixture to create unique data via API (example for
my-fixtures.ts
):This fixture creates an article via an API and ensures it's cleaned up afterwards.
// my-fixtures.ts (continued) // Assuming 'apiClient' and 'expect' are already part of your extended 'test' object type DynamicArticle = { id: string; title: string; content: string }; type DynamicArticleFixture = { uniqueTestArticle: DynamicArticle }; // Extend your existing 'test' object export const test = test.extend<DynamicArticleFixture>({ uniqueTestArticle: async ({ apiClient, expect }, use) => { const articleTitle = `My Test Article ${Date.now()}`; const articleContent = 'This is dynamically created content.'; console.log(`[Fixture] Creating article via API: ${articleTitle}`); const response = await apiClient.post('/api/articles', { data: { title: articleTitle, content: articleContent } }); expect(response.ok()).toBeTruthy(); // Ensure API call was successful const article: DynamicArticle = await response.json(); expect(article.id).toBeDefined(); // Ensure we got an ID back // Provide the created article to the test await use(article); // Teardown: Delete the article after the test console.log(`[Fixture] Cleaning up article ${article.id} via API`); const deleteResponse = await apiClient.delete(`/api/articles/${article.id}`); expect(deleteResponse.ok()).toBeTruthy(); // Ensure cleanup was successful }, // Default scope is 'test', which is appropriate here for unique data per test });
Usage in a test file (e.g.,
article-view.spec.ts
):// article-view.spec.ts // import { test, expect } from './my-fixtures'; // // test('should interact with a unique article', async ({ uniqueTestArticle, page }) => { // await page.goto(`/articles/${uniqueTestArticle.id}`); // await expect(page.locator('h1')).toHaveText(uniqueTestArticle.title); // // ... further interactions or assertions ... // });
Service Abstractions & Mocks
-
API Client Fixture: (Shown in worker-scope example in Section 2)
-
Mocking API Responses with
page.route()
(example formy-fixtures.ts
):This fixture automatically mocks an API endpoint for user details.
// my-fixtures.ts (continued) // Define the type for this fixture (void as it doesn't pass a value to 'use') type MockUserApiFixture = { mockStandardUserApi: void }; // Extend your existing 'test' object export const test = test.extend<MockUserApiFixture>({ mockStandardUserApi: [async ({ page }, use) => { const apiUrl = '**/api/users/me'; // Define the URL to mock console.log(`[Fixture] Setting up API mock for ${apiUrl}`); await page.route(apiUrl, route => { console.log(`[Fixture] Intercepted ${apiUrl}, fulfilling with mock data.`); route.fulfill({ status: 200, contentType: 'application/json', json: { id: 'mockUser123', username: 'MockStandardUser', role: 'viewer' } }); }); await use(); // The mock is active during the test // Teardown: Unroute the mock after the test to avoid affecting other tests. console.log(`[Fixture] Tearing down API mock for ${apiUrl}`); await page.unroute(apiUrl); }, { auto: true }], // Auto-true: this mock applies to all tests using this extended 'test' object });
Usage in a test file (e.g.,
profile-mocked.spec.ts
): The mock is auto-applied, so no explicit destructuring is needed formockStandardUserApi
.// profile-mocked.spec.ts // import { test, expect } from './my-fixtures'; // // test('should display mocked user data on profile page', async ({ page }) => { // await page.goto('/profile'); // await expect(page.locator('#username-display')).toHaveText('MockStandardUser'); // await expect(page.locator('#user-role')).toHaveText('viewer'); // });
Browser Context Manipulation
-
Fixture for a page with specific permissions/storage (example for
my-fixtures.ts
):This fixture provides a
Page
object from a browser context with custom geolocation settings.// my-fixtures.ts (continued) // Import Page from '@playwright/test' if not already at the top of the file. // type Page is typically available via `import { Page } from '@playwright/test';` type GeoPageFixture = { pageWithGeoLocation: Page }; // Type for the fixture // Extend your existing 'test' object export const test = test.extend<GeoPageFixture>({ pageWithGeoLocation: async ({ browser }, use) => { console.log('[Fixture] Creating new browser context with geolocation permission set to Berlin'); const context = await browser.newContext({ geolocation: { latitude: 52.52, longitude: 13.39 }, // Berlin, Germany permissions: ['geolocation'] // Grant geolocation permission }); const geoPage = await context.newPage(); await use(geoPage); // Provide the specially configured page to the test // Teardown: Close the custom context after the test console.log('[Fixture] Closing browser context with geolocation permission'); await context.close(); }, // This fixture provides a 'pageWithGeoLocation' Page object. // Tests needing this specific setup should destructure 'pageWithGeoLocation' // and use it instead of (or in addition to) the default 'page' fixture if needed. });
Usage in a test file (e.g.,
map-view.spec.ts
):// map-view.spec.ts // import { test, expect } from './my-fixtures'; // // test('should display location-based content for Berlin', async ({ pageWithGeoLocation }) => { // await pageWithGeoLocation.goto('/map'); // // Example: Check if Berlin is centered or if location-specific features appear // await expect(pageWithGeoLocation.locator('#current-city-display')).toHaveText('Berlin'); // });
Complex Setup and Teardown Logic
Fixtures ensure teardown runs even if setup or the test itself fails (unless the worker process crashes). This makes them very robust for managing external resources or state that needs cleanup.
7. Organizing and Reporting Fixtures
As your fixture library grows, managing and understanding their usage in reports becomes important.
Combining Fixtures from Multiple Files
For larger projects, you might define fixtures in different files based on their domain or purpose. Playwright allows you to merge these using mergeTests
.
Example File Structure:
my-project/
├── fixtures/
│ ├── base-fixtures.ts // Contains common fixtures like testUser, apiClient
│ ├── user-auth-fixtures.ts // Fixtures for various user login states
│ └── product-fixtures.ts // Fixtures related to product setup
│ └── index.ts // Merges and exports the final 'test' object
└── tests/
└── my-feature.spec.ts
fixtures/index.ts
(Merges and exports):
// fixtures/index.ts
import { mergeTests } from '@playwright/test';
import { test as baseFixtures } from './base-fixtures'; // Assuming base-fixtures exports its extended test
import { test as userAuthFixtures } from './user-auth-fixtures';
import { test as productFixtures } from './product-fixtures';
// Merge all fixture sets. Order might matter if fixtures override each other (last one wins).
export const test = mergeTests(baseFixtures, userAuthFixtures, productFixtures);
// Re-export expect for convenience
export { expect } from '@playwright/test';
Usage in tests/my-feature.spec.ts
:
// tests/my-feature.spec.ts
import { test, expect } from '../fixtures'; // Import from the merged index file
// Assume loggedInUserPage comes from user-auth-fixtures and currentProduct from product-fixtures
// test('should use fixtures from different files', async ({ loggedInUserPage, currentProduct }) => {
// await loggedInUserPage.goto(`/products/${currentProduct.id}`);
// await expect(loggedInUserPage.locator('h1')).toHaveText(currentProduct.name);
// });
Controlling Fixture Visibility in Reports (box: true
)
Some fixtures are purely for internal setup and don't add much value to the test report steps. You can hide them from the report using the box: true
option. This is useful for utility or automatic fixtures that clutter the report.
// In my-fixtures.ts or any specific fixture file
// This example shows extending your current 'test' object with a new boxed fixture.
// Ensure 'test' is the progressively built object from your my-fixtures.ts.
// Define type for the new fixture
// type BoxedFixture = { internalSetupValue: string };
// export const test = test.extend<BoxedFixture>({
// internalSetupValue: [async ({}, use) => {
// console.log('[Fixture] Running boxed internalSetupFixture (hidden in report steps)');
// await use('internalValueFromBoxedFixture');
// console.log('[Fixture] Tearing down boxed internalSetupFixture');
// }, { box: true }], // This fixture's steps won't appear in the HTML report by default
// });
// Usage (if not auto:true, it needs to be destructured):
// test('test with a boxed fixture', async({ internalSetupValue }) => {
// console.log('Received from boxed fixture:', internalSetupValue);
// // Test logic...
// });
Customizing Fixture Titles in Reports (title: '...'
)
To make reports more readable, you can provide a custom title for your fixtures that will be displayed instead of the fixture name in the HTML report.
// In my-fixtures.ts
// Extend your existing 'test' object.
// type UserSessionData = { sessionId: string; userRole: string };
// type ReportTitleFixture = { userSession: UserSessionData };
// export const test = test.extend<ReportTitleFixture>({
// userSession: [async ({}, use) => {
// console.log('[Fixture] Setting up user session (will use custom title in report)');
// const sessionData: UserSessionData = { sessionId: 'xyz123', userRole: 'editor' };
// await use(sessionData);
// console.log('[Fixture] Tearing down user session');
// }, { title: 'Test Setup: Initialize User Session' }],
// });
// Usage:
// test('test with custom fixture title in report', async ({ userSession }) => {
// console.log('User session ID:', userSession.sessionId, 'Role:', userSession.userRole);
// // Test logic...
// });
8. Tips for Effective Fixture Design
- Single Responsibility: Each fixture should ideally do one thing well.
- Clear Naming: Make fixture names descriptive of what they provide or do.
- Scope Wisely: Use
scope: 'worker'
for genuinely shareable, expensive resources. Default toscope: 'test'
for test-specific state. - Document Your Fixtures: Especially for shared fixtures, comments explaining their purpose, scope, and any teardown logic are invaluable.
- Integration with POM: Page Object Models often consume fixtures. For instance, a
LoginPage
constructor in a POM might take thepage
fixture (or a specialized one likeadminLoggedInPage
), and the test passes this fixture to the POM instance. - Fixture Timeouts: Fixtures run within the test's timeout. For long-running fixture setups, especially those that are test-scoped, ensure your overall test timeout is adequate. For very long operations, consider if they can be worker-scoped. You can also use
testInfo.setTimeout(newTimeout)
within a fixture if that specific fixture needs more time than the default test timeout, but use this judiciously. - Re-export
expect
: When creating custom fixture files (e.g.,my-fixtures.ts
), it's a common pattern to also re-exportexpect
from@playwright/test
so that your test files can import bothtest
andexpect
from your single custom fixture file.
Conclusion
Playwright fixtures are a cornerstone of building scalable, maintainable, and readable test automation suites. By moving beyond basic beforeEach
hooks and embracing test.extend
, you can create powerful, composable setup and teardown logic that significantly improves your testing efficiency and code quality.
Start by identifying repetitive setup in your tests and consider how you could extract it into a fixture. The investment in learning and implementing fixtures will pay off as your project grows.