close

Playwright

@rstest/playwright provides Playwright fixtures and Playwright-style assertions for Rstest tests that run in Node.js workers. Use it for E2E tests against a complete page or app, such as a local dev server, a preview server, or a deployed URL. The test runs in Node.js and uses Playwright to drive the page.

Rstest browser mode vs playwright

The main difference is the testing scenario: Rstest browser mode prepares a browser runtime for your test modules, while @rstest/playwright controls a page that is already prepared by your app or server.

ScenarioRecommended
Test a component with Rstest's web bundling and browser runtimeRstest browser mode 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 utilitiesRstest browser mode

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 with RSTEST_PLAYWRIGHT_DEBUG=true.

Rstest playwright vs native playwright

@rstest/playwright and native Playwright use different runners and configuration files:

Item@rstest/playwrightNative Playwright
RunnerRstest runnerPlaywright Test runner
Configurationrstest.config.ts and playwright fixture overridesplaywright.config.ts
Test APIImport test and expect from @rstest/playwrightImport 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.

Install

Install both packages:

npm
yarn
pnpm
bun
deno
npm add @rstest/playwright playwright -D

Basic usage

Import test and expect from @rstest/playwright instead of @rstest/core:

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:

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');
  });
});

Fixtures

@rstest/playwright provides these fixtures:

FixtureDescription
browserA Chromium Browser shared by tests in the current worker.
contextA new BrowserContext for each test that uses it. It is closed after the test.
pageA new Page for each test that uses it. It is closed after the test.
requestA new APIRequestContext for each test that uses it. It is disposed after the test.
serveStarts a static server from inside the test and cleans it up automatically.

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

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);
});

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 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 is 5000 milliseconds.

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

.not and expect.soft are also supported:

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

Configuration

Global playwright configuration is not supported yet. Override the playwright fixture when a test file needs custom Playwright options:

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

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

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

The playwright fixture supports these options:

OptionDescription
browserNameBrowser engine to launch. Currently only chromium.
launchOptionsOptions passed to browserType.launch().
contextOptionsOptions passed to browser.newContext().
requestOptionsOptions passed to request.newContext().
debugConvenience options for local headed debugging.

For Playwright E2E projects, set isolate: false in rstest.config.ts to reuse the worker module cache across test files and avoid repeated Playwright startup cost:

rstest.config.ts
import { defineConfig } from '@rstest/core';

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

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.

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 RSTEST_PLAYWRIGHT_DEBUG=true is enabled, serve keeps the server alive by default so the opened page remains available for inspection. Set keepAliveOnDebug: false if you want the server to close even in debug mode.

Headed debugging

Set RSTEST_PLAYWRIGHT_DEBUG=true to launch Chromium in headed mode while debugging locally:

RSTEST_PLAYWRIGHT_DEBUG=true 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:

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:

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

    await page.goto(url);
    await page.pause();
  },
  { timeout: 0 },
);

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:

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 examples/playwright for a complete Rsbuild + Playwright example.