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,globalSetupand so on. They describe how the runner schedules and reports tests. -
Browser and context options live in the
usesection:baseURL,locale,trace,viewportand 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,
},
],
});
Source: Playwright: Test configuration
Test runner options
| Option | Effect |
|---|---|
|
Directory searched for test files. Relative to the config file. |
|
Glob patterns or regular expressions that select or exclude test files. The default |
|
Where traces, screenshots, videos and attachments of a run are written. Defaults to |
|
Per-test timeout and the |
|
Maximum time for the whole run. When it is reached Playwright stops, and the reporters still write their reports. |
|
Run every test of every file in parallel. Without it, tests in one file run in order in one worker. See Parallelism, Sharding & Retries. |
|
Maximum number of worker processes, or a percentage of the logical CPU cores such as |
|
Number of retries for a failed test. Tests that pass on a retry are reported as flaky. |
|
When retries run: |
|
Fail the run if a |
|
One or more reporters, with options: |
|
Run only tests whose title (including tags) matches or does not match a regular expression. The same options exist per project. |
|
Files that run once before and after the whole run. See Global setup and teardown. |
|
The list of projects. See Projects. |
|
Start one or more servers before the tests. See The webServer option. |
|
A single |
Source: Playwright: Test configuration
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 |
|---|---|---|
|
none |
Prefix for relative navigation. |
|
|
The attribute |
|
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. |
|
|
Size of the page. Use |
|
browser default |
Emulated |
|
none |
Fixed position ( |
|
browser default |
Emulate the matching |
|
browser default |
Device emulation. Usually set together by spreading a device descriptor, |
|
|
Turn page scripts off, or bypass the page’s Content-Security-Policy (useful when the policy names the production origin). |
|
none |
Headers (string values only) added to every request the page and the |
|
none, none, |
Basic or NTLM authentication, a proxy server with a bypass list, and an offline switch for the whole context. |
|
|
Do not fail on invalid certificates, for example a local development server with a self-signed certificate. |
|
|
Whether downloads are accepted automatically. |
|
|
Timeout of each action such as |
|
|
Timeout of |
|
|
|
|
|
|
|
bundled build |
Use a branded browser instead of the bundled one: |
|
empty |
Anything accepted by |
|
|
Evidence capture. See the next table and Screenshots, Videos & Traces. |
Source: Playwright: Test use options
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 |
|---|---|---|
|
never |
never |
|
every attempt |
always |
|
every attempt |
that attempt failed |
|
first run only |
the first run failed |
|
every attempt |
that attempt failed, or it is a retry |
|
first retry only |
always |
|
every retry |
always |
Source: Playwright: Test use options
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();
});
Source: Playwright: Test use options
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)
Source: Playwright: Test use options
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 |
|
|
Expect timeout |
5 000 ms |
|
|
Action timeout |
none |
|
|
Navigation timeout |
none |
|
|
Global timeout |
none |
|
not applicable |
|
30 000 ms, each |
follows the test timeout |
|
Fixture timeout |
shares the test’s (a worker fixture has its own) |
not applicable |
|
|
60 000 ms |
|
not applicable |
Source: Playwright: Timeouts
The rules that matter most:
-
The test timeout covers the test function, the setup of the fixtures it uses and the
beforeEachhooks. A separate timeout of the same value is shared by fixture teardown andafterEachhooks after the test ends.beforeAllandafterAllhooks 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
actionTimeoutto 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
timeoutso 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/);
});
Source: Playwright: Timeouts
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' })
Source: Playwright: Timeouts
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 |
|---|---|
|
Shell command that starts the server, for example |
|
Working directory of the process. Defaults to the directory of the config file. |
|
Environment variables for the command. Defaults to |
|
URL that must answer with a 2xx, 3xx, 400, 401, 402 or 403 status before tests start; redirects are followed. Use either
|
|
Deprecated in favor of |
|
Regular expressions to look for in |
|
How long to wait for start-up, in milliseconds. Default 60 000. |
|
|
|
|
|
Prefix of the server’s log lines. Defaults to |
|
By default the process group is killed with SIGKILL. |
|
Ignore certificate errors when polling the |
Source: Playwright: Web server
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 },
},
});
Source: Playwright: Web server
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');
});
Source: Playwright: Web server
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)
Source: Playwright: Web server
Practical points:
-
DEBUG=pw:webserver npx playwright testprints every readiness poll and the server’s stdout, which is the quickest way to see why a server "never starts". A common cause is aurlthat 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.baseURLis set. WhenwebServeris an array, Playwright cannot guess which entry is the base, so setbaseURLexplicitly. -
The
waitcapture 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 asbaseUrl; the actual option name isbaseURL. -
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: trueis 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
webServerat all and readsBASE_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 |
|---|---|---|
|
|
An isolated page for this test. It belongs to the |
|
|
An isolated context for this test, created from the |
|
|
The browser, shared by all tests of a worker to save resources. Use |
|
|
|
|
|
An isolated HTTP client for this test, with |
Source: Playwright: Fixtures
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 };
Source: Playwright: Fixtures
// 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;
Source: Playwright: TypeScript
-
Test-scoped fixtures (the default) are created for each test and torn down after it.
appPagesigns in through the API withpage.request, which shares cookies with the page, so a test starts already signed in without clicking through the form. It depends on theroleoption, 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 (useworkerInfo.workerIndexto make names unique), a compiled asset. A worker fixture cannot depend on test-scoped fixtures such aspageorrequest, which is whycataloguecreates its own API client withplaywright.request.newContext()and readsBASE_URLfrom 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.pageErrorsrecords uncaught page exceptions and fails the test during tear-down, andserverErrorscounts 5xx responses. Automatic fixtures are also the way to write abeforeEach/afterEachorbeforeAll/afterAllthat applies to every file: declare it withauto: true(andscope: 'worker'for thebeforeAllequivalent) in the sharedtestobject, 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 withtest.use(), which makes configuration declarative and type-safe. Pass the option types todefineConfig<Options>()so the config checks them, asplaywright.config.tsdoes. 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. OverridingstorageStatethis way supplies cookies computed at run time. Overriding changes the fixture for every test that uses thattestobject, so keep a differently named fixture (likeappPage) 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);
},
});
Source: Playwright: Fixtures
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);
});
Source: Playwright: Fixtures
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)
Source: Playwright: Fixtures
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:
-
When fixture A depends on fixture B, B is set up before A and torn down after A.
-
Non-automatic fixtures are lazy: they are created only when a test or hook needs them, so an unused fixture is never set up.
-
Test-scoped fixtures are torn down after each test; worker-scoped ones when the worker process shuts down.
-
Automatic worker fixtures are set up before anything else in the worker, automatic test fixtures before
beforeEachhooks 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' }],
});
Source: Playwright: Fixtures
{ 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' } },
],
});
Source: Playwright: Projects
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)
Source: Playwright: Projects
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)
Source: Playwright: Projects
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 |
manual, with |
Retries and parallelism |
standard configuration |
not applicable |
|
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 |
|---|---|
|
The test is not applicable here; it is not run. Can be conditional, |
|
The test is known to be broken and is not run. Use it when running the test is slow or crashes. |
|
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. |
|
Triples the test timeout. |
Source: Playwright: Annotations
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('/');
});
Source: Playwright: Annotations
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)
Source: Playwright: Annotations
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
Source: Playwright: Annotations
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();
});
});
Source: Playwright: Parallelism
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)
Source: Playwright: Parallelism
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');
});
Source: Playwright: Parameterize tests
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)
Source: Playwright: Parameterize tests
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
Source: Playwright: TypeScript
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"]
}
Source: Playwright: TypeScript
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 |
|
1.63 |
Named test locks ( |
1.62 |
|
1.61 |
|
1.60 |
|
1.42 |
The |
1.31 |
Project dependencies and teardown, now the recommended way to do global setup. |
Source: Playwright: Release notes