QA Interview Hub · Knowledge base · Quizzes

Patterns

Fixtures as Dependency Injection

Fixtures: DI built into Playwright Test

Dependency Injection means that an object doesn't create its dependencies itself but receives them from outside. In Playwright Test this is implemented through fixtures: a test declares in its arguments what it needs, and the runner creates, passes in and then cleans up.

page, context, browser, request are built-in fixtures. Your own are added through test.extend:

import { test as base } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { PaymentsPage } from './pages/payments-page';

type Pages = { loginPage: LoginPage; paymentsPage: PaymentsPage };

export const test = base.extend<Pages>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  paymentsPage: async ({ page }, use) => {
    await use(new PaymentsPage(page));
  },
});
export { expect } from '@playwright/test';
test('archived payment visible', async ({ paymentsPage }) => {
  await paymentsPage.goto();
  await expect(paymentsPage.archived.rowByText('INV-1')).toBeVisible();
});

Key properties: fixtures are lazy (created only if a test requested them), can depend on other fixtures, the code before use is setup, after use is teardown, and the teardown runs even if the test failed. Several sets of fixtures from different modules can be combined with mergeTests.

In an interview: Fixtures in Playwright are built-in DI: a test asks for a dependency in its arguments, and the runner creates and cleans it up. I register Page Objects and API clients as fixtures, so there is no new or manual cleanup in tests.

Scope, auto and setup/teardown

A fixture may have scope test (the default, new for every test) or worker (one per worker process, living between that worker's tests).

type WorkerFx = { apiToken: string };
type TestFx = { account: Account };

export const test = base.extend<TestFx, WorkerFx>({
  apiToken: [async ({}, use) => {
    const token = await fetchServiceToken();
    await use(token);
  }, { scope: 'worker' }],

  account: async ({ request, apiToken }, use) => {
    const acc = await createAccount(request, apiToken);
    await use(acc);
    await deleteAccount(request, apiToken, acc.id);
  },
});

Rules asked about in interviews:

  • A worker fixture can't depend on a test fixture (for example on page), because it lives longer.
  • Worker fixtures suit expensive and immutable resources: a token, a DB connection, a test user per worker.
  • { auto: true } runs a fixture for every test, even if it wasn't requested. It fits logging and collecting console errors. Don't abuse it, because it is hidden magic.
  • The teardown order is the reverse of setup.

When NOT to: turn a fixture into a scenario with a dozen steps. A fixture prepares the environment, and the scenario itself must be visible in the test.

In an interview: I make expensive immutable resources worker fixtures, and everything a test can change test fixtures with cleanup after use. I use auto fixtures only for cross-cutting things like logging.

Fixtures or a base test class

In the Java/Selenium world the standard is a BaseTest with @BeforeEach, where the driver and all pages are created. Candidates often carry this into Playwright, and that is a mistake.

Why fixtures are better:

  • Laziness. A base class creates everything for all tests. A fixture is created only for a test that needs it.
  • Isolation. Each test gets its own instances, and there are no shared class fields that can be accidentally changed.
  • Guaranteed teardown even when a test fails.
  • Composition. Fixtures combine through extend and mergeTests, whereas a base class can be inherited only once.
  • Typing. TypeScript knows the types of all the fixtures in the arguments.

beforeEach also has its place: for a simple step specific to one file (opening the needed page). But if the same beforeEach is copied into 10 files, it is a candidate for a fixture.

A typical mistake: declaring let loginPage: LoginPage at the describe level and filling it in beforeEach. This works, but a module variable easily becomes shared state, and TypeScript doesn't guarantee it is initialised.

In an interview: In Playwright I don't make a BaseTest, because fixtures give lazy creation, isolation, guaranteed cleanup and composition. I leave beforeEach for simple local steps within one file.