Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 011 / 100

Playwright fixtures: a practical guide for developers

Reusable setup that keeps tests clean.

Expona Admin9 min read

Playwright fixtures: a practical guide for developers. Reusable setup that keeps tests clean.

By day 11 of the Playwright testing journey, you've likely written dozens of tests that all need the same browser, page setup, or authentication state. Repeating that boilerplate in every test file doesn't scale. Playwright fixtures solve this by letting you define reusable setup logic once and inject it wherever you need it—keeping your tests clean, maintainable, and focused on what matters.

Why fixtures matter in test architecture

Test fixtures are a form of dependency injection. Instead of each test manually creating a browser, navigating to a login page, and clicking through auth, you declare what you need and Playwright handles the rest. This separation of concerns reduces duplication, makes tests easier to read, and makes it trivial to swap implementations—say, switching from real authentication to a mock.

We've all seen test files where half the code is setup and teardown. Fixtures move that noise out of your test logic. They also handle cleanup automatically: if a fixture opens a database connection or creates a temporary user in your API, you define the cleanup code right alongside the creation. Playwright runs it after your test finishes, whether the test passed or failed.

Built-in fixtures: page, context, browser, and request

Playwright gives you several fixtures out of the box. The most common is page, which represents a single tab in the browser. Every test function receives it by default:

typescript
import { test, expect } from '@playwright/test';

test('should load the homepage', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveTitle(/example/i);
});

The context fixture is a container for one or more pages. It holds cookies, local storage, and session state. Use it when you need multiple tabs or want to isolate state between tests:

typescript
test('should maintain cookies across tabs', async ({ context }) => {
  const page1 = await context.newPage();
  const page2 = await context.newPage();
  
  await page1.goto('https://example.com');
  await page1.evaluate(() => {
    localStorage.setItem('userId', '123');
  });
  
  await page2.goto('https://example.com');
  const userId = await page2.evaluate(() => localStorage.getItem('userId'));
  expect(userId).toBe('123');
  
  await page1.close();
  await page2.close();
});

The browser fixture gives you the entire browser instance. Use this when you need to create multiple independent contexts or when you're testing browser-level behavior:

typescript
test('should handle multiple isolated contexts', async ({ browser }) => {
  const context1 = await browser.newContext();
  const context2 = await browser.newContext();
  
  const page1 = await context1.newPage();
  const page2 = await context2.newPage();
  
  // Each context is completely isolated
  await context1.close();
  await context2.close();
});

The request fixture is an API client for making HTTP calls without a browser. Use it to set up test data via API calls before running your UI test, or to assert API responses directly:

typescript
test('should load user data from API', async ({ request, page }) => {
  // Use request to seed data
  const user = await request.post('https://api.example.com/users', {
    data: { name: 'Alice', email: 'alice@example.com' }
  });
  const userId = (await user.json()).id;
  
  // Then test the UI
  await page.goto(`https://example.com/users/${userId}`);
  await expect(page.locator('text=Alice')).toBeVisible();
});

Custom fixtures: extending test with your own objects

Built-in fixtures cover the basics, but production tests need more. This is where custom fixtures shine. You define a fixture by extending the test object with a new property and an async setup function. The setup function receives the base fixtures and yields the object your tests will receive.

A common pattern is wrapping page objects (POM pattern) in fixtures. Suppose you have a shopping cart page class:

typescript
export class CartPage {
  constructor(private page: Page) {}
  
  async addItem(itemId: string) {
    await this.page.click(`[data-item-id="${itemId}"]`);
    await this.page.click('text=Add to Cart');
  }
  
  async getCartCount() {
    const badge = this.page.locator('[data-testid="cart-badge"]');
    return parseInt(await badge.textContent(), 10);
  }
  
  async checkout() {
    await this.page.click('text=Proceed to Checkout');
    await this.page.waitForURL('**/checkout');
  }
}

Instead of creating this in every test, define it as a fixture:

typescript
import { test as base, expect } from '@playwright/test';
import { CartPage } from './pages/CartPage';

export const test = base.extend<{ cart: CartPage }>({
  cart: async ({ page }, use) => {
    const cartPage = new CartPage(page);
    await use(cartPage);
    // Cleanup after test (optional)
  }
});

// Now use it in tests
test('should add item to cart', async ({ cart }) => {
  await cart.addItem('item-123');
  const count = await cart.getCartCount();
  expect(count).toBe(1);
});

The key insight: the function receives the base fixtures (like page), sets up your custom object, calls use() to pass it to your test, and then can run cleanup code after use() returns.

Here's a more complex example with database cleanup:

typescript
export const test = base.extend<{ db: Database; authenticatedPage: Page }>({
  db: async ({}, use) => {
    const db = new Database({ connectionString: process.env.TEST_DB_URL });
    await db.connect();
    await use(db);
    await db.disconnect(); // Cleanup after test
  },
  
  authenticatedPage: async ({ page, db }, use) => {
    // Create a test user in the database
    const user = await db.query('INSERT INTO users (email, password) VALUES ($1, $2) RETURNING id', [
      'test@example.com',
      'hashed_password_here'
    ]);
    
    // Log in via UI
    await page.goto('https://example.com/login');
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password');
    await page.click('text=Sign In');
    await page.waitForURL('**/dashboard');
    
    await use(page);
    
    // Cleanup: delete the test user
    await db.query('DELETE FROM users WHERE id = $1', [user.id]);
  }
});

test('should display user dashboard', async ({ authenticatedPage, db }) => {
  await expect(authenticatedPage.locator('text=Welcome')).toBeVisible();
});

Worker fixtures: sharing expensive setup across tests

Some setup is so expensive that you don't want to repeat it for every test. Starting a Docker container, seeding a large database, or warming up a service takes time. Worker fixtures run once per worker process (not per test) and can be shared across multiple tests in that worker.

To create a worker fixture, pass scope: 'worker' to the fixture definition:

typescript
export const test = base.extend<
  { },
  { apiServer: string }
>({
  apiServer: [
    async ({}, use) => {
      console.log('Starting test API server...');
      const server = spawn('npm', ['run', 'start:test-server']);
      
      // Wait for server to be ready
      await waitForPort(3001);
      
      await use('http://localhost:3001');
      
      console.log('Stopping test API server...');
      server.kill();
    },
    { scope: 'worker' }
  ]
});

test('should call the API', async ({ apiServer, request }) => {
  const response = await request.get(`${apiServer}/health`);
  expect(response.ok()).toBe(true);
});

test('another test using same API server', async ({ apiServer, request }) => {
  const response = await request.get(`${apiServer}/status`);
  expect(response.ok()).toBe(true);
});

With this setup, the API server starts once when the worker begins and stops after all tests in that worker finish. Both tests share it, cutting setup time dramatically.

Automatic teardown: cleanup code after use()

Fixtures handle cleanup elegantly. Code after use() runs whether the test passes, fails, or times out. This is powerful for ensuring resources are always freed:

typescript
export const test = base.extend<{ tempFile: string }>({
  tempFile: async ({}, use) => {
    const filePath = path.join(os.tmpdir(), `test-${Date.now()}.txt`);
    fs.writeFileSync(filePath, 'test data');
    
    await use(filePath);
    
    // Cleanup runs even if the test fails
    if (fs.existsSync(filePath)) {
      fs.unlinkSync(filePath);
    }
  }
});

test('should use temp file', async ({ tempFile }) => {
  const content = fs.readFileSync(tempFile, 'utf-8');
  expect(content).toBe('test data');
  // tempFile is automatically cleaned up after this line
});

This pattern is also useful for logging. If a test fails, you can capture a screenshot or video in the cleanup code:

typescript
export const test = base.extend<{ page: Page }>({
  page: async ({ browser }, use) => {
    const context = await browser.newContext({
      recordVideo: { dir: 'videos' }
    });
    const page = await context.newPage();
    
    await use(page);
    
    // Video is automatically saved after test
    await context.close();
  }
});

Common mistakes

  • Forgetting to call use(). If you set up a fixture but never call use(), the test won't receive it and cleanup code won't run. Always call use(object) exactly once.
  • Not handling async errors. If setup throws an error, wrap it. The test will fail at the fixture level, which is the right behavior, but make sure your error messages are clear.
  • Creating fixtures that are too specific. A fixture for "authenticated admin user who has viewed three products" is too narrow. Instead, create smaller fixtures that compose: one for authentication, one for admin role, one for seeding products. Then combine them in tests that need all three.
  • Confusing fixture scope. Remember that test-scoped fixtures (the default) run per test, while worker-scoped fixtures run once per worker. If you're seeing state leak between tests, you likely have a worker-scoped fixture when you need test-scoped.
  • Mixing setup into test logic. If your test function has 20 lines of setup and 2 lines of assertions, move that setup into fixtures. Your tests should read like specifications, not implementation details.

Key takeaways

  • Built-in fixtures like page, context, browser, and request handle browser and API fundamentals; use them as the foundation for custom fixtures.
  • Custom fixtures let you encapsulate setup logic (like page objects or database seeding) and inject it into tests, keeping test files clean and readable.
  • The use() function is the boundary between setup and test; code before it runs before the test, code after runs during cleanup.
  • Worker-scoped fixtures are shared across tests in the same worker and are ideal for expensive one-time setup like starting a server or seeding a large database.
  • Cleanup code after use() always runs, even if a test fails, making fixtures reliable for resource management and error logging.
  • Compose small, focused fixtures instead of creating monolithic ones; this keeps your test suite flexible and maintainable as requirements change.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles