Fun with Learning Technology
LearnCoursesQuestionsTracksToolsNewsExplorePractice
Fun with Learning Technology

A new problem, explained clearly, every day.

Subscribe
Learn
  • Lessons
  • Topics
  • News
  • Tools
  • Courses
  • Career tracks
  • Everything
Site
  • About
  • Contact
  • Support
  • Privacy
  • Terms
Get the daily one

One email per new problem. No spam.

Request a tutorial

Requests shape what gets made next.

© 2026 Fun with Learning TechnologyRSS
Home›Courses›Playwright›Custom fixtures and test hooks

Playwright Best Practices and Ecosystem

Custom fixtures and test hooks

Custom fixtures and test hooks allow you to extend the standard test environment with reusable state, authentication patterns, and cleanup logic. They are essential for decoupling your test suite from repetitive setup boilerplate, ensuring that each test receives exactly the dependencies it needs in a clean, isolated state. You should reach for these abstractions when you find yourself repeatedly defining the same authentication steps, database seeding routines, or browser context configurations across multiple files.

Understanding Fixtures and Dependency Injection

In this testing framework, fixtures are not global variables but rather modular, lazy-loaded dependencies that are injected into your test functions. When you define a fixture, the framework constructs a dependency graph; it only initializes a fixture if a test specifically requests it. This dependency injection model is superior to global 'beforeAll' hooks because it prevents state pollution and keeps your test environment modular. Because the framework tracks these dependencies, it can automatically tear down fixtures in the reverse order of their creation, ensuring that resources like database connections or browser pages are closed appropriately. By defining fixtures in a central 'fixtures.ts' file, you move away from imperative setup routines and toward a declarative style. This encourages you to think about what the test needs as a set of capabilities rather than a series of manual setup steps, resulting in cleaner, more maintainable code that scales as your project grows significantly in complexity and size.

import { test as base } from '@playwright/test';

// Defining a basic fixture that provides a custom configuration
export const test = base.extend<{ userSession: string }>({
  userSession: async ({ page }, use) => {
    // Setup: Perform login logic here
    const token = 'mock-auth-token';
    await use(token); // The test waits for the fixture logic here
    // Teardown: Cleanup logic runs after the test finishes
  },
});

The Life Cycle of Test Hooks

Test hooks—specifically 'beforeEach', 'afterEach', 'beforeAll', and 'afterAll'—provide a chronological way to manage state across a suite of tests. While fixtures are preferred for per-test dependencies, hooks remain highly relevant for scenarios involving setup that must occur exactly once for a whole file or cleanup that doesn't fit the 'use' pattern of fixtures. It is critical to understand that hooks are executed in the order they are defined within a describe block. When using 'beforeAll', be aware that it runs before any tests are executed, which makes it perfect for heavy operations like starting a local development server or seeding a database. Conversely, 'afterAll' is the definitive place to perform cleanup of resources that persist across multiple tests. Because these hooks can be nested within 'describe' blocks, they allow you to create granular, hierarchical setup structures that grow naturally alongside your test organization, ensuring consistency across disparate features.

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

test.describe('Hook usage example', () => {
  test.beforeEach(async () => {
    // Executed before every single test in this block
    console.log('Setting up environment state');
  });

  test('simple test', async ({ page }) => {
    await page.goto('/');
    expect(true).toBeTruthy();
  });
});

Creating Reusable Fixtures with State

To build highly reusable fixtures, you should encapsulate specific application states, such as a logged-in user or a populated cart. By extending the base test object, you can combine existing fixtures with your own custom logic. This approach allows you to inject complex objects into your tests through the argument list, making the tests themselves much shorter and more readable. When creating these stateful fixtures, you should consider the 'auto' fixture property if a specific setup is required for every test without explicit declaration. However, be cautious: overusing auto fixtures can lead to performance degradation if they perform heavy tasks like browser navigations that not every test requires. Instead, focus on creating specific fixtures that represent functional domains, such as 'adminPage' or 'editorPage', which automate common interactions. This strategy ensures that your test code focuses purely on business assertions, effectively hiding the complexity of the underlying interactions within the infrastructure layer.

export const test = base.extend<{ authenticatedPage: any }>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.fill('#user', 'admin');
    await page.click('#submit');
    // Pass the already authenticated page to the test
    await use(page);
  }
});

Managing Fixture Teardown and Cleanup

The most powerful aspect of the fixture model is the guaranteed teardown, which occurs regardless of whether the test passes or fails. The 'use' function serves as the boundary between setup and teardown; code written before 'await use(...)' prepares the environment, while code written after is executed after the test concludes. This structure is essential for avoiding resource leaks, such as orphaned browser contexts or dangling database records. When designing your fixtures, always ensure that your teardown code is robust and handles errors gracefully so it does not block the reporting of the primary test result. If you are dealing with external APIs or cloud resources, use the teardown block to explicitly invalidate sessions or revert changes. By centralizing this cleanup logic, you ensure that every test run starts from a pristine, predictable environment, which is the cornerstone of building a reliable and non-flaky test suite that teams can trust implicitly.

const test = base.extend<{ db: any }>({
  db: async ({}, use) => {
    const db = await connectToDatabase();
    await use(db);
    // This code always runs, even if the test fails
    await db.close();
  }
});

Best Practices for Fixture Organization

For maintainable projects, organize your fixtures in a central 'tests/fixtures.ts' or 'test-setup' folder rather than scattering them across individual test files. This centralization facilitates easier refactoring and ensures a single source of truth for your test infrastructure. As your suite grows, consider partitioning your fixtures by domain or capability, importing them into a main test object that serves as the entry point for all your test files. Avoid creating 'mega-fixtures' that try to do everything; instead, favor small, composable fixtures that can be combined using object spreading or nested dependency injection. Regularly audit your fixtures to ensure they do not contain hidden side effects that might conflict with other tests running in parallel. By keeping the logic inside fixtures thin and focused, you ensure that the test suite remains fast, debuggable, and adaptable to future UI changes without requiring widespread updates to your core test logic.

// Standard export pattern for fixture organization
export const test = base.extend({
  // Composable fixtures
  auth: async ({ page }, use) => {
    /* logic */
    await use();
  },
  storage: async ({}, use) => {
    /* logic */
    await use();
  }
});

Key points

  • Fixtures use dependency injection to provide clean, isolated state for each test.
  • The 'use' function defines the boundary between fixture setup and automatic teardown.
  • Hooks like 'beforeEach' are appropriate for general setup that isn't specific to a dependency.
  • Fixtures are lazy-loaded, meaning they only execute if they are requested by the test.
  • Centralizing fixtures in a dedicated file improves maintainability and allows for easier refactoring.
  • Teardown logic in fixtures runs even when tests fail, which prevents environment pollution.
  • You should prefer small, composable fixtures over large, complex setup functions.
  • Test hooks should be used sparingly, prioritizing fixtures to maintain a modular dependency graph.

Common mistakes

  • Mistake: Defining global hooks inside individual test files. Why it's wrong: It causes code duplication and makes maintenance difficult as the suite grows. Fix: Use a global setup file configured in the playwright.config.ts.
  • Mistake: Overusing 'beforeEach' for data setup when 'test.extend' is available. Why it's wrong: 'beforeEach' forces you to repeat logic and return values via variables, whereas custom fixtures allow for clean dependency injection. Fix: Move repetitive setup logic into custom fixtures using the base.extend method.
  • Mistake: Forgetting to call the 'use' function in a fixture. Why it's wrong: If the 'use' callback is not executed, the test will hang indefinitely because it never receives the fixture object. Fix: Ensure 'await use(object)' is always present in your fixture factory function.
  • Mistake: Trying to share state between tests using global variables. Why it's wrong: Playwright tests run in parallel and in isolated worker processes; global variables will be inconsistent or null. Fix: Use custom fixtures or project-level setup if persistent state is required.
  • Mistake: Not providing a teardown mechanism in a fixture. Why it's wrong: Resources like database connections or temporary files remain open, potentially causing memory leaks or polluting the environment. Fix: Include code after the 'await use()' statement to perform cleanup operations.

Interview questions

What is the basic purpose of a Playwright fixture?

A fixture in Playwright serves as a mechanism to provide a specific environment or state for a test. Instead of repeating setup logic inside every test block, you define a fixture that handles tasks like logging in, setting up a database state, or initializing a page object. This promotes modularity because you can inject the fixture into any test that requires that specific state, ensuring consistency and cleaner test code.

How do you define a custom fixture in Playwright?

You define a custom fixture by extending the base test object provided by Playwright. You use the test.extend method, passing an object where each key represents the fixture name and the value is a function. This function can use other existing fixtures as dependencies. For example, to create a 'pageWithUser' fixture, you would extend the test object, define the async function, and then export this new test object for use throughout your suite.

What is the difference between 'test.beforeEach' hooks and fixtures?

While both can be used for setup, fixtures are superior because they are lazily evaluated and have built-in teardown capabilities. A 'beforeEach' hook runs unconditionally for every test in a describe block, which can be inefficient if only some tests need that setup. Fixtures only execute if the test explicitly requests them as an argument, and they handle the cleanup process automatically once the test finishes, leading to better resource management.

How can you implement teardown logic in a Playwright fixture?

To implement teardown logic, you use the fixture function with the 'use' callback. Before calling 'await use(value)', you perform your setup operations. After the 'await use(value)' line, you write the cleanup logic. Playwright ensures that the cleanup code executes regardless of whether the test passed or failed. For example, you might create a temporary folder in the setup and then use 'fs.rm' to delete it after the 'use' call.

How do you share state across different tests using fixtures?

You can share state across tests by defining a fixture with a 'worker' scope instead of the default 'test' scope. When a fixture is defined as { scope: 'worker' }, the fixture function executes once per worker process, rather than once per test. This is ideal for expensive operations like logging into an application or setting up a complex backend state that multiple tests need to reuse, significantly speeding up the overall test suite execution time.

Compare the use of 'test.use' configuration versus a custom fixture for modifying test behavior.

The 'test.use' configuration is used to apply static settings or overrides globally or per-file, such as viewport size, locale, or browser context settings. In contrast, custom fixtures provide dynamic, programmable logic. You should use 'test.use' for static environment configuration, but use custom fixtures when you need to inject complex objects, manage service state, or perform conditional setup logic that requires context from the test itself or the environment.

All Playwright interview questions →

Check yourself

1. What is the primary advantage of using a custom fixture over a 'beforeEach' hook for page objects?

  • A.Fixtures allow you to share state directly between different test worker processes.
  • B.Fixtures provide automatic dependency injection, making tests cleaner and more reusable.
  • C.Fixtures are required to run tests in parallel, whereas 'beforeEach' forces sequential execution.
  • D.Fixtures bypass the default browser context isolation for faster execution.
Show answer

B. Fixtures provide automatic dependency injection, making tests cleaner and more reusable.
Fixtures allow for dependency injection, which keeps tests focused only on the data they need. 'BeforeEach' is procedural and requires manual variable management. Parallel execution and browser context are handled by configuration, not by the choice of fixture vs hook.

2. If you define a fixture that depends on the base 'page' fixture, how does Playwright resolve this?

  • A.It executes the fixtures in reverse order of declaration.
  • B.It injects the base page automatically as an argument to your fixture function.
  • C.It requires you to manually instantiate the page object within the fixture factory.
  • D.It forces the test to run in a non-isolated environment to share the page state.
Show answer

B. It injects the base page automatically as an argument to your fixture function.
Playwright automatically manages the dependency graph and provides the base fixtures (like 'page' or 'request') as arguments to your custom fixture definition. You do not need to manually instantiate them.

3. What happens if you define a fixture and fail to call the 'use' function within it?

  • A.The test runner ignores the fixture and proceeds with the default behavior.
  • B.The test will automatically fail with a 'timeout' error because the fixture never yields.
  • C.The test will use a null value for the fixture, leading to runtime exceptions.
  • D.The fixture will automatically terminate after the timeout period ends.
Show answer

B. The test will automatically fail with a 'timeout' error because the fixture never yields.
The 'use' function is how the fixture provides the value to the test. If it is never called, the test remains in a pending state, eventually hitting the timeout limit and failing.

4. Why would you choose a 'worker' scope for a fixture instead of the default 'test' scope?

  • A.To ensure the fixture is recreated for every single 'it' block in the test file.
  • B.To allow the fixture to perform actions that are incompatible with the test-level isolation.
  • C.To persist a resource, such as a database connection, across multiple tests within the same worker.
  • D.To bypass the need for 'await use()' calls in the fixture definition.
Show answer

C. To persist a resource, such as a database connection, across multiple tests within the same worker.
Worker scope fixtures are instantiated once per worker process, making them ideal for heavy resources like servers or database pools that should be shared by multiple tests to improve performance. Test scope fixtures reset for every test.

5. How do you correctly handle cleanup (teardown) logic within a fixture?

  • A.By placing the cleanup code immediately before the 'await use()' statement.
  • B.By using an 'afterEach' hook inside the same test file to clean up the fixture state.
  • C.By writing the teardown code after the 'await use()' statement in the fixture factory.
  • D.By returning a callback function that the test runner executes after completion.
Show answer

C. By writing the teardown code after the 'await use()' statement in the fixture factory.
Code placed after 'await use()' is executed once the test (or worker) has finished, making it the correct location for cleanup tasks. Placing it before 'use' would execute it during the setup phase.

Take the full Playwright quiz →

← PreviousPage Object Model (POM) design patternNext →Working with Playwright Test annotations

Playwright

37 lessons, free to read.

All lessons →

Track your progress

Sign in to mark lessons done, score quizzes and keep notes.

Open in the app