Skip to content

Playwright Integration

Scenarist provides Playwright helpers that enable type-safe scenario switching with autocomplete, automatic test ID management, and clean test organization.

Terminal window
npm install --save-dev @scenarist/playwright-helpers
# or
pnpm add -D @scenarist/playwright-helpers

Create a tests/fixtures.ts file that exports a typed test object:

tests/fixtures.ts
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios'; // Your scenario definitions
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };

Import from fixtures:

tests/my-feature.spec.ts
import { test, expect } from './fixtures'; // Import from your fixtures file
test('premium users see advanced features', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // Type-safe! Autocomplete works
await page.goto('/dashboard');
await expect(page.getByText('Advanced Analytics')).toBeVisible();
});

Do not import directly from @playwright/test:

// DON'T DO THIS
import { test, expect } from '@playwright/test'; // No Scenarist fixtures!
test('my test', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // Error: switchScenario doesn't exist
});

The fixtures pattern provides several benefits:

TypeScript knows which scenario IDs exist and provides autocomplete:

await switchScenario(page, 'premiumUser'); // Autocomplete suggests: 'default', 'premiumUser', 'error', etc.
await switchScenario(page, 'typo'); // TypeScript error: 'typo' not in scenarios

Each test gets a guaranteed unique test ID to prevent state collisions during parallel execution. You don’t need to manage test IDs yourself - the fixture handles it automatically.

For more details on parallel test execution and test isolation, see Parallel Testing.

Configure the scenario endpoint once in your fixtures file or playwright.config.ts:

playwright.config.ts
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig<ScenaristOptions>({
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__', // Default, can be customized
},
});

All tests import from the same fixtures file, ensuring consistency:

tests/
fixtures.ts # Single source of truth
auth.spec.ts # import { test, expect } from './fixtures';
checkout.spec.ts # import { test, expect } from './fixtures';
dashboard.spec.ts # import { test, expect } from './fixtures';

1. Define Scenarios:

lib/scenarios.ts
import type { ScenaristScenarios } from '@scenarist/express-adapter';
export const scenarios = {
// Default scenario with complete happy path
default: {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed with valid responses',
mocks: [
// Stripe: Successful payment
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_123', status: 'succeeded', amount: 5000 },
},
},
// Auth0: Authenticated standard user
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_123', email: 'john@example.com', tier: 'standard' },
},
},
],
},
// Specialized scenario: Override Auth0 for premium user
premiumUser: {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user, everything else succeeds',
mocks: [
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_456', email: 'premium@example.com', tier: 'premium' },
},
},
],
},
// Specialized scenario: Stripe payment failure
paymentFails: {
id: 'paymentFails',
name: 'Payment Declined',
description: 'Stripe declines payment, everything else succeeds',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 402,
body: { error: { code: 'card_declined', message: 'Card was declined' } },
},
},
],
},
} as const satisfies ScenaristScenarios;

2. Create Fixtures:

tests/fixtures.ts
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
export const test = withScenarios(scenarios);
export { expect };

3. Write Tests:

tests/auth.spec.ts
import { test, expect } from './fixtures';
test('premium users access advanced features', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/dashboard');
await expect(page.getByText('Advanced Analytics')).toBeVisible();
});
test('standard users see upgrade prompt', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/dashboard');
await expect(page.getByText('Upgrade to Premium')).toBeVisible();
});

If you already have custom Playwright fixtures, extend the Scenarist test object:

tests/fixtures.ts
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
type CustomFixtures = {
authenticatedPage: Page;
apiToken: string;
};
export const test = withScenarios(scenarios).extend<CustomFixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await use(page);
},
apiToken: async ({}, use) => {
const token = await generateTestToken();
await use(token);
},
});
export { expect };

Then use custom fixtures in tests:

import { test, expect } from './fixtures';
test('authenticated user sees dashboard', async ({ authenticatedPage, switchScenario }) => {
await switchScenario(authenticatedPage, 'premiumUser');
await expect(authenticatedPage.getByText('Welcome Back')).toBeVisible();
});

Override endpoint or baseURL for specific tests:

import { test, expect } from './fixtures';
test('staging environment test', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser', {
baseURL: 'https://staging.example.com',
endpoint: '/api/custom-endpoint',
});
await page.goto('/dashboard');
// Test runs against staging with custom endpoint
});

When your API server runs on a different host or port than your frontend, use an absolute URL for scenaristEndpoint. This is common in architectures where:

  • Frontend and API are separate services on different ports
  • You’re testing against a staging or production API
  • Your test infrastructure uses a dedicated mock server
playwright.config.ts
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
// Frontend: http://localhost:3000
// API Server: http://localhost:9090
export default defineConfig<ScenaristOptions>({
use: {
baseURL: 'http://localhost:3000', // For Playwright navigation (page.goto)
scenaristEndpoint: 'http://localhost:9090/__scenario__', // Absolute URL to API
},
});
Endpoint TypeExampleBehavior
Relative path/api/__scenario__Prepended with baseURLhttp://localhost:3000/api/__scenario__
Absolute URLhttp://localhost:9090/__scenario__Used directly (ignores baseURL)

You can also override for specific tests:

import { test, expect } from './fixtures';
test('test against separate API server', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser', {
endpoint: 'http://api.staging.example.com/__scenario__', // Absolute URL
});
await page.goto('/dashboard');
});

When tests fail, you often need to inspect the current test state to understand what went wrong. Scenarist provides debug fixtures for this.

Fetch the current test state from the debug endpoint:

import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'checkout');
await page.goto('/cart');
// Add item to cart
await page.click('#add-item');
// Debug: Check what state was set
const state = await debugState(page);
console.log('After add item:', state);
// → { 'cart.items': 1, 'cart.total': 29.99 }
expect(state['cart.items']).toBe(1);
});

waitForDebugState(page, condition, options?)

Section titled “waitForDebugState(page, condition, options?)”

Wait for state to meet a condition (useful for async workflows):

import { test, expect } from './fixtures';
test('async approval flow', async ({ page, switchScenario, waitForDebugState }) => {
await switchScenario(page, 'approvalFlow');
await page.goto('/dashboard');
// Trigger async approval
await page.click('#submit-for-approval');
// Wait for backend state to indicate approval completed
const state = await waitForDebugState(
page,
(s) => s['approval.status'] === 'approved',
{ timeout: 10000, interval: 100 }
);
expect(state['approval.status']).toBe('approved');
});

Options:

  • timeout?: number - Maximum wait time in milliseconds (default: 5000)
  • interval?: number - Polling interval in milliseconds (default: 100)

Configure the state endpoint in playwright.config.ts:

export default defineConfig<ScenaristOptions>({
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__',
scenaristStateEndpoint: '/__scenarist__/state', // Default value
},
});

Creates a typed Playwright test object with Scenarist fixtures.

Parameters:

  • scenarios - Scenarios object (must satisfy ScenaristScenarios type)

Returns:

  • Extended Playwright test object with switchScenario fixture

Example:

const test = withScenarios(scenarios);

switchScenario(page, scenarioId, options?)

Section titled “switchScenario(page, scenarioId, options?)”

Switch to a scenario for the current test.

Parameters:

  • page: Page - Playwright Page object
  • scenarioId: string - ID of scenario to activate (type-safe based on your scenarios)
  • options?: { endpoint?: string; baseURL?: string } - Optional overrides

Returns:

  • Promise<string> - The test ID (for explicit page.request calls)

Example:

test('my test', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'premiumUser');
// testId can be used for explicit page.request calls
});

Re-exported from @playwright/test for convenience. Use the same expect you’re familiar with.

import { test, expect } from './fixtures';
test('my test', async ({ page }) => {
await expect(page.getByText('Hello')).toBeVisible();
});