@scenarist/playwright-helpers
v0.4.3
Published
Playwright test helpers for Scenarist scenario management
Downloads
2,863
Maintainers
Readme
@scenarist/playwright-helpers
Playwright test helpers for Scenarist scenario management with guaranteed test isolation.
📖 Documentation
→ Playwright Integration Guide — Fixtures, type-safe scenarios, test isolation
| Topic | Link | | ----------------------- | ------------------------------------------------------------------------------------------------ | | Why Scenarist? | scenarist.io/getting-started/why-scenarist | | Parallel Testing | scenarist.io/testing/parallel-testing | | Tool Comparison | scenarist.io/comparison | | Writing Scenarios | scenarist.io/scenarios/basic-structure | | Request Matching | scenarist.io/scenarios/request-matching | | Response Sequences | scenarist.io/scenarios/response-sequences | | State-Aware Mocking | scenarist.io/scenarios/state-aware-mocking | | Logging & Debugging | scenarist.io/reference/logging |
Installation
# npm
npm install -D @scenarist/playwright-helpers
# pnpm
pnpm add -D @scenarist/playwright-helpers
# yarn
yarn add -D @scenarist/playwright-helpersQuick Start (Recommended: Fixtures API)
The fixtures API is the recommended way to use Scenarist with Playwright. It provides:
- ✅ Guaranteed unique test IDs (no collisions, even with parallel execution)
- ✅ Configuration in one place (no repetition across tests)
- ✅ Clean composition with your existing fixtures
- ✅ Type-safe with full TypeScript support
1. Configure in playwright.config.ts
import { defineConfig } from "@playwright/test";
import type { ScenaristOptions } from "@scenarist/playwright-helpers";
export default defineConfig<ScenaristOptions>({
use: {
baseURL: "http://localhost:3000", // Standard Playwright config
scenaristEndpoint: "/api/__scenario__", // Scenarist-specific config
},
});Note: The <ScenaristOptions> type parameter enables TypeScript to recognize scenaristEndpoint as a valid configuration option.
2. Create a Fixtures File
Create tests/fixtures.ts with your scenarios:
// tests/fixtures.ts
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "../lib/scenarios"; // Your scenario definitions
export const test = withScenarios(scenarios);
export { expect };3. Use in Tests
// tests/my-test.spec.ts
import { test, expect } from "./fixtures"; // Import from YOUR fixtures file
test("premium user sees premium pricing", async ({ page, switchScenario }) => {
// Configuration read from playwright.config.ts - no repetition!
await switchScenario(page, "premiumUser"); // Type-safe! Autocomplete works
await page.goto("/");
await expect(page.getByRole("heading", { name: "Premium" })).toBeVisible();
});That's it! No manual test ID generation, no repeating baseURL/endpoint, guaranteed test isolation.
Composing with Your Existing Fixtures
If your team already has custom Playwright fixtures, you can easily compose them with Scenarist fixtures:
// tests/fixtures.ts
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "../lib/scenarios";
import type { Page } from "@playwright/test";
type MyFixtures = {
authenticatedPage: Page;
database: Database;
};
export const test = withScenarios(scenarios).extend<MyFixtures>({
authenticatedPage: async ({ page }, use) => {
// Your custom fixture logic
await page.goto("/login");
await page.fill("[name=email]", "[email protected]");
await page.fill("[name=password]", "password");
await page.click("button[type=submit]");
await use(page);
},
database: async ({}, use) => {
const db = await connectToTestDatabase();
await use(db);
await db.close();
},
});
export { expect };Now use your extended test object:
// tests/my-test.spec.ts
import { test, expect } from "./fixtures";
test("authenticated premium user flow", async ({
authenticatedPage,
switchScenario,
database,
}) => {
// All fixtures available: yours + Scenarist's
await switchScenario(authenticatedPage, "premiumUser");
await authenticatedPage.goto("/dashboard");
const user = await database.getUser("[email protected]");
expect(user.tier).toBe("premium");
});Type-Safe Scenario IDs (Automatic)
Type safety is automatic when you use withScenarios(scenarios). The function infers scenario IDs from your scenarios object.
1. Define Scenarios
// lib/scenarios.ts
import type { ScenaristScenarios } from '@scenarist/core';
export const scenarios = {
cartWithState: { id: 'cartWithState', name: 'Cart with State', mocks: [...] },
premiumUser: { id: 'premiumUser', name: 'Premium User', mocks: [...] },
standardUser: { id: 'standardUser', name: 'Standard User', mocks: [...] },
} as const satisfies ScenaristScenarios;2. Create Typed Test Object
// tests/fixtures.ts
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "../lib/scenarios";
// Type-safe test object - scenario IDs are inferred automatically!
export const test = withScenarios(scenarios);
export { expect };3. Use with Full Autocomplete
// tests/my-test.spec.ts
import { test, expect } from "./fixtures";
test("my test", async ({ page, switchScenario }) => {
await switchScenario(page, "cart"); // ❌ TypeScript error: not a valid scenario
await switchScenario(page, "cartWithState"); // ✅ Autocomplete works!
// ^
// Autocomplete shows all valid scenario IDs
});Benefits:
- ✅ Autocomplete shows all valid scenario names
- ✅ TypeScript errors for typos or invalid scenarios
- ✅ Type stays in sync with actual scenarios (single source of truth)
- ✅ Works seamlessly with fixture composition
Advanced: Per-Test Configuration Overrides
Most tests use the global config, but you can override for specific tests:
test("staging environment test", async ({ page, switchScenario }) => {
await switchScenario(page, "myScenario", {
baseURL: "https://staging.example.com", // Override for this test only
endpoint: "/api/custom-endpoint",
});
await page.goto("/");
// Test against staging environment
});Advanced: Standalone switchScenario Function
For cases where you need manual control over test IDs or can't use fixtures:
import { test, expect } from "@playwright/test";
import { switchScenario } from "@scenarist/playwright-helpers";
test("premium user scenario", async ({ page }) => {
// Switch to premium scenario (generates unique test ID, sets headers automatically)
await switchScenario(page, "premiumUser", {
baseURL: "http://localhost:3000",
endpoint: "/api/__scenario__",
});
// Navigate and test as premium user
await page.goto("/");
await expect(page.locator(".premium-badge")).toBeVisible();
});Note on testing: This package has comprehensive behavior-driven tests at the package level. This is NOT unit testing - we test observable behavior through the public API only. See Testing Philosophy below for full rationale.
Options
type SwitchScenarioOptions = {
readonly baseURL: string; // Base URL of your application
readonly endpoint?: string; // Scenario endpoint path or absolute URL (default: '/__scenario__')
readonly testIdHeader?: string; // Test ID header name (default: 'x-scenarist-test-id')
readonly variant?: string; // Optional scenario variant
};Tip: Use an absolute URL for
endpointwhen your API runs on a different host/port than the frontend. See Cross-Origin API Servers.
What it does
The switchScenario helper:
- Generates a unique test ID (
test-{scenarioId}-{timestamp}) - POSTs to the scenario endpoint with the test ID header
- Verifies the scenario switch succeeded (200 response)
- Sets the test ID header for all subsequent requests in the test
This reduces scenario switching from 9 lines of boilerplate to 2 lines:
Without helper (9 lines):
const testId = `test-premium-${Date.now()}`;
const response = await page.request.post(
"http://localhost:3000/api/__scenario__",
{
headers: { "x-scenarist-test-id": testId },
data: { scenario: "premiumUser" },
},
);
expect(response.status()).toBe(200);
await page.setExtraHTTPHeaders({ "x-scenarist-test-id": testId });With helper (2 lines):
await switchScenario(page, "premiumUser", {
baseURL: "http://localhost:3000",
endpoint: "/api/__scenario__",
});Code reduction: 77%
API Reference
Fixtures API (Recommended)
withScenarios(scenarios)
Creates a type-safe Playwright test object with Scenarist fixtures.
// tests/fixtures.ts
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "../lib/scenarios";
export const test = withScenarios(scenarios);
export { expect };// tests/my-test.spec.ts
import { test, expect } from "./fixtures";
test("my test", async ({ page, switchScenario, scenaristTestId }) => {
// Your test code
});Fixtures provided:
switchScenario(page, scenarioId, options?)- Switch to a scenario (auto-injects test ID)scenaristTestId- Unique test ID for this test (usually don't need to access directly)debugState(page)- Get current test state from debug endpoint (useful for debugging)waitForDebugState(page, condition, options?)- Wait for state to meet a condition
expect
Re-exported from @playwright/test for convenience:
import { withScenarios, expect } from "@scenarist/playwright-helpers";Configuration Options
Set in playwright.config.ts:
export default defineConfig({
use: {
baseURL: "http://localhost:3000", // Standard Playwright (used by switchScenario)
scenaristEndpoint: "/api/__scenario__", // Scenarist endpoint path (default: '/api/__scenario__')
},
});Available options:
scenaristEndpoint?: string- The endpoint path or absolute URL for scenario switching (default:'/api/__scenario__')scenaristStateEndpoint?: string- The endpoint path for debug state inspection (default:'/__scenarist__/state')
Cross-Origin API Servers
When your API server runs on a different host or port than your frontend, use an absolute URL for scenaristEndpoint:
// Frontend: http://localhost:3000
// API Server: http://localhost:9090
export default defineConfig<ScenaristOptions>({
use: {
baseURL: "http://localhost:3000", // Frontend URL (for Playwright navigation)
scenaristEndpoint: "http://localhost:9090/__scenario__", // Absolute URL to API
},
});How it works:
- Relative paths (e.g.,
/api/__scenario__) are prepended withbaseURL - Absolute URLs (starting with
http://orhttps://) are used directly, ignoringbaseURL
This is useful when:
- Your API and frontend are separate services on different ports
- You're testing against a staging/production API endpoint
- Your test infrastructure uses a separate mock server
Debug State Fixtures
When testing multi-stage flows with state-aware mocking, you may need to inspect or wait for internal state changes. The debugState and waitForDebugState fixtures help debug test failures and verify state mutations.
debugState (Fixture)
Fetches the current test state from the debug endpoint. Useful for debugging and verifying afterResponse.setState worked correctly.
import { test, expect } from "./fixtures";
test("loan application flow", async ({ page, switchScenario, debugState }) => {
await switchScenario(page, "loanApplication");
// Before any actions - state should be empty
const initialState = await debugState(page);
expect(initialState).toEqual({});
// Navigate and submit application
await page.goto("/apply");
await page.getByRole("button", { name: "Submit" }).click();
// Verify state was set by afterResponse.setState
const state = await debugState(page);
expect(state.submitted).toBe(true);
expect(state.phase).toBe("review");
});When to use debugState:
- Debugging failing tests ("why is the response wrong?")
- Verifying
afterResponse.setStateworked correctly - Understanding state progression through multi-step flows
- Testing conditional
afterResponsebehavior
waitForDebugState (Fixture)
Waits for the test state to meet a condition. Useful for async flows where state changes after a delay.
import { test, expect } from "./fixtures";
test("async approval flow", async ({
page,
switchScenario,
waitForDebugState,
}) => {
await switchScenario(page, "asyncApproval");
await page.goto("/dashboard");
// Trigger async approval process
await page.getByRole("button", { name: "Request Approval" }).click();
// Wait for state to indicate approval completed
const state = await waitForDebugState(
page,
(s) => s.approved === true,
{ timeout: 10000 }, // Wait up to 10 seconds
);
expect(state.approved).toBe(true);
expect(state.approvedBy).toBeDefined();
});Parameters:
page: Page- Playwright Page objectcondition: (state: Record<string, unknown>) => boolean- Function that returns true when state is readyoptions?: { timeout?: number; interval?: number }- Optional configurationtimeout- Maximum time to wait in ms (default: 5000)interval- Polling interval in ms (default: 100)
Throws: Error if condition is not met within timeout.
Setting Up the Debug Endpoint
The debug fixtures require a debug state endpoint in your application. Create it alongside your scenario endpoint:
Pages Router:
// pages/api/__scenarist__/state.ts
import { scenarist } from "@/lib/scenarist";
export default scenarist?.createStateEndpoint();App Router:
// app/api/%5F%5Fscenarist%5F%5F/state/route.ts
import { scenarist } from "@/lib/scenarist";
const handler = scenarist?.createStateEndpoint();
export const GET = handler;Express:
// The state endpoint is automatically included in scenarist.middleware
// No additional setup needed!switchScenario (Fixture)
Switch to a scenario using the automatically generated test ID.
await switchScenario(page, scenarioId, options?)Parameters:
page: Page- Playwright Page objectscenarioId: string- The scenario to switch tooptions?: { baseURL?: string; endpoint?: string }- Optional overrides (rarely needed)
What it does:
- Reads
baseURLfrom Playwright config (or uses override) - Reads
scenaristEndpointfrom Playwright config (or uses override) - Generates unique test ID automatically (via
scenaristTestIdfixture) - POSTs to scenario endpoint with test ID header
- Verifies scenario switch succeeded
- Sets test ID header for all subsequent requests
Standalone API (Advanced)
switchScenario (Function)
For manual test ID control:
import { switchScenario } from "@scenarist/playwright-helpers";
await switchScenario(page, scenarioId, {
baseURL: "http://localhost:3000",
endpoint: "/api/__scenario__",
testId: "my-custom-test-id", // Manual test ID
});Use this only when:
- You need to share test IDs across multiple tests
- You're integrating with existing test infrastructure that provides test IDs
- You can't use Playwright fixtures for some reason
⚠️ Warning: Manual test IDs can cause collisions in parallel execution. The fixture API is safer.
Common Pitfalls
❌ Don't: Switch scenarios after navigation
import { test, expect } from "./fixtures"; // Import from YOUR fixtures file
test("bad example", async ({ page, switchScenario }) => {
await page.goto("/"); // BAD - Navigating first
await switchScenario(page, "premium"); // Headers set too late!
});Why it fails: Headers set AFTER navigation don't affect the already-loaded page.
Solution: ✅ Switch scenario BEFORE navigating:
import { test, expect } from "./fixtures";
test("good example", async ({ page, switchScenario }) => {
await switchScenario(page, "premium"); // Set headers first
await page.goto("/"); // Now requests use test ID header
});❌ Don't: Forget to configure in playwright.config.ts
// playwright.config.ts - Missing configuration!
export default defineConfig({
use: {
// Missing: baseURL and scenaristEndpoint
},
});Error: switchScenario won't know where to send requests.
Solution: ✅ Configure in playwright.config.ts:
export default defineConfig({
use: {
baseURL: "http://localhost:3000",
scenaristEndpoint: "/api/__scenario__",
},
});❌ Don't: Use standalone switchScenario with manual test IDs
import { switchScenario } from "@scenarist/playwright-helpers";
test("bad example", async ({ page }) => {
// BAD - Manual test ID risks conflicts
await switchScenario(page, "premium", {
baseURL: "http://localhost:3000",
endpoint: "/api/__scenario__",
testId: "my-test", // Same ID across parallel tests = collision!
});
});Why it fails: Multiple tests with the same ID will interfere with each other in parallel execution.
Solution: ✅ Use the fixture API (auto-generates unique IDs):
import { test, expect } from "./fixtures"; // Import from YOUR fixtures file
test("good example", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// Generates unique ID automatically: test-abc123-{uuid}
});Why This Package Exists
Before this helper, switching scenarios in Playwright tests required significant boilerplate:
- Generate unique test ID
- Construct scenario endpoint URL
- Send POST request with test ID header
- Verify response status
- Set test ID header for all subsequent requests
This 9-line pattern was repeated in every test, making tests verbose and error-prone. The switchScenario helper encapsulates this pattern into a single function call, reducing code by 77% while improving readability and maintainability.
Documentation
📖 Full Documentation - Complete guides, API reference, and examples.
Contributing
See CONTRIBUTING.md for development setup and guidelines.
License
MIT
