QA Interview Hub · Knowledge base · Quizzes

Playwright

Fixtures and test structure

Fixtures and scopes

Fixtures in Playwright are a dependency injection mechanism: a test declares what it needs, for example { page, request }, and the runner prepares it. Custom fixtures are created with test.extend: the code before await use(value) is setup, and after it is teardown. Fixtures can depend on each other and are created only when a test has requested them.

There are two scopes: test (the default), where the fixture is created separately for each test, and worker, once per worker for all its tests. The scope is set with the third parameter: [async ({}, use) => {...}, { scope: 'worker' }]. Worker scope suits expensive things such as a DB connection or a test user.

There are several workers, so a worker fixture will be created in each of them; for unique data use workerInfo.workerIndex. The option auto: true runs a fixture without an explicit request, and option: true makes it a parameter set in the config. A typical pattern is fixtures with ready Page Objects, so a test gets loginPage right away.

Hooks, describe and conditional tests

The structure of tests: test.describe groups tests, and the hooks beforeEach, afterEach, beforeAll, afterAll do preparation and cleanup. beforeEach has access to the same fixtures as the test, for example page. beforeAll has access only to worker fixtures, because it runs once per worker, not per test.

When to choose a hook and when a fixture? A hook is simpler for one file. A fixture is better when the logic is repeated in many files, when a guaranteed teardown is needed, and when it is important that preparation runs only for the tests that need it.

Controlling execution:

  • test.skip(condition, 'reason') skips on a condition, for example for a browser.
  • test.fixme(): the test is known to be broken, don't run it.
  • test.fail(): the test must fail; if it passes, that is an error. Useful for a known bug: as soon as it is fixed, the test will report it.
  • test.slow(): triple the timeout.
test('PDF export', async ({ page, browserName }) => {
  test.skip(browserName === 'webkit', 'Bug PAY-512 in WebKit');
  await page.goto('/reports');
});

test('quick search', { tag: ['@smoke', '@search'] }, async ({ page }) => {
  await page.goto('/search');
});

Tags are set in the test details and filtered with --grep @smoke or --grep-invert @slow. Annotations annotation: { type: 'issue', description: 'PAY-512' } are visible in the HTML report.

In an interview: I use hooks for simple preparation in one file, and move repeated logic with teardown into fixtures. For known bugs I use test.fail with a link to the ticket, so the test itself reports when the bug is fixed. I mark the smoke suite with tags and run it with --grep.

test.step and the describe.configure modes

test.step splits a test into named steps. In the report and trace you see not a flat list of clicks but "Login", "Add item", "Pay", and it is immediately clear at which business step it failed. A step can return a value:

test('purchase', async ({ page }) => {
  const orderId = await test.step('Place the order', async () => {
    await page.getByRole('button', { name: 'Buy' }).click();
    return page.getByTestId('order-id').innerText();
  });
  await test.step('Check the status', async () => {
    await expect(page.getByText(`Order ${orderId}`)).toBeVisible();
  });
});

Steps are conveniently wrapped in Page Object methods. The option { box: true } makes the error point to the step call in the test, not to a line inside the helper, which makes the report easier to read.

test.describe.configure sets the execution mode of a file or a block:

ModeBehaviour
defaultthe file's tests run sequentially in one worker, but a failure of one doesn't cancel the others
parallelthe block's tests are distributed between workers
serialsequentially; on a failure the rest are skipped, and on a retry the whole group is restarted

Through configure you can also set retries and timeout for a group. Serial is an exception for long dependent scenarios; almost always it is better to make tests independent by preparing data through the API.

In an interview: I wrap business stages in test.step so the report reads like a scenario. I use serial mode rarely, because on a retry it restarts the whole group; instead I make tests independent. I turn on parallel for files where tests don't share data.

Fixtures in depth: worker, auto, option, mergeTests

A worker fixture with unique data for each worker:

type WorkerFixtures = { account: { email: string; password: string } };

export const test = base.extend<{}, WorkerFixtures>({
  account: [async ({ playwright }, use, workerInfo) => {
    const api = await playwright.request.newContext({ baseURL: process.env.API_URL });
    const email = `qa+w${workerInfo.workerIndex}-${Date.now()}@test.com`;
    await api.post('/api/test/users', { data: { email, password: 'Secret123' } });
    await use({ email, password: 'Secret123' });
    await api.delete(`/api/test/users/${email}`);
    await api.dispose();
  }, { scope: 'worker' }],
});

Note: the built-in fixtures request and page have test scope, so a worker fixture can't depend on them, and Playwright will throw an error. Here the worker fixture playwright is used, from which its own API context is created, and at the end it is closed with dispose().

An auto fixture runs for every test without an explicit request: [async ({ page }, use) => {...}, { auto: true }]. It fits collecting logs, checking JS errors, attaching artifacts.

An option fixture is a parameter with a default value that can be overridden in a config project or through test.use:

export const test = base.extend<{ currency: string }>({
  currency: ['UAH', { option: true }],
});

Built-in fixtures can be overridden: for example, a page that immediately opens the baseURL.

When there are many fixtures, they are split into modules and combined with mergeTests(dbTest, apiTest, uiTest), and custom matchers with mergeExpects. A fixture can also be given its own timeout, so that slow preparation doesn't eat up the test's time.

In an interview: For expensive preparation I make a worker fixture with workerIndex, so workers don't conflict over data. I use auto fixtures for cross-test checks and option fixtures for parameters that change between projects. I split a large set of fixtures into modules and combine them with mergeTests.