Playwright Configuration, Fixtures & Projects

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Almost everything that makes a Playwright Test suite behave the way it does is decided in three places: the configuration file, which says how and where tests run; fixtures, which give each test the environment it needs; and projects, which run the same tests in several configurations. This page covers all three, plus the supporting machinery around them: timeouts, the webServer option that starts the application, global setup and teardown, annotations, tags and locks, parameterized tests and TypeScript. It describes Playwright 1.64.1 for Node.js (the Python, Java and .NET bindings were exercised at 1.63; see Playwright for Python, Java & .NET) and was checked on 25 October 2026. Every example below was run against the Bookshelf application with Chromium, the mobile emulation of a Pixel 7 and, where stated, WebKit. The complete test project used for this page has 71 tests in its main configuration (66 pass, five are skipped on purpose; the WebKit project adds 28 more) plus four small configurations for the webServer, global setup, timeout and parallel-run examples.

The page assumes you can already write and run a test (see Getting Started) and use locators and assertions (Locators, Actions & Auto-Waiting and Assertions).

The configuration file

Playwright Test looks for playwright.config.ts (or .js, .mjs, .cts, .mts) in the directory you run the command from; npx playwright test --config path/to/config.ts (short form -c) chooses another one. The file default-exports the result of defineConfig(), which only adds type checking and completion. Relative paths such as testDir: './tests' are resolved against the config file’s own directory.

There are two kinds of options, and mixing them up is the commonest configuration mistake:

  • Test runner options are top-level properties of the config: testDir, timeout, retries, workers, reporter, projects, webServer, globalSetup and so on. They describe how the runner schedules and reports tests.

  • Browser and context options live in the use section: baseURL, locale, trace, viewport and so on. They configure the browser, the browser context and the page that every test receives. They can be overridden per project, per file and per test, which the runner options (mostly) cannot.

The configuration used for the examples on this page reads the base URL from BASE_URL, like every other test project in this section, and declares two timeouts, a set of use options, a setup project with a teardown project, and several projects. Each part is explained in the sections that follow.

import { defineConfig, devices } from '@playwright/test';
import type { BookshelfOptions } from './fixtures';

export default defineConfig<BookshelfOptions>({
  testDir: './tests',
  outputDir: './test-results',

  // Runner options are top-level.
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: [['list']],

  // Timeouts.
  timeout: 20_000,
  expect: { timeout: 5_000 },
  globalTimeout: 10 * 60_000,

  // Browser, context and recording options.
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3914',
    testIdAttribute: 'data-testid',
    actionTimeout: 5_000,
    navigationTimeout: 10_000,
    locale: 'en-GB',
    timezoneId: 'Europe/Madrid',
    colorScheme: 'light',
    extraHTTPHeaders: { 'X-Test-Run': 'docs' },
    ignoreHTTPSErrors: true,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'off',
    launchOptions: { slowMo: 0 },
  },

  projects: [
    // Setup project, with a teardown project that runs after every dependent project.
    { name: 'setup', testMatch: /global\.setup\.ts/, teardown: 'cleanup' },
    { name: 'cleanup', testMatch: /global\.teardown\.ts/ },

    {
      name: 'desktop',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
      testIgnore: /(parameterized|locks)\.spec\.ts/,
    },
    {
      name: 'mobile',
      use: { ...devices['Pixel 7'] },
      dependencies: ['setup'],
      testIgnore: /(parameterized|locks)\.spec\.ts/,
    },
    // Tests that replace the shared data run last and serialise themselves with a lock.
    {
      name: 'shared-data',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['desktop', 'mobile'],
      testMatch: /locks\.spec\.ts/,
    },
    // Parameterized projects: the same specs with different option fixture values.
    {
      name: 'fiction-shelf',
      use: { ...devices['Desktop Chrome'], searchTerm: 'Fiction' },
      dependencies: ['setup'],
      testMatch: /parameterized\.spec\.ts/,
    },
    {
      name: 'nature-shelf',
      use: { ...devices['Desktop Chrome'], searchTerm: 'Nature' },
      dependencies: ['setup'],
      testMatch: /parameterized\.spec\.ts/,
    },
    // Configured but not run by a plain "npx playwright test" (1.64).
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
      dependencies: ['setup'],
      testIgnore: /(parameterized|locks)\.spec\.ts/,
      default: false,
    },
  ],
});

Test runner options

Option Effect

testDir

Directory searched for test files. Relative to the config file.

testMatch, testIgnore

Glob patterns or regular expressions that select or exclude test files. The default testMatch is **/*.@(spec|test).?(c|m)[jt]s?(x), which is why global.setup.ts above is not picked up by a plain project. Both options can also be set per project.

outputDir

Where traces, screenshots, videos and attachments of a run are written. Defaults to test-results. Playwright clears the folder at the start of each run, so do not keep hand-written files in it.

timeout, expect

Per-test timeout and the expect() options, including the assertion timeout. See Timeouts.

globalTimeout

Maximum time for the whole run. When it is reached Playwright stops, and the reporters still write their reports.

fullyParallel

Run every test of every file in parallel. Without it, tests in one file run in order in one worker. See Parallelism, Sharding & Retries.

workers

Maximum number of worker processes, or a percentage of the logical CPU cores such as '50%'.

retries

Number of retries for a failed test. Tests that pass on a retry are reported as flaky.

retryStrategy

When retries run: 'immediate' (default) or 'isolated', which runs all retries at the end in a single worker to minimize interference with the rest of the run (added in 1.62).

forbidOnly

Fail the run if a test.only was committed. Set it to !!process.env.CI so a focused test cannot silently skip the rest of the suite on CI.

reporter

One or more reporters, with options: [['list'], ['html', { open: 'never' }]].

grep, grepInvert

Run only tests whose title (including tags) matches or does not match a regular expression. The same options exist per project.

globalSetup, globalTeardown

Files that run once before and after the whole run. See Global setup and teardown.

projects

The list of projects. See Projects.

webServer

Start one or more servers before the tests. See The webServer option.

tsconfig

A single tsconfig file used when loading tests and reporters. See TypeScript.

Filtering with testMatch and testIgnore is how the example above keeps parameterized.spec.ts and locks.spec.ts away from the browser projects that should not run them. Both options accept an array, and patterns are matched against the absolute path, so '**/smoke/**' and /.*smoke.spec.ts/ both work.

Use options

The use section holds everything a test needs to know about its browser. Playwright creates one browser per worker and one fresh browser context per test; the options below are applied to that context and to the page opened in it, so every test starts from a known, isolated state. The most important ones, grouped as in the official reference:

Option Default Effect

baseURL

none

Prefix for relative navigation. page.goto('/books'), page.route(), page.waitForURL(), page.waitForRequest(), page.waitForResponse() and the request fixture all resolve against it with the URL() constructor.

testIdAttribute

data-testid

The attribute getByTestId() reads. Set it explicitly even when the default matches, so the contract is visible.

storageState

none

Cookies and local storage to start every context with: a file path or an object. The foundation of reusing a login; see Authentication & Isolation.

viewport

{ width: 1280, height: 720 }

Size of the page. Use null to disable the fixed viewport. Device descriptors replace it.

locale, timezoneId

browser default

Emulated navigator.language, Accept-Language header and number and date formatting, and the emulated time zone.

geolocation, permissions

none

Fixed position ({ latitude, longitude, accuracy }) and the permissions granted to every page, for example ['geolocation']. Without the permission the page’s geolocation request is denied.

colorScheme, reducedMotion, forcedColors, contrast

browser default

Emulate the matching prefers-* media features. colorScheme takes 'light', 'dark' or 'no-preference'.

userAgent, deviceScaleFactor, isMobile, hasTouch

browser default

Device emulation. Usually set together by spreading a device descriptor, ...devices['Pixel 7'].

javaScriptEnabled, bypassCSP

true, false

Turn page scripts off, or bypass the page’s Content-Security-Policy (useful when the policy names the production origin).

extraHTTPHeaders

none

Headers (string values only) added to every request the page and the request fixture make.

httpCredentials, proxy, offline

none, none, false

Basic or NTLM authentication, a proxy server with a bypass list, and an offline switch for the whole context.

ignoreHTTPSErrors

false

Do not fail on invalid certificates, for example a local development server with a self-signed certificate.

acceptDownloads

true

Whether downloads are accepted automatically.

actionTimeout

0 (none)

Timeout of each action such as click() or fill(). A per-call timeout option wins.

navigationTimeout

0 (none)

Timeout of goto() and other navigations.

headless

true

false shows the browser window. Equivalent to --headed on the command line.

browserName

chromium

'chromium', 'firefox' or 'webkit'. A device descriptor from devices already sets the matching engine.

channel

bundled build

Use a branded browser instead of the bundled one: 'chrome', 'chrome-beta', 'msedge' and so on. The browser must be installed.

launchOptions, contextOptions, connectOptions

empty

Anything accepted by browserType.launch(), browser.newContext() or browserType.connect(), for options that have no shortcut. For example launchOptions: { slowMo: 50 } slows every action down by 50 ms for a demo.

screenshot, trace, video

'off'

Evidence capture. See the next table and Screenshots, Videos & Traces.

Options that are not in the table, including every browser.newContext() option, are valid either at the top of use (when Playwright Test documents them as test options) or in contextOptions. Contexts you create yourself inherit the use options too, which the last test in use-options.spec.ts below demonstrates with browser.newContext().

Recording modes for trace and video

screenshot takes 'off', 'on', 'only-on-failure' and 'on-first-failure', or an object such as { mode: 'only-on-failure', fullPage: true }. trace and video accept the same set of eight modes. They differ in which attempts are recorded and which recordings are kept once the test finishes. "First run" is the original attempt; retries are the attempts after it.

Mode Records Keeps the recording when

'off'

never

never

'on'

every attempt

always

'retain-on-failure'

every attempt

that attempt failed

'retain-on-first-failure'

first run only

the first run failed

'retain-on-failure-and-retries'

every attempt

that attempt failed, or it is a retry

'on-first-retry'

first retry only

always

'on-all-retries'

every retry

always

With retries: 2, a test that fails once and then passes keeps the first run’s trace under retain-on-failure, the first run’s and the retry’s trace under on and retain-on-failure-and-retries, and only the retry’s trace under on-first-retry. The usual CI choice is trace: 'on-first-retry' (cheap: nothing is recorded for tests that pass the first time) and, for reproducing hard failures, 'retain-on-failure'. Since 1.63 the snapshots part of trace can be an object that selects DOM, aria and screen snapshots separately. In 1.64, video also accepts an object with mode, size, fps and a show.actions.style that styles the action markers with plain CSS; videos are encoded with VP9, and Firefox and WebKit capture up to 25 frames per second.

Setting options at four levels

The same option can be set globally, per project, per file or describe block with test.use(), and per test through test.use() in a nested block. The closest scope wins. This is the first spec file of the project; each describe block overrides a different option, and the last block shows how to put an option back to the configuration’s value.

import { test, expect } from '../fixtures';

test('config-level use options reach the page', async ({ page, baseURL }) => {
  await page.goto('/');
  expect(baseURL).toBe(process.env.BASE_URL);
  expect(await page.evaluate(() => navigator.language)).toBe('en-GB');
  expect(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)).toBe('Europe/Madrid');
  expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches)).toBe(false);
});

test('testIdAttribute makes getByTestId read data-testid', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByTestId('site-header')).toBeVisible();
});

test('extraHTTPHeaders are sent with every request', async ({ page }) => {
  const [request] = await Promise.all([
    page.waitForRequest('**/books'),
    page.goto('/books'),
  ]);
  expect((await request.allHeaders())['x-test-run']).toBe('docs');
});

test.describe('French locale for one describe block', () => {
  test.use({ locale: 'fr-FR', timezoneId: 'America/Mexico_City' });

  test('locale and time zone are overridden', async ({ page }) => {
    await page.goto('/');
    expect(await page.evaluate(() => navigator.language)).toBe('fr-FR');
    expect(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)).toBe('America/Mexico_City');
  });
});

test.describe('dark colour scheme', () => {
  test.use({ colorScheme: 'dark' });

  test('Bookshelf follows prefers-color-scheme', async ({ page }) => {
    await page.goto('/');
    expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches)).toBe(true);
    const background = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
    expect(background).toBe('rgb(17, 24, 39)');
  });
});

test.describe('explicit viewport', () => {
  test.use({ viewport: { width: 800, height: 600 } });

  test('the file-level viewport wins over the project', async ({ page }) => {
    await page.goto('/');
    expect(await page.evaluate(() => [innerWidth, innerHeight])).toEqual([800, 600]);
  });
});

test.describe('reset an option to the config value', () => {
  test.use({ baseURL: 'http://localhost:1/never-used' });

  test.describe('nested', () => {
    test.use({ baseURL: undefined });

    test('baseURL is back to the config value', async ({ page }) => {
      await page.goto('/');
      expect(new URL(page.url()).port).toBe(new URL(process.env.BASE_URL!).port);
    });
  });
});

test('browser fixtures inherit the use options', async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto(process.env.BASE_URL!);
  expect(await page.evaluate(() => navigator.language)).toBe('en-GB');
  await context.close();
});

Running the file in the desktop (Chromium) and mobile (Pixel 7) projects passed in both; the viewport test shows that the file-level 800 by 600 viewport wins over the Pixel’s own:

$ BASE_URL=http://localhost:3914 npx playwright test -c ex-configuration/playwright.config.ts --project=desktop --project=mobile --no-deps use-options
Running 16 tests using 5 workers

  ✓   1 [desktop] › tests/use-options.spec.ts:3:5 › config-level use options reach the page (169ms)
  ✓   2 [desktop] › tests/use-options.spec.ts:27:7 › French locale for one describe block › locale and time zone are overridden (136ms)
  ✓   6 [desktop] › tests/use-options.spec.ts:48:7 › explicit viewport › the file-level viewport wins over the project (84ms)
  ✓   7 [desktop] › tests/use-options.spec.ts:60:9 › reset an option to the config value › nested › baseURL is back to the config value (66ms)
  ✓   8 [desktop] › tests/use-options.spec.ts:67:5 › browser fixtures inherit the use options (120ms)
  ✓  10 [mobile] › tests/use-options.spec.ts:3:5 › config-level use options reach the page (184ms)
  ...

  16 passed (1.3s)

Three details are easy to miss. First, test.use({ baseURL: undefined }) restores the value from the configuration rather than removing it; to remove the option completely, use the long form baseURL: [async ({}, use) => use(undefined), { scope: 'test' }]. Second, test.use() at the top of a file applies to the whole file, inside test.describe() only to that block, and it cannot be called inside a test. Third, because device descriptors such as devices['Pixel 7'] set viewport, userAgent, isMobile and hasTouch together, spreading a descriptor after your own viewport overrides it; put your overrides last.

Timeouts

Playwright Test has several independent timeouts. Knowing which one fired is most of the work of fixing a "timeout" failure.

Timeout Default Set in the config Override for one test or call

Test timeout

30 000 ms

timeout: 60_000

test.setTimeout(120_000), test.slow(), testInfo.setTimeout()

Expect timeout

5 000 ms

expect: { timeout: 10_000 }

expect(locator).toBeVisible({ timeout: 10_000 })

Action timeout

none

use: { actionTimeout: 10_000 }

locator.click({ timeout: 10_000 })

Navigation timeout

none

use: { navigationTimeout: 30_000 }

page.goto('/', { timeout: 30_000 })

Global timeout

none

globalTimeout: 3_600_000

not applicable

beforeAll and afterAll hooks

30 000 ms, each

follows the test timeout

testInfo.setTimeout() inside the hook

Fixture timeout

shares the test’s (a worker fixture has its own)

not applicable

[fixture, { timeout: 60_000 }]

webServer start-up

60 000 ms

webServer: { timeout: 120_000 }

not applicable

The rules that matter most:

  • The test timeout covers the test function, the setup of the fixtures it uses and the beforeEach hooks. A separate timeout of the same value is shared by fixture teardown and afterEach hooks after the test ends. beforeAll and afterAll hooks get their own, which does not share time with any test.

  • The expect timeout is unrelated to the test timeout: a failing expect(locator).toHaveText() polls for 5 s and then fails, even if the test has 25 s left. Do not raise it globally to hide a slow application; raise it on the one assertion that needs it.

  • Action and navigation timeouts default to no limit, so a stuck action is stopped only by the test timeout. Setting actionTimeout to a few seconds produces an error that names the action, rather than a bare "test timeout exceeded".

  • test.slow() triples the test timeout and is also an annotation (see Annotations and tags); it can be conditional, test.slow(browserName === 'webkit', 'WebKit is slower on CI').

  • A slow fixture, typically a worker-scoped one that boots a service, should get its own timeout so the per-test limit can stay small.

import { test, expect } from '../fixtures';

test('test.slow() triples the timeout', async ({ page }) => {
  test.slow();
  expect(test.info().timeout).toBe(60_000);
  await page.goto('/');
});

test('test.setTimeout() sets an explicit timeout', async ({ page }) => {
  test.setTimeout(45_000);
  expect(test.info().timeout).toBe(45_000);
  await page.goto('/');
});

test('an assertion can have its own timeout', async ({ page }) => {
  await page.goto('/books');
  const assertion = expect(page.getByTestId('empty-state')).toBeVisible({ timeout: 500 });
  await expect(assertion).rejects.toThrow(/Timeout: 500ms/);
});

test('actionTimeout from the config limits a single action', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('button', { name: 'Does not exist' }).click()).rejects.toThrow(/Timeout 5000ms exceeded/);
});

test('a per-call timeout overrides actionTimeout', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('button', { name: 'Does not exist' }).click({ timeout: 700 })).rejects.toThrow(/Timeout 700ms exceeded/);
});

When the limit that fires is the test timeout, the report shows the call that was waiting. This is a separate run with a 2 second test timeout and actionTimeout set to 0, clicking a button that does not exist:

$ npx playwright test -c ex-configuration/timeout-demo/playwright.config.ts
  ✘  1 [chromium] › timeout-demo/timeout-demo.spec.ts:6:5 › this test is too slow (2.0s)

  1) [chromium] › timeout-demo/timeout-demo.spec.ts:6:5 › this test is too slow ───

    Test timeout of 2000ms exceeded.

    Error: locator.click: Test timeout of 2000ms exceeded.
    Call log:
      - waiting for getByRole('button', { name: 'Does not exist' })

In the main configuration, where actionTimeout is 5 seconds, the same click fails after 5 seconds with Timeout 5000ms exceeded, which is what the fourth test of timeouts.spec.ts asserts. Those assertions on error messages are only there to prove which timeout fired; real tests should not assert on timeout text.

The webServer option

Most browser tests need the application to be running. The webServer option starts it before the first test and stops it after the last, so npx playwright test works on a clean checkout and in CI without a separate script. Playwright starts the process, waits until the application is ready, runs the tests and then terminates the process.

Property Meaning

command

Shell command that starts the server, for example node server.js or npm run start.

cwd

Working directory of the process. Defaults to the directory of the config file.

env

Environment variables for the command. Defaults to process.env plus PLAYWRIGHT_TEST=1.

url

URL that must answer with a 2xx, 3xx, 400, 401, 402 or 403 status before tests start; redirects are followed. Use either url or port.

port

Deprecated in favor of url. Waits for the port to accept connections and passes it as the baseURL.

wait

Regular expressions to look for in stdout or stderr before the server counts as started. Named capture groups become upper-case environment variables, so (?<bookshelf_port>\d+) becomes BOOKSHELF_PORT. If both url and wait are given, the server is ready when either condition is met.

timeout

How long to wait for start-up, in milliseconds. Default 60 000.

reuseExistingServer

true uses a server that already answers on the url or port; false fails if something is already there. The common setting is !process.env.CI: reuse your own dev server locally, always start a fresh one on CI.

stdout, stderr

'pipe' shows the server’s output in the test output (default for stderr), 'ignore' hides it (default for stdout).

name

Prefix of the server’s log lines. Defaults to [WebServer].

gracefulShutdown

By default the process group is killed with SIGKILL. { signal: 'SIGTERM', timeout: 500 } sends SIGTERM first and kills only if the process is still alive after the timeout. Ignored on Windows. A Docker container needs SIGTERM.

ignoreHTTPSErrors

Ignore certificate errors when polling the url.

A separate configuration starts Bookshelf on port 3914 from its own folder with the settings that matter for a stateful test server: an explicit PORT, a short review delay, a wait regular expression on its start-up message and a graceful shutdown. Only webServer.url and the baseURL need the port.

import { defineConfig, devices } from '@playwright/test';

const port = process.env.BOOKSHELF_PORT ?? '3914';

export default defineConfig({
  testDir: '.',
  outputDir: '../test-results-webserver',
  reporter: [['list']],
  use: { baseURL: `http://localhost:${port}`, testIdAttribute: 'data-testid' },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],

  webServer: {
    name: 'Bookshelf',
    command: 'node server.js',
    cwd: '../../../../bookshelf',
    env: { PORT: port, BOOKSHELF_REVIEW_DELAY_MS: '100' },
    url: `http://localhost:${port}/api/session`,
    wait: { stdout: /Bookshelf listening on http:\/\/localhost:(?<bookshelf_port>\d+)/ },
    reuseExistingServer: !process.env.CI,
    timeout: 30_000,
    stdout: 'pipe',
    stderr: 'pipe',
    gracefulShutdown: { signal: 'SIGTERM', timeout: 2_000 },
  },
});
import { test, expect } from '@playwright/test';

test('Bookshelf answers on the configured base URL', async ({ page, request }, testInfo) => {
  // The named capture group of wait.stdout becomes an upper-case environment variable.
  // It is only set when Playwright started the server itself, not when it reused one.
  console.log('captured port:', process.env.BOOKSHELF_PORT ?? '(not captured, server was reused)');
  await page.goto('/books');
  await expect(page.getByTestId('result-count')).toHaveText('24 books found');
  const session = await (await request.get('/api/session')).json();
  expect(session.authenticated).toBe(false);
  expect(testInfo.project.use.baseURL).toContain('localhost');
});

The three outcomes below are real runs of that configuration. With nothing listening on the port and reuseExistingServer false (here CI=1), Playwright starts Bookshelf, waits for its start-up line, runs the test and stops the server, which was verified by checking that the port no longer answered. With a Bookshelf already running on the port and reuseExistingServer false, the run fails immediately instead of testing against the wrong instance. With reuseExistingServer true (no CI) the running server is used, and the wait capture group is not set, because Playwright never saw the start-up line.

$ CI=1 npx playwright test -c ex-configuration/webserver/playwright.config.ts
[Bookshelf] Bookshelf listening on http://localhost:3914

Running 1 test using 1 worker

captured port: 3914
  ✓  1 [chromium] › webserver/webserver.spec.ts:3:5 › Bookshelf answers on the configured base URL (91ms)

  1 passed (468ms)

$ CI=1 npx playwright test -c ex-configuration/webserver/playwright.config.ts   # a server is already on port 3914
Error: http://localhost:3914/api/session is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.

$ npx playwright test -c ex-configuration/webserver/playwright.config.ts        # same, with reuseExistingServer true
captured port: (not captured, server was reused)
  ✓  1 [chromium] › webserver/webserver.spec.ts:3:5 › Bookshelf answers on the configured base URL (113ms)

Practical points:

  • DEBUG=pw:webserver npx playwright test prints every readiness poll and the server’s stdout, which is the quickest way to see why a server "never starts". A common cause is a url that returns 404 or 5xx; point it at a route that answers, such as a health or session endpoint, not at an authenticated page.

  • Tests can use relative URLs when use.baseURL is set. When webServer is an array, Playwright cannot guess which entry is the base, so set baseURL explicitly.

  • The wait capture is the way to cope with servers that choose their own port. It becomes visible in the config’s environment only after the server starts, so read it in the test or in a fixture, not at the top of the config file. The reference example for this feature writes the option as baseUrl; the actual option name is baseURL.

  • Start several processes with an array: webServer: [{ command: 'npm run start', url: 'http://localhost:3000', name: 'Frontend' }, { command: 'npm run backend', url: 'http://localhost:3333', name: 'Backend' }].

  • reuseExistingServer: true is convenient and also dangerous: a stale server from yesterday makes tests pass against old code. Keep it for local development and turn it off on CI.

  • The main configuration on this page does not use webServer at all and reads BASE_URL: start the application yourself (or in CI, before the job) and skip the option, which is the right choice when the application is deployed elsewhere, built by another job, or shared with other tools.

Fixtures

A fixture is something a test asks for by name and Playwright sets up for it: a page, a logged-in user, a page object, a database connection. Tests receive fixtures through the first argument, a destructured object, and Playwright creates only those that the test (or the fixtures it depends on) mentions. Compared with beforeEach/afterEach hooks, fixtures keep set-up and tear-down in one place, are reusable between files, are created on demand, can depend on each other, and need no describe wrapper to share an environment, so tests can be grouped by meaning instead of by set-up.

Built-in fixtures

Fixture Type Description

page

Page

An isolated page for this test. It belongs to the context fixture.

context

BrowserContext

An isolated context for this test, created from the use options. Cookies, storage and permissions never leak into the next test.

browser

Browser

The browser, shared by all tests of a worker to save resources. Use browser.newContext() when a test needs a second user; contexts you create yourself inherit the use options.

browserName

string

chromium, firefox or webkit. Use it in conditions, not as a replacement for projects.

request

APIRequestContext

An isolated HTTP client for this test, with baseURL and extraHTTPHeaders applied. Used to arrange state through the API; see Network Mocking, Clock & API Testing.

Every use option is also a fixture (baseURL, viewport, isMobile, storageState and so on), which is why a test or another fixture can depend on { baseURL }, as isMobile does in annotations.spec.ts below. testInfo (metadata about the running test: title, project, status, output paths, attachments, annotations) is a second argument to tests, hooks and fixtures, and workerInfo plays the same role in worker-scoped fixtures.

Creating fixtures with test.extend

base.extend() returns a new test object that has everything the base had plus your fixtures. A fixture is an async function that receives the fixtures it depends on, an object, then calls use() with the value the test will receive. The code before use() is set-up, the code after it is tear-down, and it runs even when the test fails. The project’s fixtures.ts defines one of each kind, described under the file.

import { test as base, expect, request as pwRequest } from '@playwright/test';
import { credentials, type Role } from '@helpers/credentials';

// Options: configurable per project in playwright.config.ts or per file with test.use().
export type BookshelfOptions = {
  role: Role | 'anonymous';
  searchTerm: string;
};

// Test-scoped fixtures.
type TestFixtures = {
  serverErrors: () => number;
  pageErrors: string[];
  appPage: import('@playwright/test').Page;
};

// Worker-scoped fixtures.
type WorkerFixtures = {
  catalogue: { workerIndex: number; totalBooks: number };
};

export const test = base.extend<BookshelfOptions & TestFixtures, WorkerFixtures>({
  // An option fixture: the default is used when no project or test.use() overrides it.
  role: ['anonymous', { option: true }],
  searchTerm: ['', { option: true }],

  // An automatic, boxed fixture: set up for every test even when the test does not list it,
  // and hidden from the step list of reports.
  serverErrors: [async ({ page }, use, testInfo) => {
    let failures = 0;
    page.on('response', (response) => {
      if (response.status() >= 500) failures++;
    });
    await use(() => failures);
    testInfo.annotations.push({ type: 'server-errors', description: String(failures) });
  }, { auto: true, box: true, title: 'Count 5xx responses' }],

  // An automatic fixture with teardown that inspects the test result.
  pageErrors: [async ({ page }, use, testInfo) => {
    const errors: string[] = [];
    page.on('pageerror', (error) => errors.push(error.message));
    await use(errors);
    if (errors.length > 0) {
      await testInfo.attach('page-errors', { body: errors.join('\n'), contentType: 'text/plain' });
    }
    expect(errors, 'uncaught exceptions in the page').toEqual([]);
  }, { auto: true }],

  // Overrides a built-in fixture's behaviour by composing it: signs in through the API when a role is set.
  appPage: async ({ page, role }, use) => {
    if (role !== 'anonymous') {
      const response = await page.request.post('/api/session', { data: credentials[role] });
      expect(response.ok()).toBe(true);
    }
    await use(page);
  },

  // A worker-scoped fixture: set up once per worker process and shared by every test it runs.
  catalogue: [async ({ playwright }, use, workerInfo) => {
    const api = await playwright.request.newContext({ baseURL: process.env.BASE_URL });
    const body = await (await api.get('/api/books')).json();
    await api.dispose();
    await use({ workerIndex: workerInfo.workerIndex, totalBooks: body.total });
  }, { scope: 'worker', timeout: 30_000 }],
});

export { expect };
// Passwords come from the environment, the same variables the Bookshelf server reads.
export const credentials = {
  reader: { username: 'reader', password: process.env.BOOKSHELF_READER_PASSWORD ?? 'reader-local-only' },
  admin: { username: 'admin', password: process.env.BOOKSHELF_ADMIN_PASSWORD ?? 'admin-local-only' },
} as const;

export type Role = keyof typeof credentials;
  • Test-scoped fixtures (the default) are created for each test and torn down after it. appPage signs in through the API with page.request, which shares cookies with the page, so a test starts already signed in without clicking through the form. It depends on the role option, so a test or a project chooses who is signed in.

  • Worker-scoped fixtures are created once per worker process and shared by every test the worker runs, and torn down when the worker shuts down. Declare them with the second generic parameter and { scope: 'worker' }. They are the place for expensive, read-only resources: a started service, a seeded account per worker (use workerInfo.workerIndex to make names unique), a compiled asset. A worker fixture cannot depend on test-scoped fixtures such as page or request, which is why catalogue creates its own API client with playwright.request.newContext() and reads BASE_URL from the environment. It gets a separate timeout equal to the test timeout, adjustable as shown.

  • Automatic fixtures use { auto: true }. They are set up for every test (or every worker) even when the test does not list them. pageErrors records uncaught page exceptions and fails the test during tear-down, and serverErrors counts 5xx responses. Automatic fixtures are also the way to write a beforeEach/afterEach or beforeAll/afterAll that applies to every file: declare it with auto: true (and scope: 'worker' for the beforeAll equivalent) in the shared test object, and import that object in all specs.

  • Option fixtures use { option: true }. They have a default and are meant to be overridden in the config or with test.use(), which makes configuration declarative and type-safe. Pass the option types to defineConfig<Options>() so the config checks them, as playwright.config.ts does. If the value of an option is an array, wrap it: test.use({ persons: [[{ name: 'Alice' }], { scope: 'test' }] }).

  • Overriding a built-in fixture: a fixture with the same name replaces (or wraps) the original and can depend on it. page: async ({ baseURL, page }, use) => { await page.goto(baseURL); await use(page); } makes every test start on the home page. Overriding storageState this way supplies cookies computed at run time. Overriding changes the fixture for every test that uses that test object, so keep a differently named fixture (like appPage) when only some tests want the new behavior.

  • Names start with a letter or underscore and contain only letters, digits and underscores.

Merging fixtures and sharing them

mergeTests() combines the fixtures of several test objects, so separate modules (database, accessibility, API helpers) can each define theirs and a spec uses what it needs. fixtures-extra.ts below provides a stopwatch fixture, and fixtures.spec.ts merges it with the Bookshelf fixtures.

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

// A second module of fixtures, merged with fixtures.ts by mergeTests().
export const test = base.extend<{ stopwatch: () => number }>({
  stopwatch: async ({}, use) => {
    const started = Date.now();
    await use(() => Date.now() - started);
  },
});
import { mergeTests } from '@playwright/test';
import { test as bookshelfTest, expect } from '../fixtures';
import { test as timingTest } from '../fixtures-extra';

const test = mergeTests(bookshelfTest, timingTest);

test('built-in fixtures: page, context, browser, browserName and request', async ({ page, context, browser, browserName, request }) => {
  expect(page.context()).toBe(context);
  expect(context.browser()).toBe(browser);
  expect(['chromium', 'firefox', 'webkit']).toContain(browserName);
  const response = await request.get('/api/session');
  expect(await response.json()).toEqual({ authenticated: false });
});

test('worker-scoped fixture is created once per worker', async ({ catalogue }) => {
  expect(catalogue.totalBooks).toBe(24);
  expect(catalogue.workerIndex).toBeGreaterThanOrEqual(0);
});

test.describe('automatic fixture', () => {
  test.use({ role: 'reader' });

  test('counts 5xx responses although the test never lists it', async ({ appPage, serverErrors }) => {
    await appPage.goto('/books/1');
    const status = await appPage.evaluate(async () => {
      const response = await fetch('/api/reviews', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ bookId: 1, rating: 3, text: 'This text contains FAIL on purpose' }),
      });
      return response.status;
    });
    expect(status).toBe(500);
    expect(serverErrors()).toBe(1);
  });
});

test.describe('role option', () => {
  test.use({ role: 'reader' });

  test('appPage is already signed in as the reader', async ({ appPage }) => {
    await appPage.goto('/account');
    await expect(appPage.getByTestId('account-heading')).toContainText('reader');
  });
});

test.describe('admin role', () => {
  test.use({ role: 'admin' });

  test('appPage is signed in as the admin', async ({ appPage, stopwatch }) => {
    await appPage.goto('/admin');
    await expect(appPage.getByTestId('nav-admin')).toBeVisible();
    expect(stopwatch()).toBeLessThan(15_000);
  });
});

test('the default role is anonymous', async ({ appPage }) => {
  await appPage.goto('/account');
  await expect(appPage).toHaveURL(/\/signin\?next=\/account$/);
});

test('merged fixtures from two modules are both available', async ({ stopwatch, role }) => {
  expect(role).toBe('anonymous');
  expect(stopwatch()).toBeGreaterThanOrEqual(0);
});

Running the file with the main configuration (desktop project only) produced:

$ BASE_URL=http://localhost:3914 npx playwright test -c ex-configuration/playwright.config.ts --workers 1 --project=desktop --no-deps fixtures.spec
  ✓  1 [desktop] › tests/fixtures.spec.ts:7:1 › built-in fixtures: page, context, browser, browserName and request (59ms)
  ✓  2 [desktop] › tests/fixtures.spec.ts:15:1 › worker-scoped fixture is created once per worker (57ms)
  ✓  3 [desktop] › tests/fixtures.spec.ts:23:3 › automatic fixture › counts 5xx responses although the test never lists it (161ms)
  ✓  4 [desktop] › tests/fixtures.spec.ts:41:3 › role option › appPage is already signed in as the reader (62ms)
  ✓  5 [desktop] › tests/fixtures.spec.ts:50:3 › admin role › appPage is signed in as the admin (73ms)
  ✓  6 [desktop] › tests/fixtures.spec.ts:57:1 › the default role is anonymous (70ms)
  ✓  7 [desktop] › tests/fixtures.spec.ts:62:1 › merged fixtures from two modules are both available (59ms)

  7 passed (830ms)

Execution order

Each fixture’s set-up runs before the test or hook that needs it, and its tear-down runs when nothing needs it any more. The rules:

  1. When fixture A depends on fixture B, B is set up before A and torn down after A.

  2. Non-automatic fixtures are lazy: they are created only when a test or hook needs them, so an unused fixture is never set up.

  3. Test-scoped fixtures are torn down after each test; worker-scoped ones when the worker process shuts down.

  4. Automatic worker fixtures are set up before anything else in the worker, automatic test fixtures before beforeEach hooks and the test itself.

For a worker with a beforeAll, a first test that only uses page and a second that uses a test fixture depending on a worker fixture, the order is: browser (needed by an automatic worker fixture), the automatic worker fixture, beforeAll; then for each test the automatic test fixture, page, beforeEach, the test, afterEach, and the teardown in reverse; the worker fixture is created lazily before the second test but torn down only at the end, after afterAll.

Box fixtures, titles and timeouts

Custom fixtures show up as steps in UI Mode, the trace viewer and the HTML report, and in error messages. For fixtures that are plumbing, that is noise:

export const test = base.extend({
  helperFixture: [async ({}, use, testInfo) => {
    // ...
    await use();
  }, { box: true, title: 'Prepare data' }],
});

{ box: true } hides the fixture and its steps; { box: 'self' } hides only the fixture’s own step and keeps the steps inside it. title replaces the fixture name in reports. The Bookshelf serverErrors fixture above is boxed and titled, and ran without problems; the box and title options change the report and trace only, which was not inspected for this page. { timeout: 60_000 } gives one fixture its own timeout, so a slow set-up does not eat the test’s.

Projects

A project is a group of tests that run with the same configuration. Projects are the unit that Playwright uses to run tests in several browsers and devices, in several environments, with several option values, or in an ordered pipeline. Every project can override use, testDir, testMatch, testIgnore, grep, retries, timeout, outputDir, snapshotPathTemplate and more.

Browsers and devices

devices is a registry of descriptors (viewport, user agent, device scale factor, touch, engine). Spread a descriptor into use to run a project as that device:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
    { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
    { name: 'Microsoft Edge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
    { name: 'Google Chrome', use: { ...devices['Desktop Chrome'], channel: 'chrome' } },
  ],
});

This list is the official one and was not run as written: the Chromium, Pixel 7 and WebKit projects of this page’s configuration were run, but Firefox does not launch on the macOS version of the test machine (it works in the Linux Docker image), and Microsoft Edge is not installed. Cross-browser strategy is the topic of Cross-Browser Testing.

By default npx playwright test runs every project. --project=firefox runs one (repeat the flag for several, and wildcards work), and --project="*" runs all of them, including those marked default: false.

Environments and splitting

Projects can differ in more than the browser: a staging project with two retries and a production project with none, or a Smoke project (testMatch: /.*smoke.spec.ts/, retries: 0) next to a Default project that ignores those files. The main configuration splits files this way: desktop and mobile ignore parameterized.spec.ts and locks.spec.ts, which have their own projects.

Dependencies and teardown

dependencies lists projects that must run first; teardown names a project that runs after everything that depended on the setup project has finished. This is the recommended replacement for a global setup file, because the setup is reported like any other test, can use fixtures, and records traces. In the configuration above, setup resets Bookshelf and cleanup resets it again at the end:

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

setup('Bookshelf is up and reset', async ({ request }) => {
  const reset = await request.post('/api/test/reset');
  expect(await reset.json()).toEqual({ books: 24, reviews: 3 });
});
import { test as teardown } from '@playwright/test';

teardown('leave Bookshelf clean', async ({ request }) => {
  await request.post('/api/test/reset');
  console.log('teardown: Bookshelf reset');
});

The running order is: the setup project runs; once all of its tests have passed, the projects that depend on it run in parallel (limited by workers); when they finish, the teardown project runs. With several dependencies they run first and in parallel; if any of them fails, the dependent projects do not run at all. This run of the main configuration shows the order and that the teardown ran last:

$ npx playwright test -c ex-configuration/playwright.config.ts --grep @smoke
Running 4 tests using 2 workers

  ✓  1 [setup] › tests/global.setup.ts:3:6 › Bookshelf is up and reset (25ms)
  ✓  2 [desktop] › tests/annotations.spec.ts:3:5 › tagged with the details object @smoke (148ms)
  ✓  3 [mobile] › tests/annotations.spec.ts:3:5 › tagged with the details object @smoke (171ms)
teardown: Bookshelf reset
  ✓  4 [cleanup] › tests/global.teardown.ts:3:9 › leave Bookshelf clean (32ms)

  4 passed (1.2s)

Filtering selects the primary tests, and their dependencies come along. --grep @smoke selected the two smoke tests and Playwright added setup and cleanup. Selecting the shared-data project by itself pulled in desktop and mobile (which it depends on) and therefore every test: 61 tests ran for --project=shared-data. Add --no-deps to ignore dependencies and teardowns and run only the projects you named. That also skips the setup project’s reset: after the locks run, Bookshelf was left with an empty catalogue, and the next --no-deps run of the unrelated specs failed on 24-book assumptions until the data was reset by hand. Use --no-deps only when the tests do not rely on the setup.

A project that is configured but should not run by default has default: false (new in 1.64). The webkit project of the main configuration is one: plain npx playwright test lists 71 tests, --project=webkit ran 28 WebKit tests and passed, and --project="*" lists 99.

$ npx playwright test -c ex-configuration/playwright.config.ts --list | tail -1
Total: 71 tests in 8 files
$ npx playwright test -c ex-configuration/playwright.config.ts --project="*" --list | tail -1
Total: 99 tests in 8 files
$ npx playwright test -c ex-configuration/playwright.config.ts --project=webkit
  ...
  2 skipped
  28 passed (8.4s)

Also useful: fullConfig.filteredProjects (1.64) lists the projects that are actually selected, from global setup and reporters, and --shuffle (1.64) runs tests in random order and prints the seed to reproduce it, which is a cheap way to find tests that depend on each other.

Global setup and teardown

There are two mechanisms. Project dependencies (shown above) are recommended. A global setup file, configured with globalSetup and globalTeardown, is older and simpler but runs outside the test runner.

Feature Project dependencies globalSetup file

Runs before all tests

yes

yes

Shown in the HTML report

yes, as a project

no

Trace recording

yes

no (manual tracing only)

Playwright fixtures

yes

no

Browser management

through the browser fixture

manual, with browserType.launch()

Retries and parallelism

standard configuration

not applicable

use options such as headless and testIdAttribute

applied automatically

ignored

A global setup file exports one function that receives the resolved FullConfig; if it returns a function, that function becomes the global teardown. Data can be handed to tests through process.env, which the tests see because workers are started after the setup ran. This one resets Bookshelf, counts its books and prints the projects it was given:

import { request, type FullConfig } from '@playwright/test';

// A global setup file exports one function. It runs once, before any project, outside the test runner:
// there are no fixtures, no trace and no entry in the report.
export default async function globalSetup(config: FullConfig) {
  const baseURL = config.projects[0].use.baseURL!;
  const api = await request.newContext({ baseURL });
  await api.post('/api/test/reset');
  const { total } = await (await api.get('/api/books')).json();
  await api.dispose();

  // Share data with the tests through environment variables.
  process.env.BOOKSHELF_BOOK_COUNT = String(total);
  console.log(`global setup: ${total} books, projects: ${config.projects.map((p) => p.name).join(', ')}`);

  // Returning a function registers it as the global teardown.
  return async () => {
    console.log('global teardown (returned from globalSetup)');
  };
}
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: '.',
  globalSetup: require.resolve('./global-setup'),
  reporter: [['list']],
  outputDir: '../test-results-globalsetup',
  use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3914', testIdAttribute: 'data-testid' },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
import { test, expect } from '@playwright/test';

test('data prepared by globalSetup is visible through process.env', async ({ page }) => {
  expect(process.env.BOOKSHELF_BOOK_COUNT).toBe('24');
  await page.goto('/books');
  await expect(page.getByTestId('result-count')).toHaveText(`${process.env.BOOKSHELF_BOOK_COUNT} books found`);
});
$ BASE_URL=http://localhost:3914 npx playwright test -c ex-configuration/globalsetup/playwright.config.ts
global setup: 24 books, projects: chromium

Running 1 test using 1 worker

  ✓  1 [chromium] › globalsetup/global-setup.spec.ts:3:5 › data prepared by globalSetup is visible through process.env (89ms)
global teardown (returned from globalSetup)

  1 passed (511ms)

When a global setup needs a browser (to log in and save storageState, for example), it has to launch it itself, and its failures are not traced unless you start tracing manually and stop it in a catch block before rethrowing. Moving the login into a setup project, which the official guide recommends, avoids that boilerplate. The login and storageState recipe, including a project per role, is on Authentication & Isolation.

Annotations and tags

Annotations describe tests to the runner and the reports. Four are built in:

Annotation Meaning

test.skip()

The test is not applicable here; it is not run. Can be conditional, test.skip(browserName === 'firefox', 'Still working on it').

test.fixme()

The test is known to be broken and is not run. Use it when running the test is slow or crashes.

test.fail()

The test is expected to fail. It is run and Playwright complains if it passes, so the annotation cannot be forgotten after the bug is fixed.

test.slow()

Triples the test timeout.

They can be applied to one test (a title-first form, test.skip('title', body), or a call inside the test body) or to a whole group with a callback condition, test.skip(({ browserName }) => browserName !== 'chromium', 'Chromium only!') inside test.describe(). In a beforeEach hook, put the annotation in the hook (test.fixme(isMobile, '...')) so the hook’s navigation is skipped too. test.only() focuses a test, and forbidOnly in the config fails CI if one is left in.

Tags are labels that start with @. Add them in the details object, { tag: '@smoke' } or an array of tags, on a test or on a test.describe() (inherited by every test inside), or write the tag in the title. Annotations with a type attach more than a label: { annotation: { type: 'issue', description: 'https://example.com/tickets/42' } }, one or an array, on a test or a group. The HTML report shows them all except those whose type starts with an underscore, and custom reporters receive them. A test can also add one at run time with test.info().annotations.push(...).

import { test, expect } from '../fixtures';

test('tagged with the details object', { tag: '@smoke' }, async ({ page }) => {
  await page.goto('/');
  await expect(page.getByTestId('brand')).toBeVisible();
});

test('tagged in the title @fast', async ({ page }) => {
  await page.goto('/books');
  await expect(page.getByTestId('book-card')).toHaveCount(6);
});

test.describe('catalogue', { tag: '@catalogue' }, () => {
  test('two tags and an annotation', {
    tag: ['@slow', '@visual'],
    annotation: { type: 'issue', description: 'https://example.com/tickets/42' },
  }, async ({ page }) => {
    await page.goto('/books?q=clouds');
    await expect(page.getByTestId('result-count')).toHaveText('1 book found');
  });
});

test('conditionally skipped on mobile', async ({ page, isMobile }) => {
  test.skip(isMobile, 'The desktop navigation is not rendered on a phone');
  await page.goto('/');
  await expect(page.getByTestId('main-nav')).toBeVisible();
});

test.fixme('known crash, do not run', async ({ page }) => {
  await page.goto('/crash');
});

test('expected to fail until the bug is fixed', async ({ page }) => {
  test.fail();
  await page.goto('/books/9999');
  await expect(page.getByTestId('book-title')).toBeVisible({ timeout: 1_000 });
});

test('runtime annotation', async ({ page, browser }) => {
  test.info().annotations.push({ type: 'browser version', description: browser.version() });
  await page.goto('/');
});

test.skip('unconditionally skipped', async ({ page }) => {
  await page.goto('/');
});

In the desktop project this produced two skipped tests (the fixme and the unconditional skip; on mobile the conditional one is skipped as well) and one test marked ✘, the test.fail() one, which counts as passed because it failed as expected:

$ BASE_URL=http://localhost:3914 npx playwright test -c ex-configuration/playwright.config.ts --workers 1 --project=desktop --no-deps annotations.spec
  ✓  1 [desktop] › tests/annotations.spec.ts:3:5 › tagged with the details object @smoke (72ms)
  ✓  2 [desktop] › tests/annotations.spec.ts:8:5 › tagged in the title @fast (63ms)
  ✓  3 [desktop] › tests/annotations.spec.ts:14:7 › catalogue › two tags and an annotation @catalogue @slow @visual (78ms)
  ✓  4 [desktop] › tests/annotations.spec.ts:23:5 › conditionally skipped on mobile (73ms)
  -  5 [desktop] › tests/annotations.spec.ts:29:6 › known crash, do not run
  ✘  6 [desktop] › tests/annotations.spec.ts:33:5 › expected to fail until the bug is fixed (1.0s)
  ✓  7 [desktop] › tests/annotations.spec.ts:39:5 › runtime annotation (59ms)
  -  8 [desktop] › tests/annotations.spec.ts:44:6 › unconditionally skipped

  2 skipped
  6 passed (1.7s)

The reporter prints each tag after the title, whether it was written in the title or in the tag option.

Selecting tests by tag

--grep takes a regular expression matched against the full title, including file name, describe titles and tags; --grep-invert excludes. The same options exist as grep and grepInvert in the config and per project. These are real runs of --list, which prints what would run:

$ npx playwright test -c ex-configuration/playwright.config.ts --project=desktop --no-deps --list --grep "@smoke|@catalogue"
  [desktop] › annotations.spec.ts:3:5 › tagged with the details object
  [desktop] › annotations.spec.ts:14:7 › catalogue › two tags and an annotation
Total: 2 tests in 1 file

$ npx playwright test -c ex-configuration/playwright.config.ts --project=desktop --no-deps --list --grep "(?=.*@slow)(?=.*@visual)"
  [desktop] › annotations.spec.ts:14:7 › catalogue › two tags and an annotation
Total: 1 test in 1 file

Use --grep @fast for one tag, --grep-invert @fast to skip it, --grep "@fast|@slow" for either and the lookahead form above for both. On PowerShell, the | and ^ characters need quoting differently (--grep "@fast^|@slow"). The tag @catalogue was set on the describe group and is inherited by the test inside; the --list output shows the match even though the test’s own title has no tag.

Test locks

Parallel tests are fast until two of them touch the same thing that cannot be shared: an external service, a global account setting, a single shared record. A lock is a named mutex declared on tests. Tests that hold the same lock name never run at the same time, across files, workers and projects, while every other test keeps running in parallel. Locks arrived in 1.63; in 1.64 test.describe.configure({ lock }) adds locks to a whole file or group.

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

// Each of these tests replaces the shared Bookshelf data, so they must not run at the same time,
// even though fullyParallel is on and they could be scheduled in different workers.
// The file runs in its own project, after the others, with the plain test object.
test('seed one book', { lock: 'bookshelf-data' }, async ({ request, page }) => {
  await request.post('/api/test/seed', { data: { books: [{ title: 'Only Book', author: 'Test Author' }], reviews: [] } });
  await page.goto('/books');
  await expect(page.getByTestId('book-card')).toHaveCount(1);
});

test('seed two books', { lock: ['bookshelf-data'] }, async ({ request, page }) => {
  await request.post('/api/test/seed', {
    data: { books: [{ title: 'First', author: 'A' }, { title: 'Second', author: 'B' }], reviews: [] },
  });
  await page.goto('/books');
  await expect(page.getByTestId('book-card')).toHaveCount(2);
});

test.describe('whole group under a lock', () => {
  test.describe.configure({ lock: 'bookshelf-data' });

  test('empty catalogue', async ({ request, page }) => {
    await request.post('/api/test/seed', { data: { books: [], reviews: [] } });
    await page.goto('/books');
    await expect(page.getByTestId('empty-state')).toBeVisible();
  });
});

A test can hold several locks ({ lock: ['database', 'external-api'] }) and only starts when it can acquire all of them. Playwright acquires the locks before the test starts and releases them when it ends. Repeated calls to describe.configure({ lock }) add to, rather than replace, the locks already declared. In the default and serial modes, all tests in a file run together in order, so a lock declared on any one of them is held for the whole file.

Locks serialize; they do not isolate. They do not stop an unrelated test from reading the data that a locked test is changing. That is why this project runs locks.spec.ts in its own project (shared-data), which depends on the browser projects and therefore starts after them: Bookshelf’s /api/test/reset and /api/test/seed endpoints replace the shared data and, in the case of reset, clear every session, and an unrelated test in another worker could otherwise lose its login in the middle of a run. The first version of this project reset the data in an automatic fixture before every test and failed intermittently for exactly that reason, with six workers running. Per-test isolation is better solved by giving every test its own data (see Authentication & Isolation) and keeping locks for the few resources that truly are singletons. The lock tests were run with 4 workers and --repeat-each 5 (15 executions of the three tests):

$ npx playwright test -c ex-configuration/playwright.config.ts --workers 4 --repeat-each 5 --project=shared-data --no-deps
  ...
  15 passed (2.6s)

Parameterized tests

Playwright runs in Node.js, so parameterizing is plain JavaScript. There are three levels.

Test level: generate tests from data with a loop. Each title must be unique. Put hooks outside the loop to run them once, or inside a test.describe() in the loop to run them for every row.

import { test, expect } from '../fixtures';

// Test-level parameterization: one test per row.
const searches = [
  { query: 'quiet', title: 'The Quiet Compiler' },
  { query: 'clouds', title: 'A Field Guide to Clouds' },
  { query: 'static', title: 'Rivers of Static' },
];

for (const { query, title } of searches) {
  test(`search for "${query}" finds ${title}`, async ({ page }) => {
    await page.goto(`/books?q=${query}`);
    await expect(page.getByRole('heading', { name: title })).toBeVisible();
  });
}

// Project-level parameterization: the option fixture searchTerm is set per project.
test('the shelf project searches its own term', async ({ page, request, searchTerm }) => {
  const expected = (await (await request.get(`/api/books?q=${searchTerm}`)).json()).total;
  await page.goto('/books');
  await page.getByTestId('search-input').fill(searchTerm);
  await page.getByTestId('search-input').press('Enter');
  await expect(page.getByTestId('result-count')).toHaveText(`${expected} books found`);
});

// Environment variables select the account: the password never appears in the test.
test('sign in with credentials from the environment', async ({ page }) => {
  await page.goto('/signin');
  await page.getByLabel('Username').fill(process.env.BOOKSHELF_USER ?? 'reader');
  await page.getByLabel('Password').fill(process.env.BOOKSHELF_READER_PASSWORD ?? 'reader-local-only');
  await page.getByTestId('signin-submit').click();
  await expect(page.getByTestId('account-heading')).toContainText(process.env.BOOKSHELF_USER ?? 'reader');
});

Project level: the searchTerm option fixture is declared in fixtures.ts and set to Fiction in one project and Nature in another, so the same test runs once per project. Projects run in parallel and appear as separate rows in the report, [fiction-shelf] and [nature-shelf]. Option values are typed because defineConfig<BookshelfOptions>() knows them. The config above shows both projects; the output of the run is:

$ BASE_URL=http://localhost:3914 npx playwright test -c ex-configuration/playwright.config.ts --workers 1 --project=fiction-shelf --project=nature-shelf
  ✓   1 [setup] › tests/global.setup.ts:3:6 › Bookshelf is up and reset (22ms)
  ✓   2 [fiction-shelf] › tests/parameterized.spec.ts:11:7 › search for "quiet" finds The Quiet Compiler (86ms)
  ✓   3 [fiction-shelf] › tests/parameterized.spec.ts:11:7 › search for "clouds" finds A Field Guide to Clouds (60ms)
  ✓   4 [fiction-shelf] › tests/parameterized.spec.ts:11:7 › search for "static" finds Rivers of Static (73ms)
  ✓   5 [fiction-shelf] › tests/parameterized.spec.ts:18:5 › the shelf project searches its own term (108ms)
  ✓   6 [fiction-shelf] › tests/parameterized.spec.ts:27:5 › sign in with credentials from the environment (148ms)
  ✓   7 [nature-shelf] › tests/parameterized.spec.ts:11:7 › search for "quiet" finds The Quiet Compiler (83ms)
  ...
  ✓  10 [nature-shelf] › tests/parameterized.spec.ts:18:5 › the shelf project searches its own term (106ms)
  ✓  11 [nature-shelf] › tests/parameterized.spec.ts:27:5 › sign in with credentials from the environment (143ms)
  ✓  12 [cleanup] › tests/global.teardown.ts:3:9 › leave Bookshelf clean (22ms)

  12 passed (2.0s)

Environment level: environment variables select the target and carry secrets, which keeps them out of source control. The last test above reads BOOKSHELF_READER_PASSWORD; the config reads BASE_URL. Run BOOKSHELF_USER=admin BOOKSHELF_READER_PASSWORD=... npx playwright test to change them for one run. For many variables, put them in a .env file and load it at the top of the config with the dotenv package (dotenv.config({ path: path.resolve(__dirname, '.env') })), and keep the file out of version control. Data can also come from files: parse a CSV file with a library such as csv-parse at the top of the spec and create one test per record with the same loop shown above.

Two cautions. Loops that read the network or the application at collection time make the test list depend on the environment and can differ between workers; read static data only. And in 1.18 the behavior of parameterized projects changed; with option fixtures as shown here, nothing special is needed.

TypeScript

Playwright Test runs TypeScript without a build step: it transforms each file as it is loaded. It does not type-check, so a test with a type error still runs. Run the compiler next to it, in CI before the tests (npx tsc -p tsconfig.json --noEmit) and locally in watch mode (-w). The project of this page type-checks cleanly:

$ npx tsc -p ex-configuration/tsconfig.json
$ echo $?
0

Playwright reads the tsconfig.json closest to each file it loads (going up the directory tree, jsconfig.json too), so a tests/tsconfig.json can have test-specific settings. It honors only these compiler options: allowJs, baseUrl, paths, jsx, jsxFactory, jsxFragmentFactory, jsxImportSource, references and extends. Options such as target or strict only matter to your own tsc run. To use one file for everything, pass --tsconfig=tsconfig.test.json on the command line or set tsconfig in the config; the config option applies to test files and reporters but not to the config file or the files it imports.

Path mapping works in the specs. This project maps @helpers/* to helpers/* in its tsconfig.json, and fixtures.ts imports @helpers/credentials; the 66 tests resolved the import at run time:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "preserve",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"],
    "paths": {
      "@helpers/*": ["./helpers/*"]
    }
  },
  "include": ["**/*.ts"]
}

TypeScript 7, the version installed in the test project, removed baseUrl. Mappings then need an explicit ./ prefix ("@helpers/*": ["./helpers/*"]) and no baseUrl, and the old node (node10) moduleResolution is gone as well, so use bundler, node16 or nodenext. Playwright still lists baseUrl as a supported option, which matters for projects that stay on TypeScript 5 or 6 where it exists. Playwright compiles .tsx and .jsx files according to the jsx family of options and, since 1.64, uses the automatic runtime from react/jsx-runtime when jsx is unspecified, preserve or react-native.

If you use a very recent language feature that Playwright’s transformer does not handle, compile the tests yourself: a tests/tsconfig.json with an outDir such as ../tests-out, a pretest script of tsc --incremental -p tests/tsconfig.json, and playwright test -c tests-out as the test script.

What changed recently

Version Change

1.64

default: false on a project keeps it configured but out of a plain run. --shuffle randomizes test order (the seed can be passed back). test.describe.configure({ lock }) adds locks. fullConfig.filteredProjects. video accepts fps and a CSS style for action markers and videos use VP9. Device descriptors now forward screen, so window.screen and media queries see the emulated screen; set screen: undefined to opt out. JSX in test files follows tsconfig.json.

1.63

Named test locks ({ lock: 'name' }) on tests and on test.describe(). The snapshots setting of trace can select DOM, aria and screen snapshots separately. test.step() accepts subtitle and params.

1.62

retryStrategy: 'isolated' runs all retries at the end in one worker.

1.61

fullConfig.argv and fullConfig.failOnFlakyTests are available to reporters.

1.60

test.abort() aborts a running test from a fixture, hook or route handler. The runner now errors when a config tries to override a non-option fixture and rejects workers: 0 or negative values.

1.42

The tag and annotation details object for tests and groups (the API reference marks details as added in 1.42).

1.31

Project dependencies and teardown, now the recommended way to do global setup.