> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# E2E testing


[Added in v0.11.1](https://github.com/web-infra-dev/rstest/releases/tag/v0.11.1)

End-to-end (E2E) tests verify a complete page or app the way a user would: open the page, click and type, then check what is actually rendered. Rstest integrates [Playwright](https://playwright.dev/) fixtures and Playwright-style assertions through [@rstest/playwright](https://github.com/web-infra-dev/rstest/tree/main/packages/playwright).

The test code runs in Node.js workers and uses Playwright to drive a real browser against a local dev server, a preview server, or a deployed URL, sharing the same runner, configuration, and reporting as the rest of your Rstest tests. To test a single component instead of a whole app, see [browser mode](/guide/browser-testing.md) instead.

## Install

Install both packages:

- [@rstest/playwright](https://www.npmjs.com/package/@rstest/playwright) adds the Rstest fixtures and assertions.
- [playwright](https://www.npmjs.com/package/playwright) provides the browser automation runtime.


```sh [npm]
npm add @rstest/playwright playwright -D
```

```sh [yarn]
yarn add @rstest/playwright playwright -D
```

```sh [pnpm]
pnpm add @rstest/playwright playwright -D
```

```sh [bun]
bun add @rstest/playwright playwright -D
```

```sh [deno]
deno add npm:@rstest/playwright npm:playwright -D
```

Install the Chromium browser binary used by Playwright:

```bash
pnpm exec playwright install chromium
```

## Basic usage

Import `test` and `expect` from `@rstest/playwright` instead of `@rstest/core` to enable Playwright-specific assertions such as `toHaveTitle` and `toHaveText`:

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

test('page title', async ({ page }) => {
  await page.goto('https://example.com');

  await expect(page).toHaveTitle(/Example/);
  await expect(page.locator('h1')).toHaveText('Example Domain');
});
```

Regular lifecycle helpers are also available from `@rstest/playwright`:

```ts
import { beforeEach, describe, test } from '@rstest/playwright';

beforeEach(() => {
  // Prepare per-test state.
});

describe('checkout', () => {
  test('opens the checkout page', async ({ page }) => {
    await page.goto('http://localhost:3000/checkout');
  });
});
```

If your test modules do not rely on side effects that need isolation, set `isolate: false` in `rstest.config.ts` to reuse the worker module cache across test files and avoid repeated Playwright startup cost:

```ts title="rstest.config.ts"
import { defineConfig } from '@rstest/core';

export default defineConfig({
  isolate: false,
  testEnvironment: 'node',
});
```

## Configure playwright options


[Added in v0.12.0](https://github.com/web-infra-dev/rstest/releases/tag/v0.12.0)

Set Playwright defaults in `rstest.config.ts` with `definePlaywrightConfig`:

```ts title="rstest.config.ts"
import { defineConfig } from '@rstest/core';
import { definePlaywrightConfig } from '@rstest/playwright/config';

export default defineConfig({
  extends: definePlaywrightConfig({
    contextOptions: {
      viewport: { width: 1440, height: 900 },
    },
  }),
});
```

`definePlaywrightConfig` configures the default `playwright` fixture for the current project. In a multi-project config, add it to each Node.js project's `extends` that uses `@rstest/playwright`. Values must be JSON-serializable. Functions such as `launchOptions.logger`, class instances, direct `Buffer` values in client certificates, and values that depend on the current test or retry context are not supported. Use `certPath`/`keyPath`/`pfxPath` or `test.extend` instead. If multiple test files need a different shared set of options, define a shared module that overrides the fixture with `test.extend`, then import `test` and `expect` from that module in every test file:

```ts title="tests/e2e.ts"
import { expect, test as base } from '@rstest/playwright';
import type { PlaywrightOptions } from '@rstest/playwright';

export { expect };
export const test = base.extend({
  playwright: {
    contextOptions: {
      viewport: { width: 390, height: 844 },
    },
  } satisfies PlaywrightOptions,
});
```

The `export { expect }` line only re-exports the Playwright-aware `expect` from the shared module, so test files can import it alongside the shared `test`. `test.extend` does not require re-exporting `expect`.

```ts title="tests/home.test.ts"
import { expect, test } from './e2e';

test('mobile page', async ({ page }) => {
  await page.goto('http://localhost:3000');
  await expect(page.locator('main')).toBeAttached();
});
```

If only a few tests need different options, call `test.extend` again from those test files using the shared `test` as the base. Overriding the `playwright` fixture replaces its value instead of merging it, so the new value must include every shared option that the test should preserve.

The `playwright` fixture supports these options:

| Option           | Description                                          |
| ---------------- | ---------------------------------------------------- |
| `browserName`    | Browser engine to launch. Currently only `chromium`. |
| `launchOptions`  | Options passed to `browserType.launch()`.            |
| `contextOptions` | Options passed to `browser.newContext()`.            |
| `requestOptions` | Options passed to `request.newContext()`.            |
| `debug`          | Convenience options for local headed debugging.      |
| `trace`          | Capture Playwright trace artifacts for debugging.    |

### E2E defaults


[Added in v0.12.0](https://github.com/web-infra-dev/rstest/releases/tag/v0.12.0)

Using `extends: definePlaywrightConfig({})` also provides these defaults for the current project:

| Setting                                   | Default                                           |
| ----------------------------------------- | ------------------------------------------------- |
| `testTimeout`                             | `30_000ms`                                        |
| `hookTimeout`                             | `30_000ms`                                        |
| Playwright locator/page assertion timeout | `expect.poll.timeout` (`5000ms` with this helper) |
| Rstest `expect.poll.timeout`              | `5000ms`                                          |

Explicit Rstest configuration overrides the inherited defaults. For example:

```ts title="rstest.config.ts"
import { defineConfig } from '@rstest/core';
import { definePlaywrightConfig } from '@rstest/playwright/config';

export default defineConfig({
  extends: definePlaywrightConfig({}),
  testTimeout: 60_000,
  hookTimeout: 45_000,
  expect: {
    poll: { timeout: 2000 },
  },
});
```

Here, Playwright locator/page assertions and `expect.poll()` both use `2000ms`. Per-call timeout options still take precedence for both APIs.

These defaults apply only when using `definePlaywrightConfig`; importing `test` or `expect` from `@rstest/playwright` alone does not change Rstest's runner defaults. In a mixed unit/E2E workspace, apply the helper only to the E2E project.

Matching the timeout values does not change Rstest's hook/fixture timeout accounting to Playwright Test's shared test budget. Polling still uses Rstest's fixed interval (`50ms` by default), rather than Playwright Test's backoff intervals. Worker count and `isolate` also retain Rstest's defaults; configure them explicitly at the root when needed.

## Fixtures

`@rstest/playwright` provides these fixtures:

| Fixture   | Description                                                                          |
| --------- | ------------------------------------------------------------------------------------ |
| `browser` | A Chromium `Browser` shared by tests in the current worker.                          |
| `context` | A new `BrowserContext` for each test that uses it. It is closed after the test.      |
| `page`    | A new `Page` for each test that uses it. It is closed after the test.                |
| `request` | A new `APIRequestContext` for each test that uses it. It is disposed after the test. |
| `serve`   | Starts a static server from inside the test and cleans it up automatically.          |

The sections below show how each fixture is commonly used. `page` and `serve` link to the existing examples to avoid repeating the same code.

### Using fixtures in hooks


[Added in v0.11.4](https://github.com/web-infra-dev/rstest/releases/tag/v0.11.4)

Suite-level hooks can request fixtures provided by the tests in that suite. Specify the hook's fixture context type explicitly; a fixture used only by a hook does not need `auto: true`:

```ts
import {
  beforeEach,
  describe,
  expect,
  test,
  type PlaywrightFixture,
} from '@rstest/playwright';

type DashboardFixtures = PlaywrightFixture & {
  route: string;
};

const dashboardTest = test.extend<{ route: string }>({
  route: '/dashboard',
});

describe('dashboard', () => {
  beforeEach<DashboardFixtures>(async ({ page, route }) => {
    await page.goto(`http://localhost:3000${route}`);
  });

  dashboardTest('shows the dashboard', async ({ page }) => {
    await expect(page.locator('h1')).toHaveText('Dashboard');
  });
});
```

Hooks are scoped to their `describe` block, not to an extended test object. Every test in the block must provide the fixtures requested by the hook; otherwise, Rstest fails that test before invoking the hook and reports the missing fixture. The same behavior applies to `afterEach` and to cleanup functions returned by `beforeEach`. Fixture instances are shared across the hooks and test body for one test attempt, then torn down in reverse setup order.

Declare fixture dependencies through direct object destructuring in the hook parameter. Destructuring a named context inside the hook body does not request fixtures. Rest properties and default values are not supported in fixture-aware callbacks.

### Using fixtures with `test.for`

Destructure fixtures directly from the second callback parameter when using `test.for`:

```ts
test.for([{ path: '/dashboard' }])(
  'opens $path',
  async ({ path }, { page }) => {
    await page.goto(`http://localhost:3000${path}`);
  },
);
```

A named second parameter can still access built-in `TestContext` APIs such as `task` and `expect`, but property access or destructuring through that name does not request fixtures.

### `browser`

Use `browser` when you need to create a custom browser context yourself:

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

test('custom browser context', async ({ browser }) => {
  const context = await browser.newContext({ locale: 'en-US' });
  const page = await context.newPage();

  await page.goto('https://example.com');
  await expect(page).toHaveTitle(/Example/);

  await context.close();
});
```

### `context`

Use `context` when one test needs multiple pages that share the same browser context:

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

test('multiple pages', async ({ context }) => {
  const page = await context.newPage();
  const popup = await context.newPage();

  await page.goto('https://example.com');
  await popup.goto('https://example.com');

  await expect(page).toHaveTitle(/Example/);
  await expect(popup).toHaveTitle(/Example/);
});
```

### `page`

See [Basic usage](#basic-usage) for the common E2E flow of opening and asserting a page.

### `request`

Use `request` when you only need Playwright's API client and do not need to launch a browser:

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

test('health check', async ({ request }) => {
  const response = await request.get('http://localhost:3000/health');

  expect(response.ok()).toBe(true);
});
```

### `serve`

See [Local app server](#local-app-server) for serving a built app from local files.

## Assertions

`expect` keeps normal Rstest assertions. When the actual value is a Playwright `Locator` or `Page`, it also provides retrying Playwright-style async assertions.

Locator assertions target Playwright [`Locator`](https://playwright.dev/docs/api/class-locator) values and follow the naming of [Playwright Locator assertions](https://playwright.dev/docs/test-assertions#locator-assertions) where possible. They are aligned with the element assertions already supported by `@rstest/browser` where possible:

- `toBeVisible(options?)`
- `toBeHidden(options?)`
- `toBeEnabled(options?)`
- `toBeDisabled(options?)`
- `toBeChecked(options?)`
- `toBeUnchecked(options?)`
- `toBeAttached(options?)`
- `toBeDetached(options?)`
- `toBeEditable(options?)`
- `toBeFocused(options?)`
- `toBeEmpty(options?)`
- `toBeInViewport(options?)`
- `toContainText(expected, options?)`
- `toHaveAttribute(name, expected?, options?)`
- `toHaveClass(expected, options?)`
- `toHaveCSS(propertyName, expected, options?)`
- `toHaveCount(expected, options?)`
- `toHaveId(expected, options?)`
- `toHaveJSProperty(name, expected, options?)`
- `toHaveText(expected, options?)`
- `toHaveValue(expected, options?)`

Page assertions:

- `toHaveTitle(expected, options?)`
- `toHaveURL(expected, options?)`

String text assertions normalize whitespace. Playwright assertions retry until they pass or the `timeout` option is reached. The default timeout comes from `expect.poll.timeout`.

```ts
await expect(page.locator('.message')).toContainText('Saved', {
  timeout: 10_000,
});
```

`.not` and `expect.soft` are also supported:

```ts
await expect(page.locator('.error')).not.toBeAttached();
await expect.soft(page).toHaveTitle(/Dashboard/);
```

### Assertion timeout


[Added in v0.12.0](https://github.com/web-infra-dev/rstest/releases/tag/v0.12.0)

Playwright locator and page assertions read Rstest's [`expect.poll.timeout`](/config/test/expect.md#expectpolltimeout). Configure it once to control both these assertions and `expect.poll()`:

```ts title="rstest.config.ts"
import { defineConfig } from '@rstest/core';
import { definePlaywrightConfig } from '@rstest/playwright/config';

export default defineConfig({
  extends: definePlaywrightConfig({}),
  expect: {
    poll: { timeout: 10_000 },
  },
});
```

The matcher’s `{ timeout }` takes precedence over `expect.poll.timeout`, including for `.not` and `expect.soft`. The helper defaults `expect.poll.timeout` to `5000ms`; without the helper or an explicit configuration, Node Mode uses `1000ms`. Explicit configuration works even without the helper, and multiple helper entries do not introduce a separate assertion timeout to merge.

The outer test or hook timeout can still end the assertion sooner. Playwright assertions keep their fixed `50ms` retry interval; `expect.poll.interval` only controls `expect.poll()`. `page.setDefaultTimeout()` controls Playwright operations, not these assertion timeouts. Rstest does not read `playwright.config.ts`; migrate Playwright Test's `expect.timeout` to Rstest's `expect.poll.timeout`.

## Trace debugging


[Added in v0.11.2](https://github.com/web-infra-dev/rstest/releases/tag/v0.11.2)

Set `playwright.trace` or `RSTEST_PLAYWRIGHT_TRACE` to capture Playwright's official `trace.zip` artifact from the `context` fixture. The trace covers the default `page` fixture and pages created with `context.newPage()`. Fixture configuration takes priority over the environment variable, then falls back to `off`.

```ts
import { expect, test } from '@rstest/playwright';
import type { PlaywrightOptions } from '@rstest/playwright';

const e2e = test.extend({
  playwright: {
    trace: process.env.CI ? 'on-first-retry' : 'off',
  } satisfies PlaywrightOptions,
});

e2e('checkout', async ({ page }) => {
  await page.goto('http://localhost:3000/checkout');
  await expect(page.locator('main')).toBeAttached();
});
```

For temporary CLI-style debugging without changing test code, set `RSTEST_PLAYWRIGHT_TRACE`:

```bash
RSTEST_PLAYWRIGHT_TRACE=retain-on-failure rstest
```

Use `RSTEST_PLAYWRIGHT_TRACE_OUTPUT_DIR` to override the default output directory when trace is enabled by the environment variable:

```bash
RSTEST_PLAYWRIGHT_TRACE=on RSTEST_PLAYWRIGHT_TRACE_OUTPUT_DIR=.rstest/playwright-traces rstest
```

`trace` accepts `'off'`, `'on'`, `'retain-on-failure'`, `'on-first-retry'`, `'on-all-retries'`, or an options object:


[Added in v0.11.6](https://github.com/web-infra-dev/rstest/releases/tag/v0.11.6)

`on-first-retry` records and keeps a trace only for the first retry. `on-all-retries` records and keeps a trace for every retry. Neither mode starts tracing during the initial attempt, so passing tests avoid trace startup and temporary artifact work.

```ts
const e2e = test.extend({
  playwright: {
    trace: {
      mode: 'retain-on-failure',
      outputDir: '.rstest/playwright-traces',
      screenshots: true,
      snapshots: true,
      sources: true,
    },
  } satisfies PlaywrightOptions,
});
```

By default, traces are written to `.rstest/playwright-traces/<test-name>-<hash>/`. If the same test saves multiple traces, for example across retries, later attempts use a numeric suffix to avoid overwriting earlier traces. Every saved trace contains:

- `trace.zip`: Playwright's official trace artifact. Open it with `npx playwright show-trace <path-to-trace.zip>`.

When `summary` is enabled (the default), the directory also contains:

- `trace-summary.json`: Rstest-aware test metadata, artifact paths, and error stacks for tools and AI assistants.
- `debug.md`: a human-readable debugging report.

`trace.zip` is not a generic Chrome/Perfetto trace. It is Playwright's trace format and is intended to be inspected with Playwright Trace Viewer.

## Local app server

Use the `serve` fixture when a test needs to serve a built app. It starts a static server for the entry file and automatically stops the server after the test.

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

test('home page', async ({ page, serve }) => {
  const { url } = await serve('./dist/index.html');

  await page.goto(url);
  await expect(page.locator('h1')).toHaveText('Home');
});
```

When `PWDEBUG=1` is enabled, `serve` keeps the server alive by default so the opened page remains available for inspection. In non-watch runs, this may keep the Rstest process open until you stop it manually. Set `keepAliveOnDebug: false` if you want the server to close even in debug mode.

## Headed debugging

Set `PWDEBUG=1` to launch Chromium in headed mode while debugging locally:

```bash
PWDEBUG=1 rstest watch
```

This environment variable keeps your tests unchanged and applies these defaults:

- `headless: false`
- `slowMo: 100`
- `devtools: true`

You can also override the debug defaults from the test:

```ts
import { test } from '@rstest/playwright';
import type { PlaywrightOptions } from '@rstest/playwright';

const e2e = test.extend({
  playwright: {
    debug: {
      enabled: true,
      slowMo: 100,
      devtools: false,
    },
  } satisfies PlaywrightOptions,
});

e2e('debug page', async ({ page }) => {
  await page.goto('http://localhost:3000');
});
```

To stop on a page while debugging, use Playwright's `page.pause()` with a zero test timeout:

```ts
test('debug page state', { timeout: 0 }, async ({ page, serve }) => {
  const { url } = await serve('./dist/index.html');

  await page.goto(url);
  await page.pause();
});
```

In debug mode, failed tests automatically call `page.pause()` before closing the page and context. Set `pauseOnFailure: false` in `debug` options, or `RSTEST_PLAYWRIGHT_PAUSE=false`, to disable this behavior.

For non-interactive debugging in CI or local runs, capture a screenshot when a test fails:

```ts
import { test } from '@rstest/playwright';

test('home page', async ({ onTestFailed, page, serve }) => {
  onTestFailed(async ({ task }) => {
    await page.screenshot({
      fullPage: true,
      path: `${task.id}-failed.png`,
    });
  });

  const { url } = await serve('./dist/index.html');

  await page.goto(url);
});
```

This keeps the test runner non-blocking while preserving the failed page state as an artifact. See the [Rstest + Playwright example](https://github.com/rstackjs/rstack-examples/tree/main/rstest/playwright) for a complete Rsbuild + React project tested with `@rstest/playwright`.

## Compared with other approaches

### Rstest browser mode

Rstest browser mode bundles test modules and runs them in a browser runtime, which suits component tests. `@rstest/playwright` controls a page that is already prepared by your app or server, which suits testing a complete app.

| Scenario                                                        | Recommended                                                             |
| --------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Test a component with Rstest's web bundling and browser runtime | [Rstest browser mode](/guide/browser-testing.md) with `@rstest/browser` |
| Test a complete app or page through `page.goto()`               | `@rstest/playwright`                                                    |
| Drive an existing dev server, preview server, or deployed URL   | `@rstest/playwright`                                                    |
| Need in-browser component test utilities                        | [Rstest browser mode](/guide/browser-testing.md)                        |

Because `@rstest/playwright` controls an external page instead of running the test in Rstest's browser runner, it does not use the Browser UI preview iframe. For visual debugging, use headed mode as described above.

### Native Playwright

`@rstest/playwright` and native Playwright differ mainly in the runner and configuration files:

| Item          | `@rstest/playwright`                                  | Native Playwright                                  |
| ------------- | ----------------------------------------------------- | -------------------------------------------------- |
| Runner        | Rstest runner                                         | Playwright Test runner                             |
| Configuration | `rstest.config.ts` and `playwright` fixture overrides | `playwright.config.ts`                             |
| Test API      | Import `test` and `expect` from `@rstest/playwright`  | Import `test` and `expect` from `@playwright/test` |

Use `@rstest/playwright` when you want Playwright-driven E2E tests to run in the same Rstest workflow as the rest of your tests. Use native Playwright when you want the full Playwright Test runner workflow and its configuration model.

## Migrate from Playwright

An existing Playwright Test project can be migrated by a Coding Agent that supports Skills, using the [migrate-to-rstest](https://github.com/rstackjs/agent-skills#migrate-to-rstest) skill. Agent Skills are domain knowledge packs installed into a Coding Agent so it gives more accurate suggestions or performs actions for a specific scenario; the [skills](https://www.npmjs.com/package/skills) package installs them. The `migrate-to-rstest` skill includes Playwright-specific migration guidance for configuration, fixtures, and behavior parity.

Install the skill first:


```sh [npx]
npx skills add rstackjs/agent-skills --skill migrate-to-rstest
```

```sh [yarn]
yarn dlx skills add rstackjs/agent-skills --skill migrate-to-rstest
```

```sh [pnpm]
pnpm dlx skills add rstackjs/agent-skills --skill migrate-to-rstest
```

```sh [bunx]
bunx skills add rstackjs/agent-skills --skill migrate-to-rstest
```

```sh [deno]
deno run -A npm:skills add rstackjs/agent-skills --skill migrate-to-rstest
```

Then copy the following prompt and send it to your Coding Agent:


For your Agent

Migrate Playwright to @rstest/playwright

Copy this prompt and send it to your Coding Agent.

Copy Prompt

Migrate this Playwright Test project to @rstest/playwright using the migrate-to-rstest skill. Follow its Playwright migration reference, preserve existing behavior and coverage, and report unsupported configuration or fixture features instead of silently dropping them.