130

🛠️ Advanced Playwright Fixtures: Supercharge Your Test Setup and Teardown

Discover how to use advanced Playwright fixtures (test.extend()) to create modular, reusable setup/teardown units, significantly improving your test automation's efficiency, readability, and maintainability.

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:
    1. An object with already defined fixtures (like page, context, or your own custom fixtures that this new fixture might depend on).
    2. A use function. The fixture setup code runs before await use(fixtureValue), and the teardown code runs after it.
  • 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 our testUser 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 in playwright.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 global beforeEach 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., different baseURL for a page fixture, or a different user object for a loggedInUser 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 import fs and path:

    // 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 for my-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 for mockStandardUserApi.

    // 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 to scope: '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 the page fixture (or a specialized one like adminLoggedInPage), 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-export expect from @playwright/test so that your test files can import both test and expect 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.