This is the abridged developer documentation for Scenarist
# Quick Start
> Get up and running with Scenarist in 5 minutes
Modern frameworks like Next.js blur the lines between frontend and backend. A single Server Component might validate input, query a database, call Stripe, and render HTML—all in one request. **The testing dilemma:** * **Mock everything?** You lose integration confidence—you’re not testing how your app actually works * **Hit real external APIs?** Slow, flaky, expensive, and impossible to test edge cases like payment failures **Scenarist’s approach:** Run Playwright tests against your real application. Your Server Components render, your middleware executes, your validation runs—for real. Only external services (Stripe, Auth0, SendGrid) are mocked, and you control exactly what they return per test. ## How It Works 1. **Define scenarios** — Declarative objects describing what external APIs should return 2. **Add middleware** — One line to integrate with your framework 3. **Switch scenarios per test** — Each test can use different API responses, running in parallel
```typescript
import type { ScenaristScenarios } from '@scenarist/express-adapter';
// Or: import type { ScenaristScenarios } from '@scenarist/nextjs-adapter/app';
// Define scenarios as data (not functions)
const scenarios = {
default: {
id: 'default',
name: 'Default',
description: 'Payment succeeds',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: { status: 200, body: { id: 'ch_123', status: 'succeeded' } },
},
],
},
cardDeclined: {
id: 'cardDeclined',
name: 'Card declined',
description: 'Stripe rejects the charge',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: { status: 402, body: { error: { code: 'card_declined' } } },
},
],
},
} as const satisfies ScenaristScenarios;
```
## Choose Your Framework Pick your framework to get started with a complete, working setup: [Express](/frameworks/express/getting-started/)API testing with Supertest + Vitest. Fast parallel execution. [Next.js App Router](/frameworks/nextjs-app-router/getting-started/)Server Components, Route Handlers, Server Actions. [Next.js Pages Router](/frameworks/nextjs-pages-router/getting-started/)API routes, getServerSideProps, getStaticProps. ## Example Apps Each framework has a complete, working example you can clone and run: | Framework | Example App | | -------------------- | -------------------------------------------------------------------------------------------------------------------- | | Express | [apps/express-example](https://github.com/citypaul/scenarist/tree/main/apps/express-example) | | Next.js App Router | [apps/nextjs-app-router-example](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example) | | Next.js Pages Router | [apps/nextjs-pages-router-example](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example) | ## What You’ll Learn Each framework guide covers: * **Installation** — Package setup for your framework * **Scenario definition** — How to structure mocks with default fallbacks * **App integration** — Framework-specific middleware/endpoint setup * **Test patterns** — Real test examples using your framework’s tools * **Header forwarding** — How to propagate test IDs to external APIs * **Production safety** — Tree-shaking and deployment considerations ## Core Concepts Before diving into your framework guide, here’s what makes Scenarist different: Test Real Code Your routes, middleware, and business logic execute normally. Only external HTTP calls are mocked. Declarative Scenarios Scenarios are data structures, not functions. Inspectable, composable, and versionable. Parallel Execution Each test gets isolated scenario state via test IDs. Run hundreds of tests simultaneously. Zero Production Code Conditional exports eliminate all Scenarist code from production builds. ## Debugging with Logs When scenarios don’t match as expected, pass a logger to `createScenarist()` to see exactly what’s happening. The example apps pass `createConsoleLogger()` when a `SCENARIST_LOG` environment variable is set:
```bash
# See which mocks are matching your requests
SCENARIST_LOG=1 pnpm test
```
```plaintext
09:49:09.715 DBG [test-checkout] 🎯 matching mock_candidates_found count=5 url="/api/cart"
09:49:09.716 INF [test-checkout] 🎯 matching mock_selected mockIndex=2 specificity=5
```
**[→ Full logging guide](/reference/logging/)** ## Debugging in Playwright Tests When tests fail, inspect the current mock state directly from your Playwright tests using the debug fixtures:
```typescript
import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'checkout');
await page.goto('/cart');
await page.click('#add-item');
// Inspect current state captured by mocks
const state = await debugState(page);
console.log('Cart state:', state);
// → { 'cart.items': 1, 'cart.total': 29.99 }
});
```
For async workflows, wait for state to reach a condition:
```typescript
test('approval flow', async ({ page, switchScenario, waitForDebugState }) => {
await switchScenario(page, 'approvalFlow');
await page.click('#submit-for-approval');
// Wait for backend state to update
const state = await waitForDebugState(
page,
(s) => s['approval.status'] === 'approved',
{ timeout: 10000 }
);
});
```
**[→ Full Playwright debug helpers guide](/testing/playwright-integration/#debugging-state)** ## Next Steps 1. **[Choose your framework](#choose-your-framework)** — Follow the complete getting-started guide 2. **[Read the philosophy](/concepts/philosophy/)** — Understand the “test behavior, not implementation” approach 3. **[Explore dynamic capabilities](/scenarios/overview/)** — Request matching, sequences, stateful mocks
# Installation
> How to install Scenarist in your project
Scenarist is distributed as a set of packages. Install the adapter for your framework and the appropriate testing tools. ## Package Overview | Package | Purpose | | ------------------------------- | ----------------------------------------------------- | | `@scenarist/nextjs-adapter` | Next.js App Router and Pages Router integration | | `@scenarist/express-adapter` | Express middleware integration | | `@scenarist/playwright-helpers` | Test utilities for Playwright (browser-based testing) | ## Next.js App Router Install the Next.js adapter and Playwright helpers:
```bash
# pnpm
pnpm add @scenarist/nextjs-adapter msw
pnpm add -D @scenarist/playwright-helpers @playwright/test
# npm
npm install @scenarist/nextjs-adapter msw
npm install -D @scenarist/playwright-helpers @playwright/test
# yarn
yarn add @scenarist/nextjs-adapter msw
yarn add -D @scenarist/playwright-helpers @playwright/test
```
Import from the `/app` subpath:
```typescript
import { createScenarist } from "@scenarist/nextjs-adapter/app";
```
**Peer dependencies:** `next@^14.0.0 || ^15.0.0 || ^16.0.0`, `msw@^2.0.0` After installation, follow the [Next.js App Router Getting Started guide](/frameworks/nextjs-app-router/getting-started/) to configure your app. ## Next.js Pages Router Install the Next.js adapter and Playwright helpers:
```bash
# pnpm
pnpm add @scenarist/nextjs-adapter msw
pnpm add -D @scenarist/playwright-helpers @playwright/test
# npm
npm install @scenarist/nextjs-adapter msw
npm install -D @scenarist/playwright-helpers @playwright/test
# yarn
yarn add @scenarist/nextjs-adapter msw
yarn add -D @scenarist/playwright-helpers @playwright/test
```
Import from the `/pages` subpath:
```typescript
import { createScenarist } from "@scenarist/nextjs-adapter/pages";
```
**Peer dependencies:** `next@^14.0.0 || ^15.0.0 || ^16.0.0`, `msw@^2.0.0` After installation, follow the [Next.js Pages Router Getting Started guide](/frameworks/nextjs-pages-router/getting-started/) to configure your app. ## Express ### API Testing with Supertest (Recommended) For testing Express APIs directly without a browser, use **Supertest** with **Vitest**:
```bash
# pnpm
pnpm add @scenarist/express-adapter msw
pnpm add -D vitest supertest @types/supertest
# npm
npm install @scenarist/express-adapter msw
npm install -D vitest supertest @types/supertest
# yarn
yarn add @scenarist/express-adapter msw
yarn add -D vitest supertest @types/supertest
```
This is the recommended approach for Express API testing—fast, parallel test execution without browser overhead. **Example test with Supertest:**
```typescript
import { describe, it, expect } from "vitest";
import request from "supertest";
import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";
it("processes payment successfully", async () => {
await request(app)
.post("/__scenario__")
.set(SCENARIST_TEST_ID_HEADER, "test-1")
.send({ scenario: "default" });
const response = await request(app)
.post("/api/checkout")
.set(SCENARIST_TEST_ID_HEADER, "test-1")
.send({ amount: 5000 });
expect(response.status).toBe(200);
});
```
See the [complete Express example tests](https://github.com/citypaul/scenarist/tree/main/apps/express-example/tests) for comprehensive patterns including scenario switching, test isolation, and dynamic responses. ### Full-Stack Testing with Playwright (Optional) If you have a **full-stack application** with an Express backend and want browser-based scenario testing, add the Playwright helpers:
```bash
# pnpm
pnpm add @scenarist/express-adapter msw
pnpm add -D @scenarist/playwright-helpers @playwright/test
# npm
npm install @scenarist/express-adapter msw
npm install -D @scenarist/playwright-helpers @playwright/test
# yarn
yarn add @scenarist/express-adapter msw
yarn add -D @scenarist/playwright-helpers @playwright/test
```
Use [Playwright helpers](/testing/playwright-integration/) when you need to test user interactions through a browser (clicks, form submissions, visual verification). **Peer dependencies:** `express@^4.18.0 || ^5.0.0`, `msw@^2.0.0` After installation, follow the [Express Getting Started guide](/frameworks/express/getting-started/) to configure your app. ## Requirements * **Node.js 18+** - Required for all packages * **TypeScript 5+** - Recommended for type-safe scenario IDs * **MSW 2.x** - Peer dependency for all adapters ## Verifying Installation After installing, verify the packages are correctly installed:
```bash
# Check package versions
pnpm list @scenarist/nextjs-adapter @scenarist/express-adapter @scenarist/playwright-helpers
```
You should see the installed packages and their versions listed. ## Next Steps * Follow the [Quick Start](/getting-started/quick-start/) to set up your first scenario * Read the framework-specific guides for detailed configuration: * [Next.js App Router](/frameworks/nextjs-app-router/getting-started/) * [Next.js Pages Router](/frameworks/nextjs-pages-router/getting-started/) * [Express](/frameworks/express/getting-started/)
# Using Scenarist with AI Assistants
> Give Claude, ChatGPT, Cursor, Copilot, and other coding agents accurate Scenarist documentation through llms.txt
Coding assistants often guess at APIs they have not seen. Scenarist publishes its documentation in plain Markdown, following the [llms.txt standard](https://llmstxt.org/), so an assistant can read the real API instead of inventing one. ## Which file to use | URL | What it contains | Use it when | | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | [`scenarist.io/llms.txt`](/llms.txt) | A short index: what Scenarist is, the rules assistants most often get wrong, and links to the most useful docs pages and to the bundles below | The assistant can fetch URLs itself. Start here. | | [`scenarist.io/llms-full.txt`](/llms-full.txt) | Every docs page in one Markdown file | You want the assistant to have everything, and its context window is large | | [`scenarist.io/llms-small.txt`](/llms-small.txt) | The same pages with tips, notes, and comparison pages removed | The full file is too large for the tool you are using | `llms.txt` also links to smaller topic bundles, such as Writing scenarios, Next.js App Router, and Express, that fit in almost any context window. The full, small, and topic files are generated from the documentation on every deploy, so they always match this site. The index and rules in `llms.txt` are written by hand and reviewed with each docs change. ## Point your assistant at the docs ### Agents that read project instructions Claude Code, Codex, Cursor, GitHub Copilot, and similar agents read a project instructions file such as `AGENTS.md`, `CLAUDE.md`, or `.github/copilot-instructions.md`. Add a line to it so the agent fetches the documentation before writing Scenarist code:
```markdown
## Scenarist
Before writing or changing Scenarist scenarios, adapters, or tests, read
https://scenarist.io/llms.txt and follow the links relevant to the task.
```
### Editors that index documentation In an editor that can index documentation by URL, such as Cursor’s **@Docs**, add `https://scenarist.io/llms-full.txt`. The editor can then pull in Scenarist’s docs when you mention them in a prompt. ### Chat assistants In Claude, ChatGPT, Gemini, or another chat assistant, paste `https://scenarist.io/llms.txt` into your message and ask the assistant to read it first. If the assistant cannot browse, download `llms-small.txt` or a topic bundle and attach it to the conversation. ## What the index tells an assistant up front Beyond links, `llms.txt` states the facts assistants most often get wrong, so they are right on the first attempt: * Scenarios are plain data, never functions, and must include a `default` scenario * Next.js apps forward the test ID header on every outgoing `fetch` * The Next.js App Router scenario route lives in `app/api/%5F%5Fscenario%5F%5F/route.ts` * Playwright tests import `test` from a fixtures file built with `withScenarios` * Scenarist mocks HTTP requests only, not database calls If an assistant still produces code that does not match these docs, [open an issue](https://github.com/citypaul/scenarist/issues) with the prompt you used so the index can be improved.
# Why Scenarist?
> Understanding scenario-based testing and how Scenarist fills the gap between unit tests and end-to-end tests
## What is Scenario-Based Testing? **Scenario-based testing** is an integration testing approach where your real application code executes while external dependencies (third-party APIs, microservices) return controlled responses. Unlike true end-to-end tests that use zero mocks, scenario-based tests mock only the external services you don’t control. | Testing Approach | Your Code | External APIs | Best For | | ------------------------ | --------- | ------------- | ------------------------------------------------- | | **Unit Tests** | Mocked | Mocked | Isolated function logic | | **Scenario-Based Tests** | Real | Mocked | Application behavior with controlled dependencies | | **End-to-End Tests** | Real | Real | Full system validation (production-like) | **Why “scenario-based”?** Because you define complete backend *scenarios* (success, error, timeout, user tiers) and switch between them at runtime. Each test selects a scenario that describes the complete external API state, enabling comprehensive testing without external dependencies. **The key distinction from E2E:** True end-to-end tests use real external APIs with zero mocks—ideal for validating complete production behavior, but slow, expensive, and limited in edge case coverage. Scenario-based tests give you the speed of unit tests with the realism of integration tests by running your real code against controlled external responses. *** ## The Testing Gap Modern web development has blurred the traditional separation between frontend and backend code. Frameworks like **Next.js**, **Remix**, **SvelteKit**, and **SolidStart** run server-side logic alongside UI components. Traditional backends built with **Express**, **Hono**, or **Fastify** face the same challenge: all make HTTP calls to external services (Stripe, Auth0, SendGrid) that need different behaviors in tests. This creates a testing challenge: **Server Components, loaders, and API routes** execute server-side but are defined alongside components. Your UI code calls external APIs directly on the server. Testing this requires either mocking framework internals or running full end-to-end tests. **Traditional backend services** call the same external APIs. Testing payment flows, authentication errors, or email delivery requires simulating different API responses. ## What Scenarist Offers * **[Simple Architecture](/concepts/architecture/)** — Just an HTTP header (`x-scenarist-test-id`). No Docker, no separate processes, no complex network configuration * **[Test ID Isolation](/testing/parallel-testing/)** — Run hundreds of parallel tests with different scenarios against one server. Each test's header routes to its own scenario * **Runtime Switching** — Change scenarios mid-test without restarts (retry flows, error recovery) * **[First-Class Playwright](/testing/playwright-integration/)** — Dedicated fixtures with type-safe scenarios and automatic test ID handling * **[Response Sequences](/scenarios/response-sequences/)** — Built-in polling, retry flows, state machines * **[Stateful Mocks](/scenarios/stateful-mocks/)** — Capture request values, inject into responses. State is isolated per test ID, so parallel tests never conflict * **[Advanced Matching](/scenarios/request-matching/)** — Body, headers, query params, regex with specificity-based selection * **[Framework Adapters](/getting-started/why-scenarist/#nextjs-multi-process-handling)** — Not thin wrappers—they solve real problems. For example, the Next.js adapter includes built-in singleton protection for the [module duplication issue](https://github.com/vercel/next.js/discussions/68572) that breaks MSW * **Developer Tools ([Roadmap](/roadmap/))** — Planned browser-based plugin for switching scenarios during development and debugging—making scenario exploration instant and visual *** ## Testing Options and Their Trade-offs **Unit tests** can test server-side logic, but require mocking framework internals (Next.js `fetch`, `cookies`, `headers`) or HTTP clients. This creates distance between test execution and production behavior. **End-to-end tests** provide confidence by testing the complete system, but cannot reach most edge case states. How do you make Stripe return a specific decline code? Or Auth0 timeout? Or SendGrid fail with a particular error? You can’t control real external APIs to test these scenarios. Testing the few scenarios you can reach would also be prohibitively slow. **Between these approaches lies a gap:** Running your real server-side code against any external API response you choose, without calling live third-party services or mocking framework internals. **Scenarist fills this gap** by testing your server-side HTTP layer with mocked external APIs. Your code—Server Components, loaders, middleware, business logic—executes normally. Only HTTP requests (fetch, axios, etc.) are intercepted, returning scenario-defined responses based on test ID. This enables testing full user journeys through the browser using [Playwright helpers](/testing/playwright-integration/), with each test isolated and running in parallel. **Test extensive external API scenarios in parallel** without expensive cloud API calls or complex test infrastructure. ### Next.js Multi-Process Handling (Solved) Next.js presents a unique challenge for MSW-based testing. It has a [well-documented singleton problem](https://github.com/vercel/next.js/discussions/68572) where webpack bundles the same module multiple times, breaking classic singleton patterns. This is compounded by [MSW's challenges with Next.js's process model](https://github.com/mswjs/msw/issues/1644)—Next.js keeps multiple Node.js processes that make global module patches difficult to maintain. **Scenarist solves this automatically.** The Next.js adapter includes built-in `globalThis` singleton guards that ensure only one MSW instance exists, regardless of how Next.js loads your modules. You don't need to understand Next.js internals or implement manual workarounds—just use `export const scenarist = createScenarist(...)` and Scenarist handles the complexity. ## What You Can Test **When your app calls external HTTP APIs, Scenarist gives you full control.** You can test complete user journeys—from browser interaction through Server Components, API routes, and middleware—with your real server-side code executing, while you control exactly what responses come back from external services. ### Perfect For * **Server Components** fetching from external APIs (Stripe, Auth0, SendGrid) * **API routes** that call third-party services * **Middleware** that validates tokens or checks permissions * **Full user journeys** through real frontend + backend code
```typescript
// Server Component - Your real code executes
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
export default async function CheckoutPage() {
// ✅ This call is intercepted - you control the response
const payment = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: {
// Forward the test ID so the call gets this test's scenario
...getScenaristHeadersFromReadonlyHeaders(await headers()),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
body: JSON.stringify({ amount: 5000 }),
cache: 'no-store',
});
// Your real rendering logic
const result = await payment.json();
return ;
}
```
**Test any scenario:** Payment success, card declined, network timeout, rate limiting, webhook failures—all controlled by your test scenarios, running in parallel. ### The Requirement: Network Requests Scenarist intercepts HTTP requests that traverse the network. This works because MSW (Mock Service Worker) operates at the network level, intercepting requests from any HTTP client (fetch, axios, etc.). **What this means in practice:**
```typescript
// ✅ WORKS - External API
const stripe = await fetch("https://api.stripe.com/v1/products");
// ✅ WORKS - Different port on localhost
const products = await fetch("http://localhost:3001/products");
// ❌ Does NOT work - Same host/port internal route
const products = await fetch("http://localhost:3000/api/products");
```
**Why internal routes don’t work:** When a Server Component calls an API route on the same host/port, Next.js handles this internally without making a network request. MSW only sees requests that go through the network stack. ### What Cannot Be Intercepted * **Direct database access** (PostgreSQL, MongoDB, Prisma) - no HTTP request * **Internal API routes** on the same host/port - Next.js internal routing * **File system operations** - no HTTP request * **WebSocket connections** - MSW supports WebSockets, but Scenarist’s scenario system focuses on HTTP **If your app uses direct database access:** See [Testing Database Apps](/guides/testing-database-apps/) for strategies. We recommend the [Repository Pattern](/guides/testing-database-apps/repository-pattern/) for scalable parallel testing with the same test ID isolation model as Scenarist. [Learn how it works →](/concepts/how-it-works/) ### Why Framework Documentation Recommends E2E This gap is evident in how framework authors struggle to provide testing guidance. The [Next.js testing docs](https://nextjs.org/docs/app/building-your-application/testing) focus primarily on unit testing and E2E testing, acknowledging that async Server Components present unique testing challenges. [Remix testing guidance](https://remix.run/docs/en/main/guides/testing) notes the complexity of testing components that depend on Remix context and loaders. SvelteKit faces similar challenges with server route testing. The pattern is clear: when “frontend” components run on the server and call external APIs directly, traditional testing approaches break down. **Scenarist fills this gap** by testing real server-side code with mocked external APIs. ## Testing Behavior, Not Implementation Scenarist enables behavior-focused testing by letting you test your server’s response to different external API behaviors without mocking internal implementation details. **Your tests describe scenarios:** * “Premium user checkout with valid payment” * “Payment declined due to insufficient funds” * “Auth0 timeout during login” **Not implementation details:** * ~~“Mock stripe.charges.create to throw error”~~ * ~~“Stub authClient.getSession to return null”~~ * ~~“Mock sendgrid.send to resolve with 500”~~ This follows Test-Driven Development principles: tests document expected behavior, implementation details can change as long as behavior stays consistent. [Learn about our testing philosophy →](/concepts/philosophy/) ## Comparing Testing Approaches Scenarist fills a gap between unit tests and end-to-end tests. Each approach serves different purposes—they complement rather than replace each other: * **Unit tests** verify individual functions and modules in isolation * **Scenarist** verifies HTTP-level behavior with different external API scenarios * **E2E tests** verify the complete user experience including browser interactions For detailed comparisons with other tools (WireMock, Nock, Testcontainers, Playwright mocks), see our [Tool Comparison](/comparison/) guide. The comparison includes a [decision guide](/comparison/#making-the-decision) to help you choose the right approach for your needs. Scenarist Complements E2E Testing Scenarist tests server-side HTTP behavior, not complete user workflows. Browser interactions, JavaScript execution, and visual rendering still require end-to-end tests. Use Scenarist alongside E2E tests, not as a replacement. ## Limitations and Trade-offs **HTTP only**: Scenarist intercepts HTTP requests only. It cannot mock database calls, file system operations, or WebSocket connections (MSW supports WebSockets, but Scenarist’s scenario system is designed for HTTP request/response patterns). For apps with direct database access, see [Testing Database Apps](/guides/testing-database-apps/) for recommended strategies. **Database parallelism**: While Scenarist enables parallel HTTP tests via test ID isolation, database testing requires different strategies. We recommend the [Repository Pattern](/guides/testing-database-apps/repository-pattern/), which provides the same test ID isolation model for database access. See [Parallelism Options](/guides/testing-database-apps/parallelism-options/) for all approaches and trade-offs. **Single-server deployment**: Scenarist stores test ID to scenario mappings in memory. This works well for local development and single-instance CI environments. Load-balanced deployments would require additional state management. **Mock maintenance**: Scenario definitions need updates when external APIs change. Scenarist doesn’t validate that mocks match real API contracts—this is a deliberate trade-off for test isolation and speed. **Learning curve**: Understanding scenario definitions, test ID isolation, and the relationship between mocks and real backend code requires initial investment. The documentation and examples aim to reduce this learning time. ## Getting Started Choose your framework to see specific installation and usage instructions: * [Next.js →](/frameworks/nextjs/) - Test Server Components and API routes * [Express →](/frameworks/express/) - Test middleware and route handlers Or explore core concepts that apply to all frameworks: * [Overview: How It Works →](/concepts/how-it-works/) * [Capabilities: Writing Scenarios →](/scenarios/overview/) * [Scenarios & Format →](/scenarios/basic-structure/) * [Architecture →](/concepts/architecture/)
# How it works
> Understanding Scenarist's execution model and runtime scenario switching
Scenarist fills the testing gap by enabling **HTTP-level integration testing** with **runtime scenario switching**: * Tests make real HTTP requests to your backend * Your backend code executes normally (middleware, routing, business logic) * External API calls are intercepted and return scenario-defined responses * Different scenarios run in parallel against the same server instance * Each test is isolated via unique test identifiers ## One Server, Unlimited Scenarios **The key insight:** Each scenario is a **complete set of API mocks** that defines how every external API behaves for that test. The diagram shows 2 scenarios as examples—you can define as many as you need, and each scenario can mock as many APIs as your application uses. **Understanding the pattern:** Each test switches to a specific scenario, and that scenario controls **all external API responses** for the duration of that test: * **Test 1** switches to `allSucceed` → Stripe succeeds, Auth0 authenticates, SendGrid sends * **Test 2** switches to `paymentFails` → Stripe declines, Auth0 authenticates, SendGrid sends Notice how each scenario defines the complete behavior: in `paymentFails`, only Stripe fails—Auth0 and SendGrid still succeed. This lets you test **exactly** the edge case you care about. **Default scenario pattern (recommended):** Define a `default` scenario with your **happy path** responses for all external APIs. Then create specialized scenarios that override only what changes:
```typescript
import type { ScenaristScenarios } from "@scenarist/nextjs-adapter/app";
const scenarios = {
default: {
// Happy path - all APIs succeed
id: "default",
name: "All succeed",
description: "Happy path for every external API",
mocks: [
{
method: "POST",
url: "https://api.stripe.com/...",
response: { status: 200, body: { status: "succeeded" } },
},
{
method: "GET",
url: "https://api.auth0.com/...",
response: { status: 200, body: { user: "john@example.com" } },
},
{
method: "POST",
url: "https://api.sendgrid.com/...",
response: { status: 200, body: { status: "sent" } },
},
],
},
paymentFails: {
// Only override Stripe - Auth0 and SendGrid automatically fall back to default
id: "paymentFails",
name: "Payment fails",
description: "Stripe declines; everything else succeeds",
mocks: [
{
method: "POST",
url: "https://api.stripe.com/...",
response: { status: 402, body: { status: "declined" } },
},
],
},
} as const satisfies ScenaristScenarios;
```
When you switch to `paymentFails`, Scenarist uses that scenario’s mocks (Stripe declines) **and automatically falls back to the default scenario** for any APIs not defined (Auth0 and SendGrid succeed). This eliminates duplication—you only define what changes. **What this enables:** * ✅ **Unlimited scenarios** - Premium users, free users, error states, edge cases—as many as you need * ✅ **Unlimited APIs per scenario** - Mock Stripe, Auth0, SendGrid, GitHub, Twilio—as many as your app uses * ✅ **Default fallback** - Define happy path once, override only what changes in each scenario * ✅ **Test edge cases exhaustively** - Can’t make real Stripe decline with a specific error code, but your scenario can * ✅ **Fast parallel testing** - All scenarios run simultaneously against the same server ## Execution Model When testing with Scenarist, your backend executes as it would in production: **Green boxes**: Your code executes with production behavior **Yellow boxes**: External API calls are intercepted and handled by scenario definitions ## Example This example demonstrates HTTP-level testing with Next.js. Each framework has its own adapter that integrates Scenarist into your application. **Step 1: Framework-specific setup** (done once per application)
```typescript
// lib/scenarist.ts - Next.js App Router
import { createScenarist } from "@scenarist/nextjs-adapter/app";
import { scenarios } from "./scenarios";
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
if (typeof window === "undefined" && scenarist) {
scenarist.start();
}
// app/api/%5F%5Fscenario%5F%5F/route.ts - serves /api/__scenario__
import { scenarist } from "../../../lib/scenarist";
const handler = scenarist?.createScenarioEndpoint();
export const POST = handler;
export const GET = handler;
```
**Step 2: Define scenarios** (reusable across tests) scenarios.ts
```typescript
import type { ScenaristScenarios } from "@scenarist/nextjs-adapter/app";
export const scenarios = {
default: {
id: "default",
name: "Default",
description: "Baseline mocks for every test",
mocks: [],
},
premiumUser: {
id: "premiumUser",
name: "Premium User",
description: "Auth provider returns a premium session",
mocks: [
{
method: "GET",
url: "https://api.auth-provider.com/session",
response: {
status: 200,
body: { tier: "premium", userId: "user-123" },
},
},
],
},
} as const satisfies ScenaristScenarios;
```
**Step 3: [Set up Playwright fixtures](/testing/playwright-integration/)** (one-time setup) tests/fixtures.ts
```typescript
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "./scenarios"; // Import your scenarios
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };
```
**Step 4: Write tests** (import from fixtures, not @playwright/test) tests/premium-features.spec.ts
```typescript
import { test, expect } from "./fixtures"; // ✅ Import from fixtures, NOT @playwright/test
test("premium users access advanced features", async ({
page,
switchScenario,
}) => {
await switchScenario(page, "premiumUser"); // ✅ Type-safe! Autocomplete works
// Real HTTP request → Next.js route → middleware → business logic
await page.goto("/dashboard");
// External auth API call intercepted, returns mocked premium tier
// Your business logic processes the tier correctly
await expect(page.getByText("Advanced Analytics")).toBeVisible();
});
```
**What’s happening:** 1. Framework adapter integrates Scenarist into your Next.js app 2. Scenarios define how external APIs behave 3. [Playwright fixtures](/testing/playwright-integration/) create type-safe test helpers with scenario autocomplete 4. Tests import from fixtures (not @playwright/test directly) 5. Test switches to scenario and makes real HTTP requests 6. Your backend code executes with production behavior 7. External API calls return scenario-defined responses **See complete working examples:** * [Next.js Example App →](/frameworks/nextjs-app-router/example-app/) * [Express Example App →](/frameworks/express/example-app/) **Framework-specific guides:** * [Next.js setup →](/frameworks/nextjs-app-router/getting-started/) * [Express setup →](/frameworks/express/getting-started/) ## Ephemeral Endpoints: Test-Only Activation Scenarist creates special scenario endpoints (`/__scenario__` in Express, `/api/__scenario__` in the Next.js example route files) that **only exist in non-production builds**. These ephemeral endpoints enable runtime scenario switching while maintaining production safety. **What are ephemeral endpoints?** * `POST /__scenario__` - Switch the active scenario for a test * `GET /__scenario__` - Check which scenario is currently active **Why “ephemeral”?** The endpoints only exist when `createScenarist()` returns an instance. In production builds, each adapter’s `production` export condition resolves to a stub whose `createScenarist()` returns `undefined`, so there is nothing to mount:
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true
scenarios,
}); // undefined when the `production` export condition is resolved
```
**In development/test builds:** * Endpoints accept requests and switch scenarios * MSW intercepts external API calls * Test ID headers route requests to correct scenarios **When `enabled: false`:** * `createScenarist()` returns `undefined`, so the endpoints have no working handler: Express never mounts them (404), and Next.js scenario routes answer 405 (or your fallback handler’s status) * Zero overhead - no middleware, no MSW, no scenario infrastructure * Your app runs exactly as it would without Scenarist In production, `createScenarist()` also returns `undefined` even if you accidentally deploy with `enabled: true`: Next.js builds resolve the `production` export condition, and the Express adapter checks `NODE_ENV`. This keeps scenario switching infrastructure **out of production**. See [Production Safety](/concepts/production-safety/). [Learn more about ephemeral endpoints →](/reference/ephemeral-endpoints/) ## Runtime Scenario Switching Traditional end-to-end tests cannot switch external API behavior at runtime. Testing different scenarios (premium vs free users, error states) typically requires separate deployments, complex data setup, or conditional logic in application code. Scenarist addresses this through runtime scenario switching using test identifiers:
```typescript
// Define multiple scenarios
const scenarios = {
premium: {
/* premium tier mocks */
},
free: {
/* free tier mocks */
},
error: {
/* error state mocks */
},
} as const satisfies ScenaristScenarios;
// Tests run concurrently
test("premium features", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// Test with premium scenario
});
test("free features", async ({ page, switchScenario }) => {
await switchScenario(page, "free");
// Test with free scenario - runs simultaneously
});
```
### How Test Isolation Works: Complete Request Flow Here’s how two tests run in parallel with different scenarios, showing the complete journey from scenario setup through multiple requests: **The test isolation mechanism:** 1. **Each test gets a unique ID** (generated automatically) 2. **Test switches scenario once** via `POST /__scenario__` with its test ID 3. **All subsequent requests** include the test ID in headers (`x-scenarist-test-id: abc-123`) 4. **Scenarist routes based on test ID** - same URL, different responses per test 5. **Scenario persists** for the entire test journey (dashboard → checkout → confirmation) 6. **Tests run in parallel** - Test 1 and Test 2 execute simultaneously without affecting each other This enables: * ✅ **Unlimited scenarios** - Test premium, free, errors, edge cases all in parallel * ✅ **No interference** - Each test isolated by unique test ID * ✅ **One backend server** - All tests share same server instance * ✅ **Real HTTP execution** - Your middleware, routing, and logic run normally * ✅ **Fast execution** - No expensive external API calls This enables parallel test execution without process coordination or port conflicts. ## Framework Independence Scenarist uses hexagonal architecture to maintain framework independence. The core has no web framework dependencies. Benefits: * Scenario definitions work across all frameworks * Framework-specific adapters handle integration * Switching frameworks doesn’t require rewriting scenarios Supported frameworks: Express and Next.js (Pages and App Router). Additional adapters planned. [Learn about the architecture →](/concepts/architecture/) ## Next Steps * [Dynamic Capabilities →](/scenarios/overview/) - Request matching, sequences, stateful mocks * [Scenario Format →](/scenarios/basic-structure/) - Complete scenario structure reference * [Framework Guides →](/frameworks/express/getting-started/) - Integrating with your framework * [Architecture Details →](/concepts/architecture/) - Deep dive into hexagonal architecture
# Testing Philosophy
> Test behavior, not implementation - the core principle behind Scenarist
Scenarist is built on a core testing principle: **test behavior, not implementation**. This philosophy shapes everything about how you write scenarios and structure your tests. ## Test Behavior, Not Implementation Traditional testing often focuses on implementation details—mocking internal functions, spying on method calls, verifying that specific code paths execute. This creates **fragile tests** that break whenever you refactor, even when the external behavior stays exactly the same. Behavior-focused testing asks a different question: **“What does the user experience?”** * Implementation (Fragile)
```typescript
// Tests are coupled to internal structure
it('should call stripe.charges.create with correct params', () => {
const stripeMock = jest.spyOn(stripe.charges, 'create');
await processPayment({ amount: 100 });
expect(stripeMock).toHaveBeenCalledWith({
amount: 10000,
currency: 'usd',
source: expect.any(String),
});
});
```
**Problems:** * Breaks if you switch from `stripe.charges.create` to `stripe.paymentIntents.create` * Breaks if you add a wrapper function * Tests internal structure, not user experience * Behavior (Robust)
```typescript
// Tests describe user-visible outcomes
it('displays success message after valid payment', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-success');
await page.goto('/checkout');
await page.fill('[name="card"]', '4242424242424242');
await page.click('button[type="submit"]');
await expect(page.locator('.success')).toContainText('Payment complete');
});
```
**Benefits:** * Tests what users actually see * Survives internal refactoring * Documents expected behavior The shift from implementation to behavior testing changes how you think: | Instead of asking… | Ask… | | ----------------------------------- | ----------------------------------- | | “Did the Stripe SDK get called?” | “Can users complete a purchase?” | | “Was the auth token validated?” | “Are unauthorized users blocked?” | | “Did the email service return 200?” | “Does the user see a confirmation?” | Your tests become **documentation of expected behavior**, not a specification of internal mechanisms. ## Why Declarative Patterns Matter Scenarist enforces **declarative scenario definitions**—you describe **what** should happen, not **how** to make it happen. This isn’t an arbitrary constraint; it’s fundamental to maintainable testing.
```typescript
// ✅ Declarative - describes what response to return
const paymentSuccessMock = {
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_123', status: 'succeeded' },
},
};
// ❌ Imperative - describes how to generate response
server.use('/api/charges', (req, res) => {
const amount = req.body.amount;
if (amount > 10000) {
return res.status(402).json({ error: 'amount_too_large' });
}
return res.status(200).json({ id: generateId(), status: 'succeeded' });
});
```
Inspectable Declarative scenarios are data. You can see exactly what response will be returned—no tracing through conditionals. Composable Add request matching, sequences, or state capture without rewriting procedural logic. ### Constraints Guide Better Design Scenarist deliberately prevents functions in scenarios. When you’re tempted to write `if (req.something)`, the constraint forces you to ask: “What pattern am I actually trying to express?” | System | Constraint | What It Forces | | ------------- | ----------------------------- | ------------------------------------------ | | SQL | No procedural loops | Think in set operations | | React | No imperative DOM updates | Think in component composition | | **Scenarist** | **No functions in scenarios** | **Think in match/sequence/state patterns** | The answer is usually one of: * **Match criteria** — Different responses based on request content * **Sequences** — Ordered progression through states * **State capture** — Values that flow between requests These patterns are explicit, composable, and debuggable. ## Applying the Philosophy ### Start with Behavior Questions When writing tests, ask: 1. **What does the user see?** Focus on visible outcomes, not internal state. 2. **What scenarios matter?** List the external conditions that affect behavior. 3. **What could go wrong?** Error cases are often more important than happy paths. ### Think in Scenarios, Not Mocks Don’t think of definitions as “mocking Stripe”—think of them as **describing scenarios**:
```typescript
// ❌ Thinking in mocks
const stripeMock = { /* ... */ };
// ✅ Thinking in scenarios
const scenarios = {
'checkout-success': { /* describes what happens */ },
'card-declined': { /* describes what happens */ },
'stripe-timeout': { /* describes what happens */ },
} as const satisfies ScenaristScenarios;
```
Scenario names describe **business outcomes**, not technical implementations. ### Keep Scenarios Focused Each scenario should test **one thing**:
```typescript
// ❌ Kitchen sink scenario
const scenarios = {
'everything': {
id: 'everything',
mocks: [
{ /* stripe success */ },
{ /* auth success */ },
{ /* email success */ },
],
},
};
// ✅ Focused scenarios
const scenarios = {
'payment-success': { /* only stripe */ },
'payment-declined': { /* only stripe, declined */ },
'email-failure': { /* stripe success, email failure */ },
} as const satisfies ScenaristScenarios;
```
Focused scenarios make failures clear: when `payment-declined` fails, you know it’s a payment handling issue. ## Next Steps * [Quick Start](/getting-started/quick-start/) — Choose your framework and get started * [Scenario Format](/scenarios/basic-structure/) — All the declarative patterns available * [Dynamic Capabilities](/scenarios/overview/) — Request matching, sequences, stateful mocks
# Writing Scenarios Overview
> Learn how to write Scenarist scenarios with all available features
Scenarist scenarios are **declarative TypeScript objects** that describe what mock responses to return. This guide helps you navigate the scenario features and choose the right ones for your tests. ## What Scenarios Can Do | Feature | What It Enables | When to Use | | ------------------------------------------------------ | ---------------------------------------------------------------- | --------------------------------------------------------------- | | [Basic Structure](/scenarios/basic-structure/) | Define mock responses for HTTP requests | Every scenario needs this | | [Request Matching](/scenarios/request-matching/) | Return different responses for same URL based on request content | Different responses for different users, tiers, or request data | | [Pattern Matching](/scenarios/pattern-matching/) | Match using regex, contains, startsWith, endsWith | Campaign codes, user agents, email domains, dynamic values | | [Response Sequences](/scenarios/response-sequences/) | Return different responses on successive calls | Polling, async job status, multi-step workflows | | [Stateful Mocks](/scenarios/stateful-mocks/) | Capture data from requests, inject into later responses | Shopping carts, user profiles, accumulated data | | [Default Scenarios](/scenarios/default-scenarios/) | Define baseline mocks, override only what changes | DRY scenarios, avoid duplication | | [Combining Features](/scenarios/combining-features/) | Use all features together | Complex realistic workflows | | [TypeScript Patterns](/scenarios/typescript-patterns/) | Type-safe scenario definitions | Autocomplete, compile-time errors | ## Which Feature Do I Need? **Start here:** Every scenario needs the [Basic Structure](/scenarios/basic-structure/) - this defines your mocks with method, URL, and response. Then ask yourself: ### “I need different responses for the same URL” **Based on request content?** Use [Request Matching](/scenarios/request-matching/) * Different pricing for premium vs standard users * Different responses based on API version header * Different data based on query parameters **Based on how many times it’s called?** Use [Response Sequences](/scenarios/response-sequences/) * Job status: pending → processing → complete * Polling patterns * Retry scenarios ### “I need to match dynamic values” Use [Pattern Matching](/scenarios/pattern-matching/) for: * Campaign codes: `x-campaign: summer-premium-2024` * User agents: `Mobile`, `Chrome`, `Safari` * Email domains: `@company.com` * File extensions: `.pdf`, `.jpg` ### “I need responses to reflect earlier requests” Use [Stateful Mocks](/scenarios/stateful-mocks/) for: * Shopping cart that accumulates items * User profile that reflects submitted data * Session data that persists across requests ### “I’m duplicating mocks across scenarios” Use [Default Scenarios](/scenarios/default-scenarios/) to: * Define happy path once in ‘default’ * Override only what changes in specialized scenarios * Keep scenarios DRY ### “I need all of the above” See [Combining Features](/scenarios/combining-features/) for examples of using multiple features together. ## Quick Example A minimal scenario with all required fields:
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
export const myScenario: ScenaristScenario = {
id: 'my-scenario', // Unique identifier
name: 'My Scenario', // Human-readable name
description: 'What this scenario tests',
mocks: [
{
method: 'GET',
url: 'https://api.example.com/user',
response: {
status: 200,
body: { id: 1, name: 'Test User' },
},
},
],
};
```
## Why Declarative? Scenarist enforces **declarative patterns** - scenarios describe WHAT to return, not HOW to decide. No imperative functions with hidden if/else logic. This leads to: 1. **Visible intent** - Match criteria show what matters 2. **Composable features** - Specificity-based selection, automatic fallback 3. **Testable scenarios** - Can validate statically with TypeScript [Learn more about the philosophy →](/concepts/philosophy/#3-declarative-beats-imperative-for-test-setup) ## Next Steps * [Basic Structure →](/scenarios/basic-structure/) - Start with the fundamentals * [Request Matching →](/scenarios/request-matching/) - Different responses for same URL * [Response Sequences →](/scenarios/response-sequences/) - Polling and async workflows
# Basic Structure
> Foundational scenario definition with mocks, methods, URLs, and responses
## What This Enables Define HTTP mock responses that intercept requests to external APIs during tests. Every scenario needs this foundational structure. **Use cases:** * Mock any external API (Stripe, GitHub, Auth0, etc.) * Return controlled responses during tests * Define once, use across all tests ## Scenario Structure Every scenario requires these fields:
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
export const myScenario: ScenaristScenario = {
id: 'my-scenario', // Unique identifier (required)
name: 'My Scenario', // Human-readable name (required)
description: 'What this scenario represents', // Documentation (required)
mocks: [ // Array of mock definitions (required)
{
method: 'GET',
url: 'https://api.example.com/user',
response: {
status: 200,
body: { id: 1, name: 'Test User' },
},
},
],
};
```
### Required Fields | Field | Type | Description | | ------------- | -------- | -------------------------------------------------------- | | `id` | `string` | Unique identifier used when switching scenarios in tests | | `name` | `string` | Human-readable name for documentation and tooling | | `description` | `string` | Explains when and why to use this scenario | | `mocks` | `array` | Array of mock definitions | ## Mock Definition Each mock requires a `method`, `url`, and **one of** `response`, `sequence`, or `stateResponse`. ### Simple Response Mock
```typescript
{
method: 'GET', // HTTP method (required)
url: 'https://api.example.com/user', // URL pattern (required)
response: { // Single static response
status: 200,
body: { id: 1, name: 'Test User' },
headers: { 'x-custom': 'value' }, // Optional
delay: 1000, // Optional delay in ms
},
}
```
### Sequence Mock For multiple responses (polling, async operations), use `sequence` instead of `response`:
```typescript
{
method: 'GET',
url: 'https://api.example.com/job/:id',
sequence: { // Response sequence
responses: [ // Array of responses
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'complete' } },
],
repeat: 'last', // 'last' | 'cycle' | 'none'
},
}
```
See [Response Sequences →](/scenarios/response-sequences/) for details. ## HTTP Methods Supported methods: * `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`, `HEAD`
```typescript
{ method: 'GET', url: '...', response: {...} }
{ method: 'POST', url: '...', response: {...} }
{ method: 'PUT', url: '...', response: {...} }
{ method: 'DELETE', url: '...', response: {...} }
{ method: 'PATCH', url: '...', response: {...} }
```
## URL Patterns URLs support four matching styles: ### Exact Match
```typescript
url: 'https://api.example.com/users'
// Matches: https://api.example.com/users
// Doesn't match: https://api.example.com/users/123
```
### Path Parameters
```typescript
url: 'https://api.example.com/users/:id'
// Matches: https://api.example.com/users/123
// Matches: https://api.example.com/users/abc
```
### Multi-Segment Parameters
```typescript
url: 'https://api.example.com/users/:path+'
// Matches: https://api.example.com/users/123
// Matches: https://api.example.com/users/123/profile
```
Glob wildcards such as `/users/*` are not supported in mock URLs; use a repeating parameter (`:path+`) or a RegExp instead. ### Regular Expressions Use native JavaScript RegExp for complex URL matching:
```typescript
// Match any API version
url: /https:\/\/api\.example\.com\/v\d+\/users/
// Matches: https://api.example.com/v1/users
// Matches: https://api.example.com/v2/users
// Matches: https://api.example.com/v99/users
// Match numeric IDs only
url: /\/users\/\d+$/
// Matches: /users/123
// Matches: /users/456789
// Doesn't match: /users/abc
// Origin-agnostic matching (matches any host)
url: /\/api\/products$/
// Matches: http://localhost:3000/api/products
// Matches: https://api.example.com/api/products
```
RegExp uses **weak comparison** (partial matching), making it ideal for origin-agnostic patterns. See [Pattern Matching →](/scenarios/pattern-matching/) for advanced regex patterns. ## Response Structure Every response (single or in sequence) contains:
```typescript
response: {
status: 200, // HTTP status code (100-599, required)
body: { // Response body (any value, optional)
id: 1,
data: 'example',
},
headers: { // Response headers (string key-value pairs, optional)
'x-custom': 'value',
'x-request-id': 'abc123',
},
delay: 1000, // Delay in milliseconds (optional)
}
```
### Status Codes Any valid HTTP status code (100-599):
```typescript
// Success
{ status: 200, body: { success: true } }
{ status: 201, body: { id: 'created-123' } }
{ status: 204 } // No content
// Client errors
{ status: 400, body: { error: 'Bad Request' } }
{ status: 401, body: { error: 'Unauthorized' } }
{ status: 404, body: { error: 'Not Found' } }
// Server errors
{ status: 500, body: { error: 'Internal Server Error' } }
{ status: 503, body: { error: 'Service Unavailable' } }
```
### Response Body The `body` field accepts any JSON-serializable value:
```typescript
// Object
body: { id: 1, name: 'User', roles: ['admin', 'user'] }
// Array
body: [{ id: 1 }, { id: 2 }, { id: 3 }]
// Primitive
body: 'Success'
body: 42
body: true
// Null
body: null
```
### Response Headers Custom headers as string key-value pairs:
```typescript
headers: {
'content-type': 'application/json',
'x-request-id': 'req-123',
'x-ratelimit-remaining': '99',
}
```
### Response Delay Simulate network latency or slow responses:
```typescript
// Simulate 2-second API response
{
method: 'GET',
url: 'https://api.slow.com/data',
response: {
status: 200,
body: { data: 'result' },
delay: 2000, // 2 seconds
},
}
```
## Complete Example
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
export const defaultScenario: ScenaristScenario = {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed with valid responses',
mocks: [
// GitHub API - successful user lookup
{
method: 'GET',
url: 'https://api.github.com/users/:username',
response: {
status: 200,
body: {
login: 'octocat',
name: 'The Octocat',
public_repos: 8,
},
},
},
// Stripe API - successful payment
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
id: 'ch_123',
status: 'succeeded',
amount: 5000,
},
},
},
// SendGrid API - email sent
{
method: 'POST',
url: 'https://api.sendgrid.com/v3/mail/send',
response: {
status: 202,
body: { message_id: 'msg_123' },
},
},
],
};
```
## Next Steps * [Request Matching →](/scenarios/request-matching/) - Different responses for same URL * [Response Sequences →](/scenarios/response-sequences/) - Polling and async workflows * [Default Scenarios →](/scenarios/default-scenarios/) - DRY scenario patterns
# Default Scenarios
> Fallback behavior, override patterns, and DRY scenario definitions
## What This Enables Define baseline mocks once in a ‘default’ scenario, then create specialized scenarios that override only what changes. Automatic fallback eliminates duplication. **Use cases:** * **DRY scenarios:** Define common mocks once, reuse everywhere * **Partial overrides:** Only define what’s different in each scenario * **Error scenarios:** Override one API to fail, others fall back to success * **Clean test setup:** No duplicating happy-path mocks in every scenario ## When to Use Always use a default scenario to: * Define your happy path (all APIs succeed) * Provide baseline responses for all tests * Enable specialized scenarios to focus on what’s different ## The ‘default’ Scenario Requirement Every scenarios object **must have a ‘default’ key** (enforced via schema validation):
```typescript
import type { ScenaristScenarios } from '@scenarist/express-adapter';
export const scenarios = {
default: defaultScenario, // ✅ Required
success: successScenario,
error: errorScenario,
} as const satisfies ScenaristScenarios;
// ❌ WRONG - Missing 'default' key
export const scenarios = {
success: successScenario,
error: errorScenario,
} as const satisfies ScenaristScenarios;
// Error: Scenarios object must have a 'default' key
```
**Why ‘default’ is required:** 1. **Fallback behavior:** When no scenario is set, default is used 2. **Baseline mocks:** Provides common responses across all tests 3. **Clarity:** Makes baseline behavior obvious 4. **Safety:** Tests without explicit scenarios still work ## How Default Fallback Works When you switch to a specialized scenario, Scenarist collects the active scenario’s mocks for the request’s URL and method. If none of them is a fallback mock (a mock without `match` criteria), it also collects the default scenario’s mocks, then uses specificity-based selection.
```typescript
// Default scenario: All APIs succeed
export const defaultScenario: ScenaristScenario = {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed',
mocks: [
{ method: 'GET', url: 'https://api.github.com/users/:username',
response: { status: 200, body: { login: 'octocat' } } },
{ method: 'POST', url: 'https://api.stripe.com/v1/charges',
response: { status: 200, body: { status: 'succeeded' } } },
{ method: 'GET', url: 'https://api.weather.com/v1/:city',
response: { status: 200, body: { temp: 18 } } },
],
};
// Error scenario: Override only GitHub
export const githubErrorScenario: ScenaristScenario = {
id: 'github-error',
name: 'GitHub Error',
description: 'GitHub returns 404, everything else succeeds',
mocks: [
{ method: 'GET', url: 'https://api.github.com/users/:username',
response: { status: 404, body: { message: 'Not Found' } } },
// Stripe and Weather NOT defined → fall back to default
],
};
```
**When you switch to `github-error`:** * GitHub API → 404 (overridden by active scenario) * Stripe API → 200 (falls back to default) * Weather API → 200 (falls back to default) ## Partial Override (Not Full Replacement) Specialized scenarios only define **mocks they override**. Everything else falls back:
```typescript
// ❌ WITHOUT DEFAULT FALLBACK - Duplication hell
export const githubErrorScenario: ScenaristScenario = {
mocks: [
// Override GitHub
{ method: 'GET', url: 'https://api.github.com/...', response: { status: 500 } },
// Must duplicate Stripe (unchanged)
{ method: 'POST', url: 'https://api.stripe.com/...', response: { status: 200, body: {...} } },
// Must duplicate Weather (unchanged)
{ method: 'GET', url: 'https://api.weather.com/...', response: { status: 200, body: {...} } },
// ... 50 more unchanged APIs duplicated ...
],
};
// ✅ WITH DEFAULT FALLBACK - Only define what changes
export const githubErrorScenario: ScenaristScenario = {
mocks: [
// Only override what changes
{ method: 'GET', url: 'https://api.github.com/...', response: { status: 500 } },
// Everything else: default scenario automatically
],
};
```
## URL + Method Matching Overrides work at the URL + method level:
```typescript
// Default has both GET and POST for same base URL
export const defaultScenario: ScenaristScenario = {
mocks: [
{ method: 'GET', url: '/api/data', response: { status: 200, body: { data: 'default' } } },
{ method: 'POST', url: '/api/data', response: { status: 201, body: { created: true } } },
],
};
// Override only GET
export const customScenario: ScenaristScenario = {
mocks: [
{ method: 'GET', url: '/api/data', response: { status: 200, body: { data: 'custom' } } },
// POST not defined → falls back to default
],
};
// Result:
// GET /api/data → custom response (override)
// POST /api/data → default response (fallback)
```
## Specificity-Based Selection When both default and active scenarios have mocks for the same URL, specificity determines the winner: * Mocks with `match` criteria are more specific * More criteria = higher specificity * Most specific wins
```typescript
// Default: Simple fallback
mocks: [
{ method: 'POST', url: '/api/checkout',
response: { status: 200, body: { price: 100 } } } // Specificity: 0
]
// Active: Match premium users
mocks: [
{ method: 'POST', url: '/api/checkout',
match: { body: { tier: 'premium' } }, // Specificity: 1
response: { status: 200, body: { price: 80 } } }
]
// Request with tier='premium' → Active scenario (specificity 1 > 0)
// Request without tier → Default scenario (fallback)
```
## Active Fallbacks Replace Default Mocks When the active scenario has a fallback mock (no match criteria) for a URL and method, the default scenario’s mocks for that URL and method are not considered at all:
```typescript
// Default scenario
{ method: 'GET', url: '/api/data', response: { status: 200, body: { source: 'default' } } }
// Active scenario ← Wins (default's mock is not a candidate)
{ method: 'GET', url: '/api/data', response: { status: 200, body: { source: 'active' } } }
```
This allows active scenarios to override default fallbacks without needing match criteria. ## Complete Example
```typescript
import type { ScenaristScenarios } from '@scenarist/express-adapter';
export const scenarios = {
// Default: All APIs work (happy path)
default: {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed',
mocks: [
{ method: 'GET', url: 'https://api.github.com/users/:username',
response: { status: 200, body: { login: 'octocat' } } },
{ method: 'POST', url: 'https://api.stripe.com/v1/charges',
response: { status: 200, body: { status: 'succeeded' } } },
{ method: 'GET', url: 'https://api.weather.com/:city',
response: { status: 200, body: { temp: 18 } } },
],
},
// GitHub error - others fall back
githubError: {
id: 'github-error',
name: 'GitHub Not Found',
description: 'GitHub 404, Stripe and Weather work',
mocks: [
{ method: 'GET', url: 'https://api.github.com/users/:username',
response: { status: 404 } },
],
},
// Stripe error - others fall back
stripeError: {
id: 'stripe-error',
name: 'Payment Failed',
description: 'Stripe declines, GitHub and Weather work',
mocks: [
{ method: 'POST', url: 'https://api.stripe.com/v1/charges',
response: { status: 402, body: { error: 'Card declined' } } },
],
},
// Slow network - override all with delays
slowNetwork: {
id: 'slow-network',
name: 'Slow Network',
description: 'All APIs slow',
mocks: [
{ method: 'GET', url: 'https://api.github.com/users/:username',
response: { status: 200, delay: 2000, body: { login: 'octocat' } } },
{ method: 'POST', url: 'https://api.stripe.com/v1/charges',
response: { status: 200, delay: 1500, body: { status: 'succeeded' } } },
{ method: 'GET', url: 'https://api.weather.com/:city',
response: { status: 200, delay: 1000, body: { temp: 18 } } },
],
},
} as const satisfies ScenaristScenarios;
```
**Usage:** * No scenario switch → All APIs work (default) * `switchScenario('github-error')` → GitHub 404, Stripe/Weather work * `switchScenario('stripe-error')` → Stripe fails, GitHub/Weather work * `switchScenario('slow-network')` → All APIs slow ## When Default Is Used * Test doesn’t call `switchScenario()` * Test ID header is missing (manual testing) * Between test runs (before first scenario switch) ## Benefits Summary 1. **No Duplication:** Define common mocks once 2. **Clear Intent:** Specialized scenarios show exactly what changes 3. **Maintainability:** Update defaults, all scenarios benefit 4. **Safety:** Tests always have fallback behavior 5. **Flexibility:** Override as little or as much as needed ## Next Steps * [Basic Structure →](/scenarios/basic-structure/) - Scenario fundamentals * [Request Matching →](/scenarios/request-matching/) - Match within scenarios * [TypeScript Patterns →](/scenarios/typescript-patterns/) - Type-safe scenario definitions
# Combining Features
> Use request matching, sequences, stateful mocks, and state-aware mocking together
## What This Enables Combine all scenario features to create powerful, realistic test scenarios. Features work independently while maintaining their guarantees. **Use cases:** * Premium onboarding with progress tracking * User-specific workflows with state capture * Conditional sequences based on request content * Complex multi-step business processes * State machine workflows with automatic transitions ## Feature Combinations | Combination | What It Does | | --------------------------- | ----------------------------------------------- | | Matching + Sequences | Only matching requests advance the sequence | | Matching + State Capture | Capture different data based on request content | | Sequences + State Capture | Capture data as sequence progresses | | State-Aware + Matching | Mock selection based on accumulated state | | State-Aware + State Capture | Capture data that drives conditional responses | | All Features | Full workflow simulation with state machines | ## Matching + Sequences Only requests that match the criteria advance through the sequence:
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
const scenario: ScenaristScenario = {
id: 'premium-onboarding',
name: 'Premium Onboarding',
description: 'Premium users get onboarding sequence, others see upgrade message',
mocks: [
// Premium users advance through onboarding
{
method: 'GET',
url: '/api/onboarding/step',
match: {
headers: { 'x-tier': 'premium' }
},
sequence: {
responses: [
{ status: 200, body: { step: 1, message: 'Welcome!' } },
{ status: 200, body: { step: 2, message: 'Configure...' } },
{ status: 200, body: { step: 3, message: 'Complete!' } },
],
repeat: 'last',
},
},
// Standard users see upgrade message (no sequence)
{
method: 'GET',
url: '/api/onboarding/step',
response: {
status: 200,
body: { message: 'Upgrade to premium for onboarding' },
},
},
],
};
```
**Key insight:** Non-matching requests (standard users) don’t advance the premium sequence. The sequence position is preserved for the next matching request.
```plaintext
Premium request 1 → Step 1
Standard request → "Upgrade" message (sequence unchanged)
Premium request 2 → Step 2
Premium request 3 → Step 3
```
## Matching + State Capture different data based on request content:
```typescript
const scenario: ScenaristScenario = {
id: 'tiered-cart',
name: 'Tiered Shopping Cart',
description: 'Separate cart tracking for premium and standard items',
mocks: [
// Capture premium items
{
method: 'POST',
url: '/api/cart/add',
match: { body: { tier: 'premium' } },
captureState: {
'premiumItems[]': 'body.productId',
},
response: { status: 200, body: { added: true, tier: 'premium' } },
},
// Capture standard items
{
method: 'POST',
url: '/api/cart/add',
match: { body: { tier: 'standard' } },
captureState: {
'standardItems[]': 'body.productId',
},
response: { status: 200, body: { added: true, tier: 'standard' } },
},
// Cart shows both
{
method: 'GET',
url: '/api/cart',
response: {
status: 200,
body: {
premium: '{{state.premiumItems}}',
standard: '{{state.standardItems}}',
},
},
},
],
};
```
## Sequences + State Capture data as the sequence progresses:
```typescript
const scenario: ScenaristScenario = {
id: 'job-tracking',
name: 'Job Progress Tracking',
description: 'Capture progress through job sequence',
mocks: [
// Job status with progress capture
{
method: 'GET',
url: '/api/job/:id/status',
sequence: {
responses: [
{ status: 200, body: { status: 'queued', progress: 0 } },
{ status: 200, body: { status: 'running', progress: 50 } },
{ status: 200, body: { status: 'complete', progress: 100 } },
],
repeat: 'last',
},
captureState: {
lastStatus: 'body.status',
lastProgress: 'body.progress',
},
},
// Dashboard shows captured progress
{
method: 'GET',
url: '/api/dashboard',
response: {
status: 200,
body: {
jobStatus: '{{state.lastStatus}}',
jobProgress: '{{state.lastProgress}}',
},
},
},
],
};
```
**Note:** `captureState` captures from the **request**, not the response. To track sequence progress in state, include progress info in the request or use a separate tracking mechanism. ## All Three Together Complete example combining matching, sequences, and state:
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
export const premiumOnboardingScenario: ScenaristScenario = {
id: 'premium-onboarding-full',
name: 'Premium User Onboarding',
description: 'Multi-step onboarding with state and sequences for premium users',
mocks: [
// Premium users: Onboarding sequence with profile capture
{
method: 'POST',
url: '/api/onboarding',
match: {
headers: { 'x-tier': 'premium' }
},
sequence: {
responses: [
{ status: 200, body: { step: 1, message: 'Welcome premium user!' } },
{ status: 200, body: { step: 2, message: 'Set up your profile' } },
{ status: 200, body: { step: 3, message: 'You are all set!' } },
],
repeat: 'last',
},
captureState: {
'profileData.name': 'body.name',
'profileData.preferences[]': 'body.preference',
'completedSteps[]': 'body.stepNumber',
},
},
// Standard users: Simple upgrade prompt
{
method: 'POST',
url: '/api/onboarding',
response: {
status: 200,
body: { message: 'Upgrade to premium for full onboarding' },
},
},
// Dashboard: Shows captured profile and progress
{
method: 'GET',
url: '/api/dashboard',
response: {
status: 200,
body: {
profile: {
name: '{{state.profileData.name}}',
preferences: '{{state.profileData.preferences}}',
},
onboarding: {
completedSteps: '{{state.completedSteps}}',
stepCount: '{{state.completedSteps.length}}',
},
},
},
},
],
};
```
**This enables:** 1. Premium header triggers premium onboarding sequence 2. Each step captures profile data from request 3. Standard users get upgrade message (don’t advance sequence) 4. Dashboard shows accumulated profile and progress 5. All isolated per test ID for parallel execution ## Real-World Workflow Example E-commerce checkout with tier-based pricing and order tracking:
```typescript
export const checkoutWorkflowScenario: ScenaristScenario = {
id: 'checkout-workflow',
name: 'Checkout Workflow',
description: 'Complete checkout with pricing tiers and order tracking',
mocks: [
// Add to cart - track items by tier
{
method: 'POST',
url: '/api/cart/add',
match: { body: { itemType: 'premium' } },
captureState: {
'cart.premiumItems[]': 'body.productId',
},
response: { status: 200, body: { added: true } },
},
{
method: 'POST',
url: '/api/cart/add',
captureState: {
'cart.standardItems[]': 'body.productId',
},
response: { status: 200, body: { added: true } },
},
// Checkout - premium users get discount
{
method: 'POST',
url: '/api/checkout',
match: { headers: { 'x-tier': 'premium' } },
captureState: {
orderId: 'body.orderId',
},
response: {
status: 200,
body: { discount: 20, orderId: '{{state.orderId}}' },
},
},
{
method: 'POST',
url: '/api/checkout',
captureState: {
orderId: 'body.orderId',
},
response: {
status: 200,
body: { discount: 0, orderId: '{{state.orderId}}' },
},
},
// Order status - sequence through fulfillment
{
method: 'GET',
url: '/api/order/:id/status',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'shipped' } },
{ status: 200, body: { status: 'delivered' } },
],
repeat: 'last',
},
},
// Order summary - shows cart contents and order
{
method: 'GET',
url: '/api/order/summary',
response: {
status: 200,
body: {
orderId: '{{state.orderId}}',
premiumItems: '{{state.cart.premiumItems}}',
standardItems: '{{state.cart.standardItems}}',
},
},
},
],
};
```
## State-Aware + Request Matching Use `match.state` with other match criteria for powerful state machines:
```typescript
const scenario: ScenaristScenario = {
id: 'approval-workflow',
name: 'Approval Workflow',
description: 'State-driven approval with role-based decisions',
mocks: [
// Approve from pending_review state (admin only)
{
method: 'POST',
url: '/api/application/decision',
match: {
state: { step: 'pending_review' },
body: { decision: 'approve' },
headers: { 'x-role': 'admin' }
},
response: { status: 200, body: { status: 'approved' } },
afterResponse: { setState: { step: 'approved' } }
},
// Reject from pending_review state (any reviewer)
{
method: 'POST',
url: '/api/application/decision',
match: {
state: { step: 'pending_review' },
body: { decision: 'reject' }
},
response: { status: 200, body: { status: 'rejected' } },
afterResponse: { setState: { step: 'rejected' } }
},
// Status endpoint with stateResponse
{
method: 'GET',
url: '/api/application/status',
stateResponse: {
default: { status: 200, body: { status: 'pending' } },
conditions: [
{ when: { step: 'pending_review' }, then: { status: 200, body: { status: 'in_review' } } },
{ when: { step: 'approved' }, then: { status: 200, body: { status: 'approved' } } },
{ when: { step: 'rejected' }, then: { status: 200, body: { status: 'rejected' } } }
]
}
}
]
};
```
**This enables:** 1. `match.state` determines which mock handles the decision 2. Additional match criteria (body, headers) add role-based logic 3. `afterResponse.setState` advances the workflow 4. `stateResponse` provides status based on accumulated state ## Best Practices 1. **Keep it focused:** Each scenario should test a specific workflow, not everything 2. **Use default scenario:** Define happy path in default, override only differences 3. **Document intent:** Use clear `name` and `description` fields 4. **Consider test isolation:** State is per-test-ID, so parallel tests are safe 5. **Choose the right tool:** * Use `captureState` + templates for data flow * Use `stateResponse` for conditional responses * Use `match.state` for state-driven mock routing * Use `sequence` when call counts are predictable ## Next Steps * [State-Aware Mocking →](/scenarios/state-aware-mocking/) - State-driven behavior * [Request Matching →](/scenarios/request-matching/) - Matching criteria details * [Response Sequences →](/scenarios/response-sequences/) - Sequence behavior * [Stateful Mocks →](/scenarios/stateful-mocks/) - State capture and injection * [Default Scenarios →](/scenarios/default-scenarios/) - DRY patterns
# Pattern Matching
> Flexible matching with regex, contains, startsWith, endsWith strategies
## What This Enables Match URLs and request values using flexible patterns instead of exact strings. Useful for dynamic URLs, campaign codes, user agents, email domains, and file extensions. **Use cases:** * Version-agnostic API matching: `/api/v1/...`, `/api/v2/...` * Origin-agnostic URL patterns: Match any host * Marketing campaigns: `x-campaign: summer-premium-2024` * User agent detection: Mobile vs Desktop * Email domain filtering: `@company.com` * File type validation: `.pdf`, `.jpg` ## When to Use Use pattern matching when: * URL contains variable parts (API versions, numeric IDs) * You need origin-agnostic URL matching (any host) * Values contain variable parts (IDs, timestamps, campaigns) * You need substring matching (contains, prefix, suffix) * Multiple values should match the same mock (OR logic) * Exact string matching is too rigid ## URL Pattern Matching The mock’s `url` field accepts **native JavaScript RegExp** for flexible URL matching: ### Native RegExp (Recommended)
```typescript
// Match any API version
{
method: 'GET',
url: /https:\/\/api\.example\.com\/v\d+\/users/,
response: { status: 200, body: { users: [] } }
}
// Matches: https://api.example.com/v1/users ✓
// Matches: https://api.example.com/v2/users ✓
// Matches: https://api.example.com/v99/users ✓
```
### Origin-Agnostic Patterns RegExp uses **weak comparison** (partial matching), making it perfect for matching URLs regardless of host:
```typescript
// Match any origin
{
method: 'GET',
url: /\/api\/products$/,
response: { status: 200, body: { products: [] } }
}
// Matches: http://localhost:3000/api/products ✓
// Matches: https://api.example.com/api/products ✓
// Matches: https://staging.myapp.io/api/products ✓
```
### Common URL Patterns
```typescript
// Numeric IDs only
url: /\/users\/\d+$/
// Matches: /users/123, /users/456789
// Doesn't match: /users/abc, /users/
// Any path segment
url: /\/api\/[^/]+\/items/
// Matches: /api/v1/items, /api/beta/items
// Multiple path params
url: /\/users\/\d+\/posts\/\d+/
// Matches: /users/1/posts/42
```
### Case-Insensitive URL Matching
```typescript
url: /\/api\/users/i // Note the 'i' flag
// Matches: /api/users, /API/USERS, /Api/Users
```
## Match Criteria URL Patterns In addition to the mock’s `url` field, you can use pattern matching in `match.url` for more refined control:
```typescript
{
method: 'GET',
url: 'https://api.github.com/users/:username', // Base pattern
match: {
url: /\/users\/\d+$/ // Only match numeric usernames
},
response: { status: 200, body: { type: 'numeric-user' } }
}
```
This is useful when you want path parameters for some cases but regex matching for others. ## Value Matching Strategies Scenarist provides **7 matching strategies** that work in URL, body, headers, and query: | Strategy | Syntax | Behavior | | ---------------- | ---------------------------------------------- | ----------------------------------- | | Plain String | `'value'` | Exact match (default) | | Native RegExp | `/pattern/flags` | Pattern match (recommended for URL) | | Equals | `{ equals: 'value' }` | Explicit exact match | | Contains | `{ contains: 'substring' }` | Value contains substring | | Starts With | `{ startsWith: 'prefix' }` | Value starts with prefix | | Ends With | `{ endsWith: 'suffix' }` | Value ends with suffix | | Serialized Regex | `{ regex: { source: 'pattern', flags: 'i' } }` | JSON-safe regex pattern | ## Strategy Examples ### Native RegExp Use native JavaScript RegExp for pattern matching (works in `url` and `match.url`):
```typescript
// In mock url field
url: /\/api\/v\d+\/users/
// In match.url field
match: {
url: /\/users\/\d+$/
}
```
### Contains Match values containing a substring:
```typescript
match: {
headers: {
'user-agent': { contains: 'Mobile' }
}
}
// Matches: 'Mozilla/5.0 (iPhone; Mobile)' ✓
// Matches: 'Mobile Safari' ✓
// Doesn't match: 'Chrome Desktop' ✗
```
### Starts With Match values with a prefix:
```typescript
match: {
body: {
apiKey: { startsWith: 'sk_' }
}
}
// Matches: 'sk_live_abc123' ✓
// Matches: 'sk_test_xyz789' ✓
// Doesn't match: 'pk_live_abc123' ✗
```
### Ends With Match values with a suffix:
```typescript
match: {
body: {
filename: { endsWith: '.pdf' }
}
}
// Matches: 'report.pdf' ✓
// Matches: 'invoice_2024.pdf' ✓
// Doesn't match: 'document.docx' ✗
```
### Serialized Regex For JSON-safe scenarios (stored in files or databases), use serialized regex:
```typescript
match: {
headers: {
'x-campaign': {
regex: { source: 'premium|vip|exclusive', flags: 'i' }
}
}
}
// Matches: 'summer-premium-sale' ✓
// Matches: 'early-VIP-access' ✓ (case-insensitive)
// Matches: 'exclusive-members-2024' ✓
// Doesn't match: 'standard-sale' ✗
```
## Where Strategies Apply All strategies work in: * ✅ **Mock URL** (`url` field) - Native RegExp only * ✅ **Match URL** (`match.url`) - All strategies * ✅ **Request Body** (`match.body`) - All strategies * ✅ **Request Headers** (`match.headers`) - All strategies * ✅ **Query Parameters** (`match.query`) - All strategies
```typescript
{
method: 'POST',
url: /\/api\/v\d+\/products/, // Native RegExp in url
match: {
url: { contains: '/featured' }, // Strategy in match.url
body: {
email: { contains: '@company.com' },
apiKey: { startsWith: 'sk_' },
},
headers: {
'user-agent': { contains: 'Mobile' },
'referer': { endsWith: '/checkout' },
},
query: {
category: { regex: { source: '^(tech|science)$', flags: 'i' } },
}
},
response: { status: 200, body: { ... } }
}
```
## Regex Reference ### Native RegExp Syntax
```typescript
// In url field or match.url
url: /pattern/flags
// Examples
url: /\/api\/users\/\d+/ // No flags
url: /\/api\/users/i // Case-insensitive
```
### Serialized Regex Syntax
```typescript
// In match.body, match.headers, match.query, or match.url
{
regex: {
source: 'pattern', // Regex pattern (without delimiters)
flags: 'i' // Optional flags
}
}
```
### Supported Flags | Flag | Name | Description | | ---- | ---------------- | ---------------------------------------- | | `i` | Case-insensitive | Most common - matches regardless of case | | `m` | Multiline | `^` and `$` match line boundaries | | `s` | Dotall | `.` matches newlines | | `u` | Unicode | Enables Unicode features | | `v` | Unicode sets | Enhanced Unicode support |
```typescript
// Case-insensitive (most common)
{ regex: { source: 'premium|vip', flags: 'i' } }
// Multiple flags
{ regex: { source: '/api/v\\d+/', flags: 'im' } }
```
### Common Patterns **Alternatives (OR logic):**
```typescript
{ regex: { source: 'premium|vip|enterprise', flags: 'i' } }
```
**Exact match from options:**
```typescript
{ regex: { source: '^(tech|science|health)$', flags: 'i' } }
```
**Numeric patterns:**
```typescript
// Version numbers (v1, v2, v3)
{ regex: { source: 'v\\d+', flags: '' } }
// Semver (1.2.3)
{ regex: { source: '^\\d+\\.\\d+\\.\\d+$', flags: '' } }
```
**Email domains:**
```typescript
{ regex: { source: '@(gmail|yahoo|outlook)\\.com$', flags: 'i' } }
```
**File extensions:**
```typescript
{ regex: { source: '\\.(jpg|png|gif|webp)$', flags: 'i' } }
```
## Real-World Examples ### Marketing Campaigns
```typescript
import type { ScenaristMock } from '@scenarist/express-adapter';
const campaignMock: ScenaristMock = {
method: 'GET',
url: '/api/products',
match: {
headers: {
'x-campaign': {
regex: { source: 'premium|vip|exclusive', flags: 'i' }
}
}
},
response: {
status: 200,
body: { pricing: 'premium', discount: 25 }
}
};
```
### Mobile Detection
```typescript
const mobileMock: ScenaristMock = {
method: 'GET',
url: '/api/config',
match: {
headers: {
'user-agent': {
regex: { source: '(iPhone|iPad|Android)', flags: 'i' }
}
}
},
response: {
status: 200,
body: { layout: 'mobile', features: ['touch', 'swipe'] }
}
};
```
### Referer Patterns
```typescript
const checkoutMock: ScenaristMock = {
method: 'POST',
url: '/api/checkout',
match: {
headers: {
'referer': {
regex: { source: '/checkout/(confirm|review)', flags: '' }
}
}
},
response: { status: 200, body: { allowCheckout: true } }
};
```
### Email Domain Filtering
```typescript
const emailMock: ScenaristMock = {
method: 'GET',
url: '/api/search',
match: {
query: {
email: {
regex: { source: '@(gmail|yahoo|outlook)\\.com$', flags: 'i' }
}
}
},
response: { status: 200, body: { provider: 'common-email' } }
};
```
## Security: ReDoS Protection Scenarist validates all serialized regex patterns for **ReDoS (Regular Expression Denial of Service)** vulnerabilities:
```typescript
// ✅ SAFE - Simple alternation
{ regex: { source: 'premium|vip', flags: 'i' } }
// ✅ SAFE - Character classes
{ regex: { source: '[A-Z]{3}-\\d{4}', flags: '' } }
// ❌ REJECTED - Catastrophic backtracking risk
{ regex: { source: '(a+)+b', flags: '' } }
// Error: Regex pattern is unsafe (ReDoS vulnerability detected)
```
**Protection mechanisms:** * Pattern validation using `redos-detector` before scenario registration * Unsafe patterns rejected immediately with clear error messages * No runtime regex compilation for invalid patterns ## Combining with Other Matching Pattern matching combines with other match criteria (AND logic):
```typescript
match: {
body: {
itemType: { contains: 'premium' }, // Pattern matching
category: 'electronics', // Exact matching
},
headers: {
'x-campaign': { regex: { source: 'summer|winter', flags: 'i' } },
'x-region': 'eu', // Exact matching
}
}
// ALL criteria must match
```
## Next Steps * [Request Matching →](/scenarios/request-matching/) - Basic matching concepts * [Response Sequences →](/scenarios/response-sequences/) - Combine patterns with sequences * [Combining Features →](/scenarios/combining-features/) - Use all features together
# Request Matching
> Return different responses based on request body, headers, and query parameters
## What This Enables Return different responses for the same URL based on request content. Multiple mocks can exist for the same URL, and Scenarist selects the most specific match. **Use cases:** * Different pricing for premium vs standard users * Different API responses based on version header * Different data based on query parameters * Tiered functionality based on request content ## When to Use Use request matching when: * Same endpoint returns different responses based on who’s calling * You need to test different request payloads * API behavior varies by header values (API version, user tier, locale) * Query parameters change the response ## Match Criteria Match on URL, request body, headers, or query parameters using the `match` field:
```typescript
import type { ScenaristMock } from '@scenarist/express-adapter';
const mock: ScenaristMock = {
method: 'POST',
url: '/api/checkout',
match: {
url: /\/checkout$/, // URL pattern (native RegExp)
body: { tier: 'premium' }, // Partial body match
headers: { 'x-api-version': 'v2' }, // Exact header match
query: { detailed: 'true' }, // Exact query param match
},
response: { status: 200, body: { discount: 20 } }
};
```
### URL Matching Match URLs using strings, RegExp, or pattern strategies:
```typescript
// Native RegExp (recommended)
match: {
url: /\/users\/\d+$/ // Match numeric user IDs only
}
// String strategies
match: {
url: { contains: '/api/v2/' } // URL contains substring
url: { startsWith: 'https://' } // URL starts with prefix
url: { endsWith: '/checkout' } // URL ends with suffix
}
```
This is useful when your mock’s `url` field uses path parameters but you need finer control:
```typescript
{
method: 'GET',
url: 'https://api.github.com/users/:username', // Accepts any username
match: {
url: /\/users\/\d+$/ // But only match numeric usernames
},
response: { status: 200, body: { type: 'numeric-user' } }
}
```
### Body Matching (Partial) Body matching is **partial** - only specified fields must match. The request can have additional fields:
```typescript
match: {
body: { itemType: 'premium' } // Only checks itemType field
}
// Matches these requests:
// { itemType: 'premium', quantity: 5, color: 'red' } ✓
// { itemType: 'premium' } ✓
// { itemType: 'standard' } ✗
```
### Header Matching (Exact) Header matching is **exact** for specified keys:
```typescript
match: {
headers: {
'x-user-tier': 'premium',
'x-region': 'eu',
}
}
// Request must have these headers with exact values
```
### Query Parameter Matching (Exact) Query parameter matching is **exact** for specified keys:
```typescript
match: {
query: {
detailed: 'true',
units: 'metric',
}
}
// Request must have these query params with exact values
```
### Combined Matching All criteria must match (AND logic):
```typescript
match: {
body: { itemType: 'premium' },
headers: { 'x-user-tier': 'gold' },
query: { region: 'us' },
}
// All three must match for this mock to be selected
```
## Specificity-Based Selection When multiple mocks match the same URL, Scenarist uses **specificity scoring** to choose the best match: * URL match = +1 point * Each body field = +1 point * Each header = +1 point * Each query param = +1 point * Each state key = +1 point * No match criteria = 0 points (fallback) **Most specific mock wins**, regardless of order.
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
const scenario: ScenaristScenario = {
id: 'tiered-pricing',
name: 'Tiered Pricing',
description: 'Different pricing based on specificity',
mocks: [
// Specificity: 2 (body.tier + body.category)
{
method: 'POST',
url: '/api/products',
match: {
body: { tier: 'premium', category: 'electronics' }
},
response: { status: 200, body: { discount: 30 } }
},
// Specificity: 1 (body.tier only)
{
method: 'POST',
url: '/api/products',
match: {
body: { tier: 'premium' }
},
response: { status: 200, body: { discount: 20 } }
},
// Specificity: 0 (no match criteria, fallback)
{
method: 'POST',
url: '/api/products',
response: { status: 200, body: { discount: 10 } }
}
]
};
// Request with tier='premium' and category='electronics'
// → Returns 30% discount (specificity 2 wins)
// Request with tier='premium' only
// → Returns 20% discount (specificity 1 wins)
// Request with neither
// → Returns 10% discount (fallback)
```
## OR Logic Since match criteria use AND logic, implement OR logic with separate mocks:
```typescript
mocks: [
// Mock 1: Premium users
{
method: 'GET',
url: '/api/products',
match: { headers: { 'x-tier': 'premium' } },
response: { status: 200, body: { pricing: 'discounted' } }
},
// Mock 2: VIP users (OR - separate mock)
{
method: 'GET',
url: '/api/products',
match: { headers: { 'x-tier': 'vip' } },
response: { status: 200, body: { pricing: 'discounted' } }
},
// Fallback: Standard users
{
method: 'GET',
url: '/api/products',
response: { status: 200, body: { pricing: 'standard' } }
}
]
```
For OR logic within a single field, use regex in [Pattern Matching →](/scenarios/pattern-matching/):
```typescript
match: {
headers: {
'x-tier': { regex: { source: '^(premium|vip|enterprise)$', flags: '' } }
}
}
// Matches x-tier='premium' OR 'vip' OR 'enterprise' in one mock
```
## Tiebreaker Rules When multiple mocks have **equal specificity**: **Mocks with match criteria (specificity > 0):** First match wins
```typescript
mocks: [
// Both have specificity: 1
{
match: { body: { type: 'premium' } },
response: { body: { discount: 20 } }, // ← Wins (first)
},
{
match: { body: { type: 'premium' } },
response: { body: { discount: 15 } },
},
]
```
**Fallback mocks (specificity = 0):** Last match wins
```typescript
// Two fallback mocks for the same endpoint in one scenario
mocks: [
{ response: { body: { tier: 'standard' } } },
{ response: { body: { tier: 'premium' } } }, // ← Wins (last)
]
```
Active scenarios do not rely on this to override the default: an active scenario’s fallback mock excludes the default scenario’s mocks for that endpoint. ## Mock Type Priority When comparing fallback mocks (no match criteria), **dynamic response types have higher priority** than simple responses: | Mock Type | Fallback Priority | | --------------- | ----------------- | | `sequence` | 1 (higher) | | `stateResponse` | 1 (higher) | | `response` | 0 (lower) | This priority applies when fallback mocks compete within the same set of candidates. It does **not** stop an active scenario overriding the default: when the active scenario has a fallback mock for an endpoint, the default scenario’s mocks for that endpoint are not considered at all. ### Example: Overriding a Default Sequence
```typescript
// Default scenario has a sequence
const defaultScenario = {
mocks: [
{
method: 'GET',
url: '/api/job/status',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'complete' } }
],
repeat: 'last'
}
}
]
};
// Active scenario overrides with simple response
const activeScenario = {
mocks: [
{
method: 'GET',
url: '/api/job/status',
response: { status: 200, body: { status: 'error' } } // ✅ Overrides the default sequence
}
]
};
```
**Result:** Active’s `response` wins. Because the active scenario has a fallback mock for this endpoint, the default scenario’s `sequence` is not a candidate. ## Real-World Example
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
export const tieredPricingScenario: ScenaristScenario = {
id: 'tiered-pricing',
name: 'Tiered Pricing',
description: 'Different pricing based on user tier and item type',
mocks: [
// Premium users buying premium items - best discount
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
match: {
body: { itemType: 'premium' },
headers: { 'x-user-tier': 'gold' },
},
response: {
status: 200,
body: { amount: 7000, discount: 'gold_premium_30' },
},
},
// Premium items (any user)
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
match: { body: { itemType: 'premium' } },
response: {
status: 200,
body: { amount: 8000, discount: 'premium_20' },
},
},
// Standard items
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
match: { body: { itemType: 'standard' } },
response: {
status: 200,
body: { amount: 10000 },
},
},
// Fallback for other item types
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { amount: 5000 },
},
},
],
};
```
## Next Steps * [Pattern Matching →](/scenarios/pattern-matching/) - Regex and string patterns for flexible matching * [Response Sequences →](/scenarios/response-sequences/) - Combine matching with sequences * [Combining Features →](/scenarios/combining-features/) - Use matching with other features
# Response Sequences
> Multi-step responses for polling, async workflows, and state progression
## What This Enables Return different responses on successive calls to the same endpoint. Each request advances through a sequence of predefined responses. **Use cases:** * **Polling patterns:** Job status: pending → processing → complete * **Async workflows:** Payment: initiated → authorized → captured * **Rate limiting:** Allow N requests, then return 429 * **Retry scenarios:** Fail twice, succeed on third attempt ## When to Use Use response sequences when: * Behavior changes based on **number of calls** (not request content) * Testing polling or async job status * Simulating progressive workflows * Testing retry logic or rate limits **Not for request content differences** - use [Request Matching](/scenarios/request-matching/) instead. ## Basic Sequence Replace `response` with `sequence` containing an array of responses:
```typescript
import type { ScenaristMock } from '@scenarist/express-adapter';
const mock: ScenaristMock = {
method: 'GET',
url: '/api/job/status',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete' } }
],
repeat: 'last' // Options: 'last' | 'cycle' | 'none'
}
};
```
**Behavior:** 1. First request → `{ status: 'pending' }` 2. Second request → `{ status: 'processing' }` 3. Third request → `{ status: 'complete' }` 4. Fourth+ requests → `{ status: 'complete' }` (repeats last) ## Repeat Modes ### `repeat: 'last'` (Default) Repeat the final response indefinitely after sequence exhausts:
```typescript
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'complete' } }
],
repeat: 'last'
}
```
```plaintext
Call 1 → pending
Call 2 → complete
Call 3 → complete (repeats)
Call 4 → complete (repeats)
```
**Use for:** Most polling scenarios where final state persists. ### `repeat: 'cycle'` Loop back to the first response after sequence exhausts:
```typescript
sequence: {
responses: [
{ status: 200, body: { weather: 'sunny' } },
{ status: 200, body: { weather: 'cloudy' } },
{ status: 200, body: { weather: 'rainy' } }
],
repeat: 'cycle'
}
```
```plaintext
Call 1 → sunny
Call 2 → cloudy
Call 3 → rainy
Call 4 → sunny (cycles back)
Call 5 → cloudy
```
**Use for:** Rotating data, round-robin behavior. ### `repeat: 'none'` Sequence exhausts completely, allowing fallback to next mock:
```typescript
sequence: {
responses: [
{ status: 200, body: { attempt: 1 } },
{ status: 200, body: { attempt: 2 } },
{ status: 200, body: { attempt: 3 } }
],
repeat: 'none'
}
```
```plaintext
Call 1 → attempt 1
Call 2 → attempt 2
Call 3 → attempt 3
Call 4 → [Exhausted - falls through to next mock]
```
**Use for:** Rate limiting, limited-use tokens, finite sequences. ## Sequence with Fallback Combine `repeat: 'none'` with a fallback mock for rate limiting:
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
const scenario: ScenaristScenario = {
id: 'rate-limited',
name: 'Rate Limited API',
description: 'Allow 3 requests, then rate limit',
mocks: [
// First 3 requests succeed
{
method: 'POST',
url: '/api/payment',
sequence: {
responses: [
{ status: 200, body: { id: 'pay_1', status: 'pending' } },
{ status: 200, body: { id: 'pay_2', status: 'pending' } },
{ status: 200, body: { id: 'pay_3', status: 'succeeded' } },
],
repeat: 'none', // Exhausts after 3 calls
},
},
// Request 4+ hits this fallback
{
method: 'POST',
url: '/api/payment',
response: {
status: 429,
body: { error: 'Rate limit exceeded' },
},
},
],
};
```
## GitHub Job Polling Example
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
export const githubPollingScenario: ScenaristScenario = {
id: 'github-polling',
name: 'GitHub Job Polling',
description: 'Simulates async job progression',
mocks: [
{
method: 'GET',
url: 'https://api.github.com/repos/:owner/:repo/actions/runs/:id',
sequence: {
responses: [
{ status: 200, body: { status: 'queued', progress: 0 } },
{ status: 200, body: { status: 'in_progress', progress: 50 } },
{ status: 200, body: { status: 'completed', progress: 100 } },
],
repeat: 'last',
},
},
],
};
```
## Combining Sequences with Matching Sequences can be combined with [Request Matching](/scenarios/request-matching/):
```typescript
{
method: 'GET',
url: '/api/onboarding/step',
match: {
headers: { 'x-tier': 'premium' }
},
sequence: {
responses: [
{ status: 200, body: { step: 1, message: 'Welcome!' } },
{ status: 200, body: { step: 2, message: 'Configure...' } },
{ status: 200, body: { step: 3, message: 'Complete!' } }
],
repeat: 'last'
}
}
```
**Important:** Only **matching requests** advance the sequence. Non-matching requests don’t affect sequence position.
```plaintext
Request with x-tier: premium → Step 1
Request without x-tier header → [Doesn't match, doesn't advance]
Request with x-tier: premium → Step 2
Request with x-tier: premium → Step 3
```
## Retry Simulation Test retry logic by failing then succeeding:
```typescript
{
method: 'POST',
url: '/api/external-service',
sequence: {
responses: [
{ status: 503, body: { error: 'Service unavailable' } },
{ status: 503, body: { error: 'Service unavailable' } },
{ status: 200, body: { success: true } },
],
repeat: 'last'
}
}
// Call 1 → 503 (retry)
// Call 2 → 503 (retry)
// Call 3 → 200 (success)
// Call 4+ → 200 (stable)
```
## Sequence Reset Sequences reset when: * Test switches to a different scenario * New test starts (different test ID) Each test has isolated sequence state - parallel tests don’t affect each other’s sequence positions. ## Next Steps * [Request Matching →](/scenarios/request-matching/) - Combine sequences with matching * [Stateful Mocks →](/scenarios/stateful-mocks/) - Capture state as sequence progresses * [Combining Features →](/scenarios/combining-features/) - Use all features together
# State-Aware Mocking
> Conditional responses and state transitions for workflow testing
## What This Enables Build state machines where mock responses depend on accumulated state from previous requests. Perfect for testing workflows where the same endpoint returns different data based on what happened earlier. **Use cases:** * **Loan applications:** Status changes from “pending” → “reviewing” → “approved” based on form submissions * **Multi-step workflows:** Same GET returns different data after POSTs modify state * **Feature flags:** Toggle behavior via API, subsequent requests reflect the change * **Authentication flows:** Login sets state, protected endpoints check it ## The Problem It Solves [Response Sequences](/scenarios/response-sequences/) work when you can predict the exact number of calls:
```typescript
// This works IF you know there will be exactly 3 calls before the POST
sequence: {
responses: [
{ body: { status: 'pending' } },
{ body: { status: 'pending' } },
{ body: { status: 'pending' } },
{ body: { status: 'approved' } },
]
}
```
But with modern frontends (React re-renders, middleware, async timing), call counts are unpredictable. You might need 11 “pending” responses in one test and 15 in another. **State-aware mocking solves this:** Response changes based on **state**, not **call count**. ## Three Capabilities | Capability | Purpose | Category | | -------------------------------------------------------------------- | -------------------------------------------------- | ---------------------- | | [`stateResponse`](#state-driven-responses-stateresponse) | Return different responses based on current state | State-Driven Responses | | [`afterResponse.setState`](#state-transitions-afterresponsesetstate) | Mutate state after returning a response | State Transitions | | [`match.state`](#state-driven-matching-matchstate) | Select which mock handles a request based on state | State-Driven Matching | ## State-Driven Responses: `stateResponse` Return different responses from a single mock based on current test state. Use when one endpoint needs multiple possible responses depending on accumulated workflow state.
```typescript
import type { ScenaristMock } from '@scenarist/express-adapter';
const mock: ScenaristMock = {
method: 'GET',
url: '/api/application/status',
stateResponse: {
default: {
status: 200,
body: { status: 'pending', message: 'Application not yet submitted' }
},
conditions: [
{
when: { step: 'submitted' },
then: {
status: 200,
body: { status: 'reviewing', message: 'Under review' }
}
},
{
when: { step: 'reviewed' },
then: {
status: 200,
body: { status: 'approved', message: 'Application approved' }
}
}
]
}
};
```
**Behavior:** * If state is empty or has no matching condition → returns `default` response * If `state.step === 'submitted'` → returns “reviewing” response * If `state.step === 'reviewed'` → returns “approved” response ### Specificity-Based Selection When multiple conditions match, the most specific one wins (more keys = more specific):
```typescript
conditions: [
// Specificity: 1 (one key)
{ when: { step: 'reviewed' }, then: { body: { tier: 'basic' } } },
// Specificity: 2 (two keys) - wins when both match
{ when: { step: 'reviewed', urgent: true }, then: { body: { tier: 'priority' } } }
]
// State: { step: 'reviewed', urgent: true }
// → Returns 'priority' (2 keys beats 1 key)
```
## State Transitions: `afterResponse.setState` Mutate test state **after** returning a response. Use to advance workflow state when a request completes.
```typescript
{
method: 'POST',
url: '/api/application/submit',
response: {
status: 200,
body: { success: true, message: 'Submitted' }
},
afterResponse: {
setState: { step: 'submitted' }
}
}
```
**Behavior:** 1. Mock returns the response (`{ success: true }`) 2. **After** response is sent, state is updated (`step: 'submitted'`) 3. Subsequent requests see the new state ### Conditional afterResponse When using `stateResponse`, you can define condition-specific `afterResponse` to run different state mutations based on which condition matched:
```typescript
{
method: 'GET',
url: '/api/loan/status',
stateResponse: {
default: { status: 200, body: { status: 'pending' } },
conditions: [
{
when: { submitted: true },
then: { status: 200, body: { status: 'reviewing' } },
afterResponse: { setState: { phase: 'review' } } // Condition-specific
},
{
when: { approved: true },
then: { status: 200, body: { status: 'complete' } },
afterResponse: null // Explicitly no mutation
}
]
},
afterResponse: { setState: { phase: 'initial' } } // Fallback for default
}
```
**Resolution logic:** 1. If condition matched AND has `afterResponse` key → use condition’s (including `null`) 2. If condition matched AND has no `afterResponse` key → use mock-level afterResponse 3. If default matched → use mock-level afterResponse **Key insight:** `afterResponse: null` means “explicitly no state mutation” - different from omitting it (which inherits from mock-level). ### Works with Any Response Type `afterResponse.setState` combines with `response`, `sequence`, or `stateResponse`:
```typescript
// With sequence
{
method: 'POST',
url: '/api/verify',
sequence: {
responses: [
{ status: 200, body: { verified: false } },
{ status: 200, body: { verified: true } }
],
repeat: 'last'
},
afterResponse: {
setState: { verificationAttempted: true }
}
}
// With stateResponse
{
method: 'POST',
url: '/api/process',
stateResponse: {
default: { status: 200, body: { processed: false } },
conditions: [
{ when: { ready: true }, then: { status: 200, body: { processed: true } } }
]
},
afterResponse: {
setState: { processAttempted: true }
}
}
```
## State-Driven Matching: `match.state` Select which mock handles a request based on current state. Different from `stateResponse` (one mock, many responses) - this selects **which mock**.
```typescript
// Same endpoint, different mocks based on state
const mocks = [
// When step is 'initial' → transition to 'reviewed'
{
method: 'POST',
url: '/api/review',
match: { state: { step: 'initial' } },
response: { status: 200, body: { newStatus: 'pending_approval' } },
afterResponse: { setState: { step: 'reviewed' } }
},
// When step is 'reviewed' → transition to 'approved'
{
method: 'POST',
url: '/api/review',
match: { state: { step: 'reviewed' } },
response: { status: 200, body: { newStatus: 'approved' } },
afterResponse: { setState: { step: 'approved' } }
},
// Fallback (no state match) → transition to 'reviewed'
{
method: 'POST',
url: '/api/review',
response: { status: 200, body: { newStatus: 'pending_approval' } },
afterResponse: { setState: { step: 'reviewed' } }
}
];
```
**Use case:** Same endpoint needs completely different behavior (not just different response data) based on workflow state. ### Combined with Other Match Criteria `match.state` works with existing match criteria (AND logic):
```typescript
{
method: 'POST',
url: '/api/review',
match: {
state: { step: 'pending_review' },
body: { decision: 'approve' }
},
response: { status: 200, body: { status: 'approved' } },
afterResponse: { setState: { step: 'approved' } }
},
{
method: 'POST',
url: '/api/review',
match: {
state: { step: 'pending_review' },
body: { decision: 'reject' }
},
response: { status: 200, body: { status: 'rejected' } },
afterResponse: { setState: { step: 'rejected' } }
}
```
## Complete Example: Loan Application
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
export const loanApplicationScenario: ScenaristScenario = {
id: 'loan-application',
name: 'Loan Application Workflow',
description: 'State-aware loan workflow with automatic state transitions',
mocks: [
// GET status - responds based on workflow state
{
method: 'GET',
url: 'https://api.loans.com/application/status',
stateResponse: {
default: {
status: 200,
body: { status: 'pending', message: 'Not yet submitted' }
},
conditions: [
{
when: { step: 'submitted' },
then: {
status: 200,
body: { status: 'reviewing', message: 'Under review' }
}
},
{
when: { step: 'reviewed' },
then: {
status: 200,
body: { status: 'approved', message: 'Approved!' }
}
}
]
}
},
// POST submit - advances state to 'submitted'
{
method: 'POST',
url: 'https://api.loans.com/application/submit',
response: {
status: 200,
body: { success: true, message: 'Application submitted' }
},
afterResponse: {
setState: { step: 'submitted' }
}
},
// POST review - advances state to 'reviewed'
{
method: 'POST',
url: 'https://api.loans.com/application/review',
response: {
status: 200,
body: { success: true, message: 'Review completed' }
},
afterResponse: {
setState: { step: 'reviewed' }
}
}
]
};
```
**Test workflow:**
```typescript
test('loan application workflow', async ({ page, switchScenario }) => {
await switchScenario(page, 'loan-application');
// Initial state - pending
await page.goto('/application');
await expect(page.getByText('Not yet submitted')).toBeVisible();
// Submit form - advances state
await page.click('[data-action="submit"]');
// Now shows reviewing
await page.goto('/application');
await expect(page.getByText('Under review')).toBeVisible();
// Complete review - advances state again
await page.click('[data-action="review"]');
// Now shows approved
await page.goto('/application');
await expect(page.getByText('Approved!')).toBeVisible();
});
```
## Complete Example: Feature Flags
```typescript
export const featureFlagsScenario: ScenaristScenario = {
id: 'feature-flags',
name: 'Feature Flags',
description: 'Toggle features via API, subsequent requests reflect changes',
mocks: [
// Toggle feature flag - captures state
{
method: 'POST',
url: 'https://api.features.com/flags',
captureState: {
premiumEnabled: 'body.enabled'
},
response: {
status: 200,
body: { success: true, message: 'Flag updated' }
}
},
// GET pricing - premium when flag enabled (match.state)
{
method: 'GET',
url: 'https://api.pricing.com/pricing',
match: {
state: { premiumEnabled: true }
},
response: {
status: 200,
body: { tier: 'premium', price: 50, discount: '50% off' }
}
},
// GET pricing - standard (fallback)
{
method: 'GET',
url: 'https://api.pricing.com/pricing',
response: {
status: 200,
body: { tier: 'standard', price: 100 }
}
}
]
};
```
## When to Use What | Feature | Use When | | ------------------------ | --------------------------------------------------------------------- | | `stateResponse` | One endpoint, multiple possible responses based on accumulated state | | `afterResponse.setState` | Need to advance workflow state after a request | | `match.state` | Same endpoint needs completely different mock behavior based on state | | `captureState` | Need to capture request data for injection into responses | | `sequence` | Behavior depends on call count (predictable number of calls) | ## State vs Sequences | Aspect | State-Aware Mocking | Sequences | | ----------------- | ------------------------- | --------------------------- | | Changes based on | Accumulated state | Call count | | Predictable calls | Not required | Required | | Use case | Workflows, state machines | Polling with known count | | Resilience | Resilient to re-renders | Fragile with variable calls | **Rule of thumb:** If you find yourself padding sequences with extra responses “just in case,” switch to state-aware mocking. ## State Isolation State is isolated per test ID - parallel tests don’t interfere:
```typescript
// Test 1 (test-id: abc-123)
POST /submit → state.step = 'submitted'
GET /status → 'reviewing'
// Test 2 (test-id: xyz-789) - simultaneous
GET /status → 'pending' (independent state)
```
## State Reset State resets when: * Scenario switches (clean slate for new scenario) * A new test starts with a different test ID This ensures idempotent tests. ## Combining with Other Features State-aware mocking combines with all existing features:
```typescript
{
method: 'POST',
url: '/api/checkout',
match: {
state: { cartReady: true }, // State matching
headers: { 'x-tier': 'premium' } // Request matching
},
stateResponse: {
default: { status: 200, body: { discount: 10 } },
conditions: [
{ when: { loyaltyTier: 'gold' }, then: { status: 200, body: { discount: 20 } } }
]
},
afterResponse: {
setState: { checkoutComplete: true }
}
}
```
## State Model State is Shared Across ALL Endpoints State is stored in a **single flat object per test ID**, not namespaced by endpoint. This is intentional - it enables coordination between endpoints.
```plaintext
┌──────────────────────────────────────────────────┐
│ Test ID: test-checkout-123 │
│ ┌─────────────────────────────────────────────┐ │
│ │ Shared State │ │
│ │ { 'cart.items': 3, 'user.tier': 'premium' }│ │
│ └─────────────────────────────────────────────┘ │
│ ↑ write ↑ write ↓ read │
│ POST /cart/add POST /login GET /pricing │
└──────────────────────────────────────────────────┘
```
### Namespace Your Keys Since state is shared, use namespaced keys to avoid collisions:
```typescript
// ✅ DO: Namespace your keys by domain
afterResponse: { setState: { 'cart.itemCount': 3 } }
afterResponse: { setState: { 'user.authenticated': true } }
when: { 'cart.itemCount': 3, 'user.authenticated': true }
// ❌ DON'T: Use generic keys that could collide
afterResponse: { setState: { count: 3 } } // What count?
afterResponse: { setState: { status: 'active' } } // Which status?
```
### Why Not Per-Endpoint? The primary use case is **cross-endpoint coordination**:
```typescript
// POST /api/loan/submit sets state
{
method: 'POST',
url: '/api/loan/submit',
response: { status: 200 },
afterResponse: { setState: { 'loan.submitted': true } },
}
// GET /api/loan/status reads that state
{
method: 'GET',
url: '/api/loan/status',
stateResponse: {
default: { status: 200, body: { step: 'pending' } },
conditions: [
{ when: { 'loan.submitted': true }, then: { status: 200, body: { step: 'reviewing' } } },
],
},
}
```
If state were per-endpoint, this coordination pattern wouldn’t work. ## Debugging State When tests fail, you often need to inspect the current state to understand what went wrong. Scenarist provides debug tools for this. ### Debug Endpoint The Express adapter exposes a debug endpoint at `GET /__scenarist__/state`. In Next.js, create the route yourself with `createStateEndpoint()` (for example at `/api/__scenarist__/state`):
```bash
curl -H "x-scenarist-test-id: test-123" http://localhost:3000/__scenarist__/state
```
**Response:**
```json
{
"testId": "test-123",
"state": {
"cart.items": 3,
"user.tier": "premium",
"checkout.started": true
}
}
```
### Playwright Debug Fixtures For Playwright tests, use the `debugState` and `waitForDebugState` fixtures:
```typescript
import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState, waitForDebugState }) => {
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 }
// Wait for async state to stabilize
await page.click('#checkout');
const finalState = await waitForDebugState(
page,
(s) => s['checkout.status'] === 'complete',
{ timeout: 10000 }
);
expect(finalState['checkout.status']).toBe('complete');
});
```
**Available fixtures:** * `debugState(page)` - Fetch current state (no testId needed - fixture manages it) * `waitForDebugState(page, condition, options)` - Poll until condition is met **Configure the endpoint in playwright.config.ts:**
```typescript
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
scenaristStateEndpoint: '/__scenarist__/state', // Default value
},
});
```
## Next Steps * [Stateful Mocks →](/scenarios/stateful-mocks/) - Capture and inject request data * [Response Sequences →](/scenarios/response-sequences/) - Call-count based responses * [Request Matching →](/scenarios/request-matching/) - Match on request content * [Combining Features →](/scenarios/combining-features/) - Use all features together
# Stateful Mocks
> Capture state from requests and inject it into responses
## What This Enables Capture data from requests and inject it into subsequent responses. Build state over multiple requests that affects later responses. **Use cases:** * **Shopping cart:** Add items, cart endpoint shows accumulated items * **User profiles:** Submit data, profile endpoint reflects it * **Session data:** Capture authentication, use in later requests * **Form workflows:** Capture step data, show in confirmation ## When to Use Use stateful mocks when: * Later responses should reflect earlier request data * Testing multi-step workflows where data accumulates * Need to verify data flows through your application correctly **Not for call-count behavior** - use [Response Sequences](/scenarios/response-sequences/) instead. ## Basic State Capture and Injection
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
const scenario: ScenaristScenario = {
id: 'user-profile',
name: 'User Profile State',
description: 'Captures profile updates and injects into responses',
mocks: [
// Capture state from POST request
{
method: 'POST',
url: '/api/profile/update',
captureState: {
userName: 'body.name', // Capture from body.name
userEmail: 'body.email', // Capture from body.email
},
response: { status: 200, body: { success: true } }
},
// Inject state into GET response
{
method: 'GET',
url: '/api/profile',
response: {
status: 200,
body: {
name: '{{state.userName}}', // Inject captured value
email: '{{state.userEmail}}', // Inject captured value
}
}
}
]
};
```
**Workflow:** 1. POST to `/api/profile/update` with `{ name: 'Alice', email: 'alice@example.com' }` 2. Scenarist captures `userName='Alice'`, `userEmail='alice@example.com'` 3. GET to `/api/profile` returns `{ name: 'Alice', email: 'alice@example.com' }` ## State Capture Syntax ### Path Expressions Extract values from request body, headers, or query:
```typescript
captureState: {
userId: 'body.user.id', // Nested body field
email: 'headers.x-user-email', // Header value
region: 'query.region', // Query parameter
}
```
**Request example:**
```json
// Body: { "user": { "id": "usr_123", "name": "Alice" } }
// Headers: { "x-user-email": "alice@example.com" }
// Query: ?region=eu
// Captures:
// userId = 'usr_123'
// email = 'alice@example.com'
// region = 'eu'
```
### Array Append Syntax Build arrays over multiple requests using `[]` suffix:
```typescript
captureState: {
'cartItems[]': 'body.productId', // Appends to array
'tags[]': 'body.tag', // Another array
}
```
**Behavior across requests:**
```typescript
// Request 1: { productId: 'prod-1' }
// State: { cartItems: ['prod-1'] }
// Request 2: { productId: 'prod-2' }
// State: { cartItems: ['prod-1', 'prod-2'] }
// Request 3: { productId: 'prod-3' }
// State: { cartItems: ['prod-1', 'prod-2', 'prod-3'] }
```
### Nested Path Support Capture deeply nested values:
```typescript
captureState: {
userName: 'body.user.profile.displayName',
billingCountry: 'body.payment.address.country',
}
```
## State Injection Syntax ### Template Syntax Inject captured state into responses using `{{state.key}}`:
```typescript
response: {
status: 200,
body: {
user: '{{state.userId}}', // Scalar value
items: '{{state.cartItems}}', // Array
count: '{{state.cartItems.length}}', // Array property
profile: {
name: '{{state.userName}}', // Nested injection
},
},
}
```
### Missing State When a state key doesn’t exist, a value that is only a template becomes `null`. A template embedded in a longer string remains as-is:
```typescript
// If state.userName is not captured:
body: { name: '{{state.userName}}' }
// Returns: { name: null }
// If state.cartItems is not captured:
body: { summary: 'Items: {{state.cartItems}}' }
// Returns: { summary: 'Items: {{state.cartItems}}' }
```
## Shopping Cart Example
```typescript
import type { ScenaristScenario } from '@scenarist/express-adapter';
export const shoppingCartScenario: ScenaristScenario = {
id: 'shopping-cart',
name: 'Shopping Cart',
description: 'Cart with state persistence across requests',
mocks: [
// Add item - captures product ID
{
method: 'POST',
url: 'https://api.store.com/cart/add',
captureState: {
'cartItems[]': 'body.productId', // Append to array
},
response: {
status: 200,
body: { success: true },
},
},
// Get cart - injects captured items
{
method: 'GET',
url: 'https://api.store.com/cart',
response: {
status: 200,
body: {
items: '{{state.cartItems}}',
count: '{{state.cartItems.length}}',
},
},
},
// Clear cart - resets by setting to empty array
{
method: 'DELETE',
url: 'https://api.store.com/cart',
captureState: {
cartItems: 'body.resetTo', // Overwrite (not append)
},
response: {
status: 200,
body: { cleared: true },
},
},
],
};
```
## State Isolation Per Test ID **State is isolated per test ID.** Each parallel test maintains independent state:
```typescript
// Test 1 (test-id: abc-123)
POST /api/cart/add { productId: 'prod-1' }
GET /api/cart // Returns { items: ['prod-1'] }
// Test 2 (test-id: xyz-789) - runs simultaneously
POST /api/cart/add { productId: 'prod-999' }
GET /api/cart // Returns { items: ['prod-999'] }
// No interference - each test has isolated state
```
This isolation is automatic via the test ID header. ## State Lifecycle 1. **Capture:** Extract values from request when mock is matched 2. **Store:** Save values keyed by test ID (isolated per test) 3. **Inject:** Replace templates in responses with stored values 4. **Reset:** Clear state when scenario switches (clean slate) ### State Reset State resets when: * Test switches to a different scenario via `switchScenario()` * New test starts with a different test ID
```typescript
test('cart workflow', async ({ page, switchScenario }) => {
await switchScenario(page, 'shopping-cart');
// Add items...
await fetch('/api/cart/add', { body: { productId: 'prod-1' } });
// State: { cartItems: ['prod-1'] }
// Switch to different scenario
await switchScenario(page, 'different-scenario');
// State is cleared
await switchScenario(page, 'shopping-cart');
// State is empty - cart is now empty
});
```
## Combining with Other Features State capture works with [Request Matching](/scenarios/request-matching/):
```typescript
{
method: 'POST',
url: '/api/cart/add',
match: {
body: { itemType: 'premium' } // Only capture premium items
},
captureState: {
'premiumItems[]': 'body.productId',
},
response: { status: 200, body: { success: true } }
}
```
State capture also works with [Response Sequences](/scenarios/response-sequences/):
```typescript
{
method: 'POST',
url: '/api/onboarding',
sequence: {
responses: [
{ status: 200, body: { step: 1 } },
{ status: 200, body: { step: 2 } },
{ status: 200, body: { step: 3 } },
],
repeat: 'last'
},
captureState: {
'completedSteps[]': 'body.stepNumber',
}
}
```
## Next Steps * [State-Aware Mocking →](/scenarios/state-aware-mocking/) - Conditional responses based on state * [Request Matching →](/scenarios/request-matching/) - Combine state with matching * [Response Sequences →](/scenarios/response-sequences/) - Capture state through sequences * [Combining Features →](/scenarios/combining-features/) - Use all features together
# TypeScript Patterns
> Type-safe scenario definitions with autocomplete and compile-time validation
## What This Enables Type-safe scenario definitions that provide autocomplete in tests and catch errors at compile time. **Use cases:** * **Autocomplete:** `switchScenario(page, '...')` suggests valid scenario IDs * **Compile-time errors:** Typos in scenario names caught immediately * **Structure validation:** Missing required fields caught at compile time * **Refactoring safety:** Rename scenarios confidently ## The Scenarios Object Pattern Organize scenarios in a typed object:
```typescript
import type { ScenaristScenarios } from '@scenarist/express-adapter';
export const scenarios = {
default: defaultScenario, // Required: 'default' key
success: successScenario,
error: errorScenario,
premiumUser: premiumUserScenario,
} as const satisfies ScenaristScenarios;
```
## Why `as const satisfies` The `as const satisfies ScenaristScenarios` pattern is essential for type safety. Each part serves a distinct purpose: ### `as const` - Preserves Literal Types
```typescript
// WITHOUT as const - scenario IDs become generic 'string'
const scenarios = {
default: defaultScenario,
premiumUser: premiumUserScenario,
};
type ScenarioId = keyof typeof scenarios;
// Result: string (not useful for autocomplete)
// WITH as const - scenario IDs are preserved as literal types
const scenarios = {
default: defaultScenario,
premiumUser: premiumUserScenario,
} as const;
type ScenarioId = keyof typeof scenarios;
// Result: 'default' | 'premiumUser' (enables autocomplete!)
```
### `satisfies ScenaristScenarios` - Validates Structure
```typescript
// Using : type annotation WIDENS the type (loses literals)
const scenarios: ScenaristScenarios = {
default: defaultScenario,
premiumUser: premiumUserScenario,
};
type ScenarioId = keyof typeof scenarios;
// Result: string (annotation widened the type)
// Using satisfies VALIDATES without widening
const scenarios = {
default: defaultScenario,
premiumUser: premiumUserScenario,
} as const satisfies ScenaristScenarios;
type ScenarioId = keyof typeof scenarios;
// Result: 'default' | 'premiumUser' (validated AND preserved!)
```
### Together They Enable 1. **Autocomplete in tests:** `switchScenario(page, '...')` suggests valid scenario IDs 2. **Compile-time errors:** `switchScenario(page, 'typo')` fails immediately 3. **Structure validation:** Missing fields caught at compile time
```typescript
// In your Playwright tests:
await switchScenario(page, 'premiumUser'); // ✅ Autocomplete works
await switchScenario(page, 'typo'); // ❌ TypeScript error
```
### Comparison | Pattern | Autocomplete | Validation | | --------------------------------------- | ------------ | ---------- | | `as const satisfies ScenaristScenarios` | ✅ | ✅ | | `as const` only | ✅ | ❌ | | `satisfies` only | ❌ | ✅ | | `: ScenaristScenarios` annotation | ❌ | ✅ | ## Extracting ScenarioId Type Create a type for valid scenario IDs:
```typescript
export const scenarios = {
default: defaultScenario,
success: successScenario,
error: errorScenario,
} as const satisfies ScenaristScenarios;
// Extract scenario ID type
export type ScenarioId = keyof typeof scenarios;
// 'default' | 'success' | 'error'
// Use in helper functions
export function getScenario(id: ScenarioId) {
return scenarios[id];
}
```
## Import Patterns Types are re-exported from all adapter packages:
```typescript
// Express
import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/express-adapter';
// Next.js App Router
import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/app';
// Next.js Pages Router
import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/pages';
```
## Complete Example lib/scenarios.ts
```typescript
import type {
ScenaristScenario,
ScenaristScenarios,
} from '@scenarist/express-adapter';
// Define individual scenarios
const defaultScenario: ScenaristScenario = {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed',
mocks: [
{
method: 'GET',
url: 'https://api.example.com/user',
response: { status: 200, body: { name: 'Test User' } },
},
],
};
const errorScenario: ScenaristScenario = {
id: 'error',
name: 'API Error',
description: 'External API returns error',
mocks: [
{
method: 'GET',
url: 'https://api.example.com/user',
response: { status: 500, body: { error: 'Server Error' } },
},
],
};
const premiumUserScenario: ScenaristScenario = {
id: 'premium-user',
name: 'Premium User',
description: 'User has premium tier',
mocks: [
{
method: 'GET',
url: 'https://api.example.com/user',
response: { status: 200, body: { name: 'Premium User', tier: 'premium' } },
},
],
};
// Export typed scenarios object
export const scenarios = {
default: defaultScenario,
error: errorScenario,
premiumUser: premiumUserScenario,
} as const satisfies ScenaristScenarios;
// Export scenario ID type for use in tests
export type ScenarioId = keyof typeof scenarios;
```
## Using in Tests tests/example.spec.ts
```typescript
import { test as base, expect } from '@playwright/test';
import type { ScenarioId } from '../lib/scenarios';
// Type-safe fixture
const test = base.extend<{ switchScenario: (id: ScenarioId) => Promise }>({
switchScenario: async ({ page }, use) => {
await use(async (id: ScenarioId) => {
await page.request.post('/__scenario__', {
data: { scenario: id },
});
});
},
});
test('handles premium user', async ({ page, switchScenario }) => {
await switchScenario('premiumUser'); // ✅ Autocomplete
await switchScenario('typo'); // ❌ TypeScript error
});
```
## Organizing Large Scenario Sets For many scenarios, organize by feature: scenarios/auth.ts
```typescript
export const authScenarios = {
loggedIn: loggedInScenario,
loggedOut: loggedOutScenario,
sessionExpired: sessionExpiredScenario,
};
// scenarios/payment.ts
export const paymentScenarios = {
paymentSuccess: paymentSuccessScenario,
paymentDeclined: paymentDeclinedScenario,
paymentPending: paymentPendingScenario,
};
// scenarios/index.ts
import { authScenarios } from './auth';
import { paymentScenarios } from './payment';
export const scenarios = {
default: defaultScenario,
...authScenarios,
...paymentScenarios,
} as const satisfies ScenaristScenarios;
export type ScenarioId = keyof typeof scenarios;
```
## Type Errors You’ll See ### Missing ‘default’ Key
```typescript
const scenarios = {
success: successScenario,
// ❌ Not a compile-time error: createScenarist() throws at runtime
// "Scenarios object must have a 'default' key"
} as const satisfies ScenaristScenarios;
```
### Invalid Scenario Structure
```typescript
const badScenario: ScenaristScenario = {
id: 'bad',
// ❌ Error: Property 'name' is missing
// ❌ Error: Property 'description' is missing
mocks: [],
};
```
### Invalid Mock Structure
```typescript
const scenario: ScenaristScenario = {
id: 'test',
name: 'Test',
description: 'Test scenario',
mocks: [
{
method: 'GET',
url: '/api/test',
// ❌ Not a compile-time error: a request that selects this mock
// fails at runtime because it has no 'response', 'sequence', or 'stateResponse'
},
],
};
```
## Next Steps * [Basic Structure →](/scenarios/basic-structure/) - Scenario definition fundamentals * [Default Scenarios →](/scenarios/default-scenarios/) - The required ‘default’ key * [Overview →](/scenarios/overview/) - Feature decision guide
# Express
> Using Scenarist with Express
## Testing Express with Scenarist Scenarist provides first-class support for testing Express applications at the HTTP level, enabling you to test middleware, route handlers, and external API integrations with real HTTP requests. ### The Challenge Testing Express applications traditionally requires choosing between: * Unit testing route handlers in isolation (misses middleware integration) * Mocking Express request/response objects (creates distance from production) * Full E2E tests with browser automation (too slow for comprehensive coverage) ### How Scenarist Helps Scenarist enables HTTP-level integration testing for Express: * Test middleware chains with real HTTP requests * Verify route handlers with different external API scenarios * Test error handling and edge cases comprehensively * Run parallel tests without interference ## Working Example See Scenarist in action with a complete Express application: [**Explore the Express Example App →**](/frameworks/express/example-app/) The example demonstrates: * Testing Express middleware and route handlers * Test ID isolation for parallel execution * Request matching for content-based responses * Sequences for polling and async operations * Stateful mocks with state capture and injection * Complete installation and usage instructions **[View source on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/express-example)** ## Getting Started Ready to integrate Scenarist into your Express application? [Get started with Express →](/frameworks/express/getting-started/)
# Express Example App
> Working example demonstrating Scenarist with Express
## Overview The Express example demonstrates HTTP-level integration testing for Express applications using Scenarist. This example focuses on testing middleware, route handlers, and external API integrations. **GitHub:** [apps/express-example](https://github.com/citypaul/scenarist/tree/main/apps/express-example) ## What It Demonstrates This example app showcases all major Scenarist features with Express: ### Core Features * **Middleware Testing** - Test Express middleware chains with real HTTP requests * **Route Handlers** - Test route handlers with different external API responses * **Test ID Isolation** - Parallel test execution without interference * **Runtime Scenario Switching** - Switch scenarios during test execution ### Dynamic Response Features * **Request Matching** - Different responses based on request content * **Sequences** - Multi-step processes (polling, async operations) * **Stateful Mocks** - State capture and injection across requests * **Default Fallback** - Graceful handling when no scenario is active ## Installation ### Prerequisites * Node.js 22+ * pnpm 11+ ### Clone and Install
```bash
# Clone the repository
git clone https://github.com/citypaul/scenarist.git
cd scenarist
# Install dependencies
pnpm install
# Navigate to Express example
cd apps/express-example
```
## Running the Example ### Development Mode
```bash
# Start the Express server
pnpm dev
```
Server runs on . ### Run Tests
```bash
# Run all tests
pnpm test
# Run specific test file
pnpm test scenario-switching
# Run with coverage
pnpm test --coverage
```
## Key Files ### Scenarist Setup **`src/app.ts`** - Express app with Scenarist integration (simplified; [view on GitHub](https://github.com/citypaul/scenarist/blob/main/apps/express-example/src/app.ts))
```typescript
import express from "express";
import {
createScenarist,
type ExpressScenarist,
} from "@scenarist/express-adapter";
import { scenarios } from "./scenarios.js";
export const createApp = () => {
const app = express();
app.use(express.json());
// Create Scenarist instance (synchronous - returns undefined in production builds)
const scenarist = createScenarist({
enabled: true,
scenarios,
strictMode: false,
});
// Register Scenarist middleware (skipped in production builds)
if (scenarist) {
app.use(scenarist.middleware);
}
// Your routes (the app registers GitHub, weather, Stripe, cart, form and more)
app.get("/api/github/user/:username", async (req, res) => {
const response = await fetch(
`https://api.github.com/users/${encodeURIComponent(req.params.username)}`,
);
const data = await response.json();
res.status(response.status).json(data);
});
return { app, scenarist };
};
```
**`src/server.ts`** - Entry point
```typescript
import { createApp } from "./app.js";
const { app, scenarist } = createApp();
scenarist?.start();
app.listen(process.env.PORT || 3000);
```
### Scenario Definitions **`src/scenarios.ts`** - All scenario definitions ([view on GitHub](https://github.com/citypaul/scenarist/blob/main/apps/express-example/src/scenarios.ts)) Key scenarios: **`default`** - Standard responses for all tests **`github-not-found`** - GitHub user lookup returns 404
```typescript
{
id: "github-not-found",
name: "GitHub User Not Found",
description: "GitHub API returns 404 for user lookup",
mocks: [{
method: "GET",
url: "https://api.github.com/users/:username",
response: {
status: 404,
body: { message: "Not Found", documentation_url: "https://docs.github.com" }
}
}]
}
```
**`github-polling`** - Polling sequence
```typescript
{
id: "github-polling",
name: "GitHub Job Polling Sequence",
description: "Simulates async GitHub job polling with state progression",
mocks: [{
method: "GET",
url: "https://api.github.com/users/:username",
sequence: {
responses: [
{ status: 200, body: { status: "pending", progress: 0, login: "user1" } },
{ status: 200, body: { status: "processing", progress: 50, login: "user2" } },
{ status: 200, body: { status: "complete", progress: 100, login: "user3" } }
],
repeat: "last"
}
}]
}
```
**`shoppingCart`** - Stateful shopping cart
```typescript
{
id: "shoppingCart",
name: "Shopping Cart (Stateful)",
description: "Stateful shopping cart with capture and injection",
mocks: [
{
method: "GET",
url: "http://localhost:3001/cart",
response: {
status: 200,
body: {
items: "{{state.cartItems}}",
count: "{{state.cartItems.length}}",
total: 0
}
}
},
{
method: "PATCH",
url: "http://localhost:3001/cart",
captureState: {
cartItems: "body.items"
},
response: {
status: 200,
body: {
items: "{{state.cartItems}}",
count: "{{state.cartItems.length}}",
message: "Item added to cart"
}
}
}
]
}
```
### Test Examples The tests share a `createTestFixtures()` helper from `tests/test-helpers.ts` that calls `createApp()`, starts MSW, and returns an async `cleanup()` that stops it. **`tests/scenario-switching.test.ts`** - Basic scenario switching
```typescript
import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";
import { describe, it, expect, afterAll } from "vitest";
import request from "supertest";
import { createTestFixtures } from "./test-helpers.js";
const fixtures = createTestFixtures();
describe("Scenario Switching E2E", () => {
afterAll(async () => {
await fixtures.cleanup();
});
it("should switch to success scenario via POST /__scenario__", async () => {
await request(fixtures.app)
.post(fixtures.scenarist.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "success-test-1")
.send({ scenario: "success" });
// Request uses success scenario
const response = await request(fixtures.app)
.get("/api/github/user/testuser")
.set(SCENARIST_TEST_ID_HEADER, "success-test-1");
expect(response.status).toBe(200);
});
});
```
**`tests/test-id-isolation.test.ts`** - Parallel test isolation
```typescript
describe("Test ID Isolation E2E", () => {
it("should allow different test IDs to use different scenarios concurrently", async () => {
// Test ID 1 uses success
await request(fixtures.app)
.post(fixtures.scenarist.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "test-id-1")
.send({ scenario: "success" });
// Test ID 2 uses github-not-found
await request(fixtures.app)
.post(fixtures.scenarist.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "test-id-2")
.send({ scenario: "github-not-found" });
// Requests are isolated
const res1 = await request(fixtures.app)
.get("/api/github/user/testuser")
.set(SCENARIST_TEST_ID_HEADER, "test-id-1");
expect(res1.status).toBe(200);
expect(res1.body.login).toBe("testuser");
const res2 = await request(fixtures.app)
.get("/api/github/user/testuser")
.set(SCENARIST_TEST_ID_HEADER, "test-id-2");
expect(res2.status).toBe(404);
});
});
```
**`tests/dynamic-sequences.test.ts`** - Polling with sequences
```typescript
describe("Dynamic Response Sequences E2E (Phase 2)", () => {
it("should return responses in sequence order (pending → processing → complete)", async () => {
await request(fixtures.app)
.post(fixtures.scenarist.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "polling-test-1")
.send({ scenario: "github-polling" });
// First request: pending
const res1 = await request(fixtures.app)
.get("/api/github/user/testuser")
.set(SCENARIST_TEST_ID_HEADER, "polling-test-1");
expect(res1.body.status).toBe("pending");
// Second request: processing
const res2 = await request(fixtures.app)
.get("/api/github/user/testuser")
.set(SCENARIST_TEST_ID_HEADER, "polling-test-1");
expect(res2.body.status).toBe("processing");
// Third request: complete
const res3 = await request(fixtures.app)
.get("/api/github/user/testuser")
.set(SCENARIST_TEST_ID_HEADER, "polling-test-1");
expect(res3.body.status).toBe("complete");
});
});
```
**`tests/stateful-scenarios.test.ts`** - State capture and injection
```typescript
describe("Stateful Scenarios E2E (Phase 3)", () => {
it("should capture items and inject into cart response", async () => {
await request(fixtures.app)
.post(fixtures.scenarist.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "cart-test-1")
.send({ scenario: "shoppingCart" });
// Add items - the route PATCHes the cart, state captured
await request(fixtures.app)
.post("/api/cart/add")
.set(SCENARIST_TEST_ID_HEADER, "cart-test-1")
.send({ item: "Apple" });
await request(fixtures.app)
.post("/api/cart/add")
.set(SCENARIST_TEST_ID_HEADER, "cart-test-1")
.send({ item: "Banana" });
// Get cart - state injected
const response = await request(fixtures.app)
.get("/api/cart")
.set(SCENARIST_TEST_ID_HEADER, "cart-test-1");
expect(response.body.items).toEqual(["Apple", "Banana"]);
});
});
```
## Architecture ### How It Works 1. **Middleware Registration** - Scenarist middleware added to Express app 2. **Test ID Extraction** - Middleware extracts `x-scenarist-test-id` header from requests 3. **Scenario Activation** - Test calls `POST /__scenario__` to set active scenario 4. **Request Handling** - Express routes execute normally 5. **External API Interception** - MSW intercepts external API calls 6. **Scenario Response** - Returns response defined in scenario ### Middleware Flow
```plaintext
Incoming Request
↓
[Scenarist Middleware] - Extracts x-scenarist-test-id header
↓
[Express Route] - Executes normally
↓
[External API Call] - fetch('https://api.example.com/...')
↓
[MSW Intercepts] - Checks active scenario for test ID
↓
[Scenario Response] - Returns mocked response
↓
[Express Route] - Continues with mocked data
↓
Response to Client
```
### File Structure ## Common Patterns ### Testing Middleware
```typescript
// Middleware that fetches user from external API
app.use(async (req, res, next) => {
const response = await fetch("https://api.auth.example.com/user");
req.user = await response.json();
next();
});
// Test with different user scenarios
describe("Auth Middleware", () => {
it("should set premium user", async () => {
const testId = "test-premium";
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", testId)
.send({ scenario: "premiumUser" });
const response = await request(app)
.get("/api/profile")
.set("x-scenarist-test-id", testId);
expect(response.body.user.tier).toBe("premium");
});
});
```
### Testing with Request Matching
```typescript
import type { ScenaristScenarios } from "@scenarist/express-adapter";
// Different responses based on request body
const scenarios = {
checkout: {
id: "checkout",
name: "Checkout",
description: "Charge response depends on the amount",
mocks: [
{
method: "POST",
url: "https://api.payment.example.com/charge",
match: { body: { amount: 100 } },
response: { status: 200, body: { success: true } },
},
{
method: "POST",
url: "https://api.payment.example.com/charge",
match: { body: { amount: 10000 } },
response: { status: 400, body: { error: "Amount too large" } },
},
],
},
} as const satisfies ScenaristScenarios;
```
### Testing Default Fallback
```typescript
describe("Default Fallback", () => {
it("should use default scenario when none specified", async () => {
// No scenario switch - uses default
const response = await request(app).get("/api/user");
expect(response.status).toBe(200);
expect(response.body.tier).toBe("standard"); // default scenario
});
});
```
## Next Steps * [Express Getting Started →](/frameworks/express/getting-started/) - Integrate Scenarist into your Express app * [Request Matching →](/scenarios/request-matching/) - Learn about request content matching * [Sequences →](/scenarios/response-sequences/) - Learn about response sequences * [Stateful Mocks →](/scenarios/stateful-mocks/) - Learn about state capture and injection
# Express - Getting Started
> Set up Scenarist with Express in 5 minutes
Test your Express APIs with runtime scenario switching. Zero boilerplate setup using AsyncLocalStorage. ## Installation
```bash
npm install @scenarist/express-adapter
npm install -D msw @playwright/test @scenarist/playwright-helpers
```
`msw` (v2) is a required peer dependency of `@scenarist/express-adapter`. ## Basic Setup **1. Define your scenarios:** src/scenarios.ts
```typescript
import type {
ScenaristScenario,
ScenaristScenarios,
} from "@scenarist/express-adapter";
// ✅ RECOMMENDED - Default scenario with complete happy path
const defaultScenario: ScenaristScenario = {
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 user
{
method: "GET",
url: "https://api.auth0.com/userinfo",
response: {
status: 200,
body: { sub: "user_123", email: "john@example.com", tier: "standard" },
},
},
// SendGrid: Email sent successfully
{
method: "POST",
url: "https://api.sendgrid.com/v3/mail/send",
response: {
status: 202,
body: { message_id: "msg_123" },
},
},
],
};
// Specialized scenario: Override ONLY Stripe for payment failure
const cardDeclinedScenario: ScenaristScenario = {
id: "cardDeclined",
name: "Card Declined",
description: "Stripe declines payment, everything else succeeds",
mocks: [
// Override: Stripe declines payment
{
method: "POST",
url: "https://api.stripe.com/v1/charges",
response: {
status: 402,
body: {
error: { code: "card_declined", message: "Your card was declined" },
},
},
},
// Auth0 and SendGrid automatically fall back to default (happy path)
],
};
export const scenarios = {
default: defaultScenario,
cardDeclined: cardDeclinedScenario,
} as const satisfies ScenaristScenarios;
```
**2. Set up Scenarist in your Express app:** src/app.ts
```typescript
import express from "express";
import {
createScenarist,
type ExpressScenarist,
} from "@scenarist/express-adapter";
import { scenarios } from "./scenarios";
// Use async factory pattern for Express apps
export const createApp = async () => {
const app = express();
app.use(express.json());
// Returns undefined when enabled is false or NODE_ENV is production
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
});
// Add Scenarist middleware BEFORE your routes (scenarist is undefined in production builds)
if (scenarist) {
app.use(scenarist.middleware);
}
// Your routes run normally - Scenarist just mocks external APIs
app.post("/api/checkout", async (req, res) => {
const { amount, token } = req.body;
// Your validation runs
if (amount < 1) {
return res.status(400).json({ error: "Invalid amount" });
}
// External Stripe call is mocked by Scenarist
const charge = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` },
body: JSON.stringify({ amount, source: token }),
});
const result = await charge.json();
// Your business logic runs
if (charge.status === 200) {
return res.json({ success: true, chargeId: result.id });
} else {
return res.status(402).json({ error: result.error.message });
}
});
return { app, scenarist };
};
// src/server.ts - Entry point
const main = async () => {
const { app, scenarist } = await createApp();
if (scenarist) {
scenarist.start();
}
app.listen(3000, () => console.log("Server running on :3000"));
};
main().catch(console.error);
```
**3. Install testing dependencies:**
```bash
npm install -D vitest supertest @types/supertest
```
**4. Write your tests:** tests/checkout.test.ts
```typescript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import request from "supertest";
import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";
import { createApp } from "../src/app";
// Factory function for test setup - no let variables
const createTestSetup = async () => {
const { app, scenarist } = await createApp();
return { app, scenarist };
};
describe("Checkout API", () => {
const testContext = createTestSetup();
beforeAll(async () => {
const { scenarist } = await testContext;
scenarist?.start(); // Start MSW
});
afterAll(async () => {
const { scenarist } = await testContext;
await scenarist?.stop(); // Stop MSW
});
it("processes payment successfully", async () => {
const { app, scenarist } = await testContext;
// Switch to default scenario
await request(app)
.post(scenarist!.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "test-1")
.send({ scenario: "default" });
// Make API request - your Express route runs normally
const response = await request(app)
.post("/api/checkout")
.set(SCENARIST_TEST_ID_HEADER, "test-1")
.send({ amount: 5000, token: "tok_test" });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.chargeId).toBe("ch_123");
});
it("handles card declined error", async () => {
const { app, scenarist } = await testContext;
// Switch to cardDeclined scenario
await request(app)
.post(scenarist!.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "test-2")
.send({ scenario: "cardDeclined" });
const response = await request(app)
.post("/api/checkout")
.set(SCENARIST_TEST_ID_HEADER, "test-2")
.send({ amount: 5000, token: "tok_test" });
expect(response.status).toBe(402);
expect(response.body.error).toContain("declined");
});
});
```
## Test ID Headers **Every test request must include a test ID header** for proper test isolation. This is what enables parallel test execution without interference. ### How Test Isolation Works 1. **Unique test ID per test:** Each test gets a unique identifier (e.g., `'test-1'`, `'test-2'`) 2. **Header on every request:** Send the test ID with every HTTP request 3. **AsyncLocalStorage tracking:** Express adapter uses AsyncLocalStorage to track which test ID is active for the current request 4. **Scenario isolation:** Each test ID has its own active scenario and state ### Required Headers Include the test ID header on **both** scenario switch requests AND actual API requests:
```typescript
// Step 1: Switch scenario (with test ID header)
await request(app)
.post(scenarist.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "test-1") // ← Test ID header
.send({ scenario: "cardDeclined" });
// Step 2: Make API request (with SAME test ID header)
const response = await request(app)
.post("/api/checkout")
.set(SCENARIST_TEST_ID_HEADER, "test-1") // ← Same test ID
.send({ amount: 5000, token: "tok_test" });
```
**What happens:** 1. First request switches to `'cardDeclined'` scenario for test ID `'test-1'` 2. Second request includes same test ID header 3. AsyncLocalStorage associates the request with `'test-1'` 4. Scenarist uses the `'cardDeclined'` scenario for this request 5. Different test using `'test-2'` would use its own scenario ### Header Name The header name is standardized to `'x-scenarist-test-id'`:
```typescript
// These are equivalent:
.set('x-scenarist-test-id', 'test-1')
.set(SCENARIST_TEST_ID_HEADER, 'test-1') // Recommended
```
**Why use `SCENARIST_TEST_ID_HEADER`?** * Avoids magic strings (clear intent) * Type-safe (TypeScript will catch typos) * Consistent across all Scenarist packages ### Parallel Test Execution Test IDs enable **parallel test execution** without interference:
```typescript
describe("Parallel checkout tests", () => {
it("test 1: successful payment", async () => {
// Uses test ID 'test-1'
await request(app)
.post(scenarist.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "test-1")
.send({ scenario: "default" });
const response = await request(app)
.post("/api/checkout")
.set(SCENARIST_TEST_ID_HEADER, "test-1")
.send({ amount: 5000, token: "tok_test" });
expect(response.status).toBe(200);
});
it("test 2: card declined", async () => {
// Uses test ID 'test-2' - runs simultaneously with test 1
await request(app)
.post(scenarist.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "test-2")
.send({ scenario: "cardDeclined" });
const response = await request(app)
.post("/api/checkout")
.set(SCENARIST_TEST_ID_HEADER, "test-2")
.send({ amount: 5000, token: "tok_test" });
expect(response.status).toBe(402);
});
});
```
**Both tests run in parallel:** * Test 1 uses scenario `'default'` via test ID `'test-1'` * Test 2 uses scenario `'cardDeclined'` via test ID `'test-2'` * No interference because test IDs isolate scenarios and state ### Missing Headers = Default Scenario If you forget to include the test ID header:
```typescript
// ❌ Missing test ID header
const response = await request(app)
.post("/api/checkout")
// .set(SCENARIST_TEST_ID_HEADER, 'test-1') ← MISSING!
.send({ amount: 5000, token: "tok_test" });
```
**What happens:** * Request uses the `'default'` scenario (fallback behavior) * If you switched to a different scenario, it won’t be used * May cause confusing test failures (wrong scenario active) **Always include the header on every request!** ### Internal Fetch Calls If your Express routes make internal fetch calls to other services, you must manually propagate the test ID header:
```typescript
app.get("/api/dashboard", async (req, res) => {
// Extract test ID from incoming request
const testId = req.get(SCENARIST_TEST_ID_HEADER) || "default-test";
// Include test ID in internal fetch
const response = await fetch("http://localhost:3001/api/user", {
headers: {
[SCENARIST_TEST_ID_HEADER]: testId,
},
});
const data = await response.json();
res.json(data);
});
```
**Why this is needed:** * AsyncLocalStorage only tracks the current request * Internal fetch calls are new requests (separate context) * Must explicitly pass test ID header to maintain isolation ## What Makes Express Setup Special **Zero Boilerplate** - Scenarist uses `AsyncLocalStorage` to automatically track test IDs. No manual header passing required. **Test Isolation** - Each test gets its own scenario state. Tests run in parallel without interference. **Your Code Runs** - Your Express routes, middleware, validation, and business logic all execute normally. Only external API calls are mocked. ## Production Tree-Shaking Scenarist keeps its endpoints and request interception out of production through two layers: 1. **The `production` export condition** resolves `@scenarist/express-adapter` to a stub that returns `undefined` and imports nothing. No Scenarist or MSW code is loaded or bundled. 2. **A runtime guard**: when `NODE_ENV` is `production`, `createScenarist()` returns `undefined` even if the condition was not applied. No middleware, no `/__scenario__` endpoints, no interception. `createScenarist()` also returns `undefined` when `enabled` is `false`, so always guard with `if (scenarist)`. ### Unbundled Deployments (Most Common) `production` is a custom condition, so Node.js only applies it when you pass it explicitly:
```bash
# Recommended: resolves the zero-dependency stub
NODE_ENV=production node --conditions=production src/server.js
```
With the flag: * `createScenarist()` returns `undefined` at runtime * The production entry point imports nothing * MSW code **never loads into memory** If you start the server without the flag, the runtime guard still disables Scenarist:
```bash
# Scenarist is inert: createScenarist() returns undefined, /__scenario__ returns 404
NODE_ENV=production node src/server.js
```
Without --conditions=production, MSW must be installed Without the condition, Node.js loads the full adapter, which imports `msw`. If you install `msw` as a dev dependency and prune dev dependencies in production, the server fails at startup with `ERR_MODULE_NOT_FOUND`. Use `--conditions=production`, or keep `msw` installed. ### Bundled Deployments (esbuild, webpack, Vite, rollup) **For teams bundling Express server code**, configure the `production` condition. Without it, the runtime guard still disables Scenarist, but the adapter and MSW code stay in the bundle. Scenarist uses **conditional package.json exports** to provide different entry points:
```json
{
"exports": {
".": {
"production": "./dist/setup/production.js", // Zero dependencies
"default": "./dist/index.js" // Full implementation
}
}
}
```
The `"production"` condition is **custom** (not a Node.js built-in). Bundlers must be configured to recognize it: **esbuild:**
```json
{
"scripts": {
"build": "esbuild src/server.ts --bundle --conditions=production --define:process.env.NODE_ENV='\"production\"'"
}
}
```
**webpack:** webpack.config.js
```js
module.exports = {
mode: "production",
resolve: {
conditionNames: ["production", "import", "require"],
},
};
```
**Vite:** vite.config.js
```js
export default {
resolve: {
conditions: ["production"],
},
};
```
**rollup:**
```js
import resolve from "@rollup/plugin-node-resolve";
export default {
plugins: [
resolve({
exportConditions: ["production"],
}),
],
};
```
**Results:** * ✅ Bundle size: 618kb → 298kb (52% reduction) * ✅ Zero MSW code in production bundle **Verification:**
```bash
# Build with bundler configuration
npm run build:production
# Verify MSW code eliminated (succeeds only if dist/ exists and has no matches)
test -d dist && {
grep -rqE '(__scenarist_shared_msw_server|setupWorker|HttpResponse\.json)' dist/
test $? -eq 1
}
```
**For detailed configuration examples**, see the [Express Adapter README - Production Tree-Shaking](https://github.com/citypaul/scenarist/tree/main/packages/express-adapter#production-tree-shaking). ## Debugging with Logging If mocks aren’t matching as expected, enable logging to see what’s happening:
```typescript
import { createScenarist, createConsoleLogger } from "@scenarist/express-adapter";
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
logger: createConsoleLogger({
level: "debug",
categories: ["matching", "scenario"],
}),
});
```
This shows which mocks are being evaluated and why they match or don’t. See [Logging Reference](/reference/logging/) for all options. ## Next Steps * **Debugging:** Learn about [logging options](/reference/logging/) for troubleshooting * **Production safety:** Learn why [Scenarist is safe for production](/concepts/production-safety/) * **Example app:** See [complete Express example](https://github.com/citypaul/scenarist/tree/main/apps/express-example) with comprehensive test suite * **Architecture:** Learn [how Scenarist works](/concepts/architecture/)
# Next.js
> Using Scenarist with Next.js (App Router and Pages Router)
## Testing Next.js with Scenarist Scenarist provides first-class support for testing Next.js applications, addressing the challenges outlined in the [official Next.js testing documentation](https://nextjs.org/docs/app/building-your-application/testing). ### The Challenge Next.js recommends end-to-end testing for Server Components because *“async Server Components are new to the React ecosystem.”* Unit testing requires mocking Next.js internals (fetch, cookies, headers), creating distance from production execution. Traditional testing approaches face similar challenges across both routing paradigms: * Mocking framework internals creates distance from production behavior * Testing server-side logic requires complex setup * End-to-end tests are too slow for comprehensive scenario coverage ### How Scenarist Helps Scenarist enables testing Next.js applications through real HTTP requests: * Test server-side code without mocking framework internals * Verify different external API scenarios with runtime switching * Run parallel tests without interference * Switch scenarios per test against one running server, with no restarts * **Automatic singleton protection** - Handles Next.js module duplication for you (no `globalThis` boilerplate needed) ## Next.js Support Scenarist supports both Next.js routing patterns: * **[App Router](/frameworks/nextjs-app-router/)** - Server Components, Server Actions, Route Handlers * **[Pages Router](/frameworks/nextjs-pages-router/)** - API Routes, getServerSideProps, getStaticProps ## Choose Your Router ### App Router (Modern) The App Router introduces React Server Components, allowing server-side rendering with direct data fetching. Scenarist enables testing these components without mocking Next.js internals. [**Get started with App Router →**](/frameworks/nextjs-app-router/) **Best for:** * New Next.js projects (Scenarist supports Next.js 14, 15 and 16) * Server Components and streaming * Server Actions for mutations * Route Handlers for API endpoints ### Pages Router (Traditional) The Pages Router uses the traditional pages directory with API routes and data fetching methods. Scenarist enables testing API routes and server-side rendering without complex mocking. [**Get started with Pages Router →**](/frameworks/nextjs-pages-router/) **Best for:** * Existing Next.js projects * Traditional API routes * getServerSideProps and getStaticProps * Gradual migration strategies ## What Makes Next.js Testing Different **Server-Side Execution** - Both routers execute code on the server that needs testing with different external API scenarios. **Framework Internals** - Traditional unit testing requires mocking Next.js internals (fetch, cookies, headers), creating distance from production. **Parallel Testing** - Scenarist’s test ID isolation allows concurrent tests with different scenarios, maintaining fast test execution. ## Next Steps Choose your routing paradigm to get started: * [App Router Guide →](/frameworks/nextjs-app-router/) * [Pages Router Guide →](/frameworks/nextjs-pages-router/)
# Next.js App Router
> Using Scenarist with Next.js App Router (Server Components, Route Handlers, Server Actions)
## Testing Next.js App Router with Scenarist The Next.js App Router introduces React Server Components, enabling server-side rendering with direct data fetching. Scenarist provides first-class support for testing App Router applications, addressing the challenges outlined in the [official Next.js testing documentation](https://nextjs.org/docs/app/building-your-application/testing). ### The Challenge Next.js recommends end-to-end testing for Server Components because *“async Server Components are new to the React ecosystem.”* Unit testing requires mocking Next.js internals (fetch, cookies, headers), creating distance from production execution. **Specific App Router challenges:** * Server Components execute asynchronously with no standard testing approach * Route Handlers need testing with different external API scenarios * Server Actions require testing mutations without complex mocking * Framework internals (fetch, cookies, headers) must be mocked for unit tests ### How Scenarist Helps Scenarist enables HTTP-level testing for App Router applications: * **Test Server Components** without mocking Next.js internals * **Test Route Handlers** with different external API scenarios * **Test Server Actions** with runtime scenario switching * **Run parallel tests** without interference * **No restarts** - each test switches scenario at runtime against one running server * **Automatic singleton protection** - Handles Next.js module duplication for you (no `globalThis` boilerplate needed) ## App Router Features Supported ### Server Components Test async Server Components with real HTTP requests: app/products/page.tsx
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
export default async function ProductsPage() {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: getScenaristHeadersFromReadonlyHeaders(await headers()),
cache: 'no-store',
});
const { data: products } = await response.json();
return ;
}
// Test without mocking Next.js internals
test('renders products from external API', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await expect(page.getByText('Premium Product')).toBeVisible();
});
```
### Route Handlers Test API routes with different scenarios: app/api/checkout/route.ts
```typescript
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
export async function POST(request: Request) {
const body = await request.json();
const response = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: getScenaristHeaders(request),
body: JSON.stringify(body),
cache: "no-store",
});
return Response.json(await response.json());
}
// Test with different payment scenarios
test("processes successful payment", async ({ page, switchScenario }) => {
const testId = await switchScenario(page, "paymentSuccess");
const response = await page.request.post("/api/checkout", {
headers: { "x-scenarist-test-id": testId },
data: { amount: 5000, token: "tok_test" },
});
expect(response.ok()).toBe(true);
});
```
### Server Actions Test mutations with state capture: app/cart/actions.ts
```typescript
"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app";
export async function addToCart(productId: string) {
await fetch("https://api.cart.example.com/add", {
method: "POST",
headers: getScenaristHeadersFromReadonlyHeaders(await headers()),
body: JSON.stringify({ productId }),
cache: "no-store",
});
revalidatePath("/cart");
}
// Test with stateful mocks
test("cart maintains state across requests", async ({
page,
switchScenario,
}) => {
await switchScenario(page, "cartWithState");
await page.goto("/products");
await page.getByRole("button", { name: "Add to Cart" }).click();
await page.goto("/cart");
await expect(page.getByText("Product A")).toBeVisible();
});
```
## Working Example See Scenarist in action with a complete Next.js App Router application: [**Explore the Next.js App Router Example →**](/frameworks/nextjs-app-router/example-app/) The example demonstrates: * Testing Server Components without mocking Next.js internals * Request matching for tier-based pricing * Sequences for polling scenarios * Stateful mocks for shopping cart functionality * Complete installation and usage instructions **[View source on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example)** ## Getting Started Ready to integrate Scenarist into your Next.js App Router application? [**Get started with App Router →**](/frameworks/nextjs-app-router/getting-started/) ## Key Benefits **Server Components Execute** - Your React Server Components render and run your application logic, not mocked stubs. **API Routes Run Normally** - Your validation, error handling, and business logic all execute. **Test Isolation** - Each test gets isolated scenario state. Run tests in parallel with zero interference. **No App Restart** - Switch scenarios instantly during test execution. **Real HTTP Requests** - Tests make actual HTTP requests to your Next.js app, exercising middleware and routing.
# Next.js Example App
> Working example demonstrating Scenarist with Next.js App Router
## Overview The Next.js App Router example demonstrates HTTP-level testing for Server Components, Client Components, API routes, and Server Actions using Scenarist. **GitHub:** [apps/nextjs-app-router-example](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example) ## What It Demonstrates This example app showcases all major Scenarist features: ### Core Features * **Server Components** - Test async Server Components without mocking Next.js internals * **Client Components** - Test client-side hydration with backend scenarios * **API Routes** - Test Route Handlers with different external API responses * **Runtime Scenario Switching** - Multiple scenarios running concurrently ### Dynamic Response Features * **Request Matching** - Different responses based on request content (tier-based pricing) * **Sequences** - Polling scenarios (pending → processing → complete) * **Stateful Mocks** - Shopping cart with state capture and injection ## Installation ### Prerequisites * Node.js 22+ * pnpm 11+ ### Clone and Install
```bash
# Clone the repository
git clone https://github.com/citypaul/scenarist.git
cd scenarist
# Install dependencies
pnpm install
# Navigate to Next.js example
cd apps/nextjs-app-router-example
```
## Running the Example ### Development Mode
```bash
# Start the Next.js dev server
pnpm dev
```
Visit to see the app. ### Run Tests
```bash
# Run all tests
pnpm test
# Run tests in UI mode
pnpm test:e2e:ui
# Run specific test file
pnpm test products-server-components
```
## Key Files ### Scenarist Setup **`lib/scenarist.ts`** - Scenarist configuration
```typescript
import { createScenarist } from '@scenarist/nextjs-adapter/app';
import { scenarios } from './scenarios';
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Start MSW in Node.js environment
if (typeof window === 'undefined' && scenarist) {
scenarist.start();
}
```
**`app/api/%5F%5Fscenario%5F%5F/route.ts`** - Scenario control endpoint
```typescript
import { scenarist } from '../../../lib/scenarist';
const handler = scenarist?.createScenarioEndpoint();
export const POST = handler;
export const GET = handler;
```
This creates the `/api/__scenario__` endpoint used by tests to switch scenarios. ### Scenario Definitions **`lib/scenarios.ts`** - All scenario definitions ([view on GitHub](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/lib/scenarios.ts)) Key scenarios: **`default`** - Standard user, successful API responses **`premiumUser`** - Premium tier with request matching
```typescript
premiumUser: {
id: 'premiumUser',
mocks: [{
method: 'GET',
url: 'http://localhost:3001/products',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { products: buildProducts('premium') } }
}]
}
```
**`githubPolling`** - Polling sequence (pending → processing → complete)
```typescript
githubPolling: {
id: 'githubPolling',
mocks: [{
method: 'GET',
url: 'http://localhost:3001/github/jobs/:id',
sequence: {
responses: [
{ status: 200, body: { jobId: '123', status: 'pending', progress: 0 } },
{ status: 200, body: { jobId: '123', status: 'processing', progress: 50 } },
{ status: 200, body: { jobId: '123', status: 'complete', progress: 100 } }
],
repeat: 'last'
}
}]
}
```
**`cartWithState`** - Stateful shopping cart
```typescript
cartWithState: {
id: 'cartWithState',
name: 'Shopping Cart with State',
description: 'Stateful shopping cart that captures and injects cart items',
mocks: [
{
method: 'GET',
url: 'http://localhost:3001/cart',
response: {
status: 200,
body: { items: '{{state.cartItems}}' }
}
},
{
method: 'PATCH',
url: 'http://localhost:3001/cart',
captureState: {
cartItems: 'body.items'
},
response: {
status: 200,
body: { items: '{{state.cartItems}}' }
}
}
]
}
```
### Test Examples **`tests/playwright/products-server-components.spec.ts`** - Server Components with request matching
```typescript
test('should render products from server component with premium tier', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products?tier=premium');
// Server Component forwards x-user-tier: premium to the products API
// Scenarist returns mock matching { headers: { 'x-user-tier': 'premium' } }
await expect(page.getByText('Current tier: premium')).toBeVisible();
await expect(page.getByText('£99.99')).toBeVisible();
});
```
**`tests/playwright/polling-server-components.spec.ts`** - Polling with sequences
```typescript
test('should show complete status on third request', async ({ page, switchScenario }) => {
await switchScenario(page, 'githubPolling');
// First request: pending
await page.goto('/polling?jobId=123');
// Second request: processing
await page.reload();
// Third request: complete
await page.reload();
await expect(page.getByText('COMPLETE', { exact: true })).toBeVisible();
await expect(page.getByText('100%', { exact: true })).toBeVisible();
});
```
**`tests/playwright/cart-server-components.spec.ts`** - Stateful mocks with Server Components
```typescript
test('should display cart item after adding product via state capture', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'cartWithState');
// Add product - state captured
await page.request.post('http://localhost:3002/api/cart/add', {
headers: { 'x-scenarist-test-id': testId },
data: { productId: 'prod-1' }
});
await page.goto('/cart-server');
// Cart shows added product - state injected
await expect(page.getByText('Product A')).toBeVisible();
});
```
## Architecture ### How It Works 1. **Setup** - Next.js app includes the Scenarist scenario endpoint route 2. **Test starts** - Calls `switchScenario()` to set active scenario 3. **HTTP request** - Test makes request to Next.js app 4. **Backend execution** - Server Components, API routes execute normally 5. **External API call** - Intercepted by MSW with scenario-defined response 6. **Test assertion** - Verifies rendered output or API response ### Test Isolation Each test gets a unique test ID: * Scenario switching: `POST /api/__scenario__` with `x-scenarist-test-id` header * All requests include `x-scenarist-test-id` header automatically (Playwright helper) * Server routes requests to correct scenario based on test ID * Parallel tests don’t interfere with each other ### File Structure ## Common Patterns ### Testing Server Components
```typescript
// Server Component fetches external API, forwarding the test ID
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
export default async function ProductsPage() {
const response = await fetch('https://api.products.example.com/list', {
headers: getScenaristHeadersFromReadonlyHeaders(await headers()),
cache: 'no-store',
});
const products = await response.json();
return
{products.map(p => )}
;
}
// Test with different scenarios
test('standard products', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/products');
await expect(page.getByText('Product A')).toBeVisible();
});
test('premium products', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await expect(page.getByText('Premium Product')).toBeVisible();
});
```
### Testing with Request Matching Use request content to determine response:
```typescript
// Scenario with tier-based pricing
mocks: [{
method: 'GET',
url: 'https://api.products.example.com/pricing',
match: { query: { tier: 'premium' } },
response: { status: 200, body: { price: 799, discount: 20 } }
}, {
method: 'GET',
url: 'https://api.products.example.com/pricing',
// No match criteria - fallback for standard tier
response: { status: 200, body: { price: 999, discount: 0 } }
}]
```
### Testing Polling Scenarios Use sequences to simulate async operations:
```typescript
// Scenario with polling sequence
mocks: [{
method: 'GET',
url: 'https://api.github.com/repos/user/repo/status',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete' } }
],
repeat: 'last' // After sequence exhausts, repeat last response
}
}]
```
## Next Steps * [Next.js App Router Getting Started →](/frameworks/nextjs-app-router/getting-started/) - Integrate Scenarist into your Next.js app * [Request Matching →](/scenarios/request-matching/) - Learn about request content matching * [Sequences →](/scenarios/response-sequences/) - Learn about response sequences * [Stateful Mocks →](/scenarios/stateful-mocks/) - Learn about state capture and injection
# Next.js App Router - Getting Started
> Set up Scenarist with Next.js App Router in 5 minutes
Test your Next.js App Router application with Server Components, Route Handlers, and Server Actions all executing. No mocking of Next.js internals required. ## Installation
```bash
npm install @scenarist/nextjs-adapter msw
npm install -D @playwright/test @scenarist/playwright-helpers
```
## 1. Define Scenarios lib/scenarios.ts
```typescript
import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/app';
// ✅ RECOMMENDED - Default scenario with complete happy path
const defaultScenario: ScenaristScenario = {
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' },
},
},
// SendGrid: Email sent successfully
{
method: 'POST',
url: 'https://api.sendgrid.com/v3/mail/send',
response: {
status: 202,
body: { message_id: 'msg_123' },
},
},
],
};
// Specialized scenario: Override ONLY Auth0 for premium user
const premiumUserScenario: ScenaristScenario = {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user, everything else succeeds',
mocks: [
// Override: Auth0 returns premium tier
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_456', email: 'premium@example.com', tier: 'premium' },
},
},
// Stripe and SendGrid automatically fall back to default (happy path)
],
};
export const scenarios = {
default: defaultScenario,
premiumUser: premiumUserScenario,
} as const satisfies ScenaristScenarios;
```
## 2. Set Up Scenarist lib/scenarist.ts
```typescript
import { createScenarist } from '@scenarist/nextjs-adapter/app';
import { scenarios } from './scenarios';
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Start MSW in Node.js environment
if (typeof window === 'undefined' && scenarist) {
scenarist.start();
}
```
Create One Instance Use the `export const scenarist` pattern shown above, and import `scenarist` wherever you need it. Call `createScenarist()` in one module only. **Why this matters:** Scenarist handles a Next.js-specific issue for you. It has a [well-documented singleton problem](https://github.com/vercel/next.js/discussions/68572) where webpack bundles the same module multiple times, breaking classic singleton patterns. This is compounded by [MSW's challenges with Next.js's process model](https://github.com/mswjs/msw/issues/1644)—Next.js keeps multiple Node.js processes that make global module patches difficult to maintain. **Scenarist solves this automatically.** The Next.js adapter includes built-in `globalThis` singleton guards that ensure only one MSW instance exists, regardless of how Next.js loads your modules. You don't need to understand Next.js internals or implement manual workarounds—just use `export const scenarist = createScenarist(...)` and Scenarist handles the complexity. Without protection, this causes: * `[MSW] Multiple handlers with the same URL` warnings * Intermittent 500 errors from MSW * Different tests getting wrong scenarios * Scenarios not switching properly **How Scenarist solves this:** The first `createScenarist()` call stores its instance in `global.__scenarist_instance`. Every later call returns that same instance, even when Next.js loads your module more than once, so only one instance and one MSW server ever exist. Later calls ignore the options you pass them, so a second `createScenarist()` call with different scenarios has no effect. The exception is `enabled: false`, which always returns `undefined`. ## 3. Create Scenario Control Endpoint app/api/%5F%5Fscenario%5F%5F/route.ts
```typescript
import { scenarist } from '@/lib/scenarist';
// scenarist is undefined in production builds or when enabled is false,
// so these handlers are undefined and the route answers 405
const handler = scenarist?.createScenarioEndpoint();
export const POST = handler;
export const GET = handler;
```
To inspect captured state from your tests with `debugState` and `waitForDebugState`, add the debug state route the same way: app/api/%5F%5Fscenarist%5F%5F/state/route.ts
```typescript
import { scenarist } from '@/lib/scenarist';
export const GET = scenarist?.createStateEndpoint();
```
This route is served at `/api/__scenarist__/state`. ## 4. Configure Playwright playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
// The Playwright helpers default to /__scenarist__/state, which is the Express path
scenaristStateEndpoint: '/api/__scenarist__/state',
},
// Playwright starts the app with next dev, where Scenarist is active
webServer: {
// Next.js 16; on Next.js 14 and 15, drop --webpack (webpack is already the default)
command: 'npx next dev --webpack --port 3000',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
```
`scenaristEndpoint` defaults to `/api/__scenario__`, so you only need to set it if you moved the scenario route. ### Running your app for tests Run your scenario tests against `next dev`. The `webServer` block above starts it before the tests run and, outside CI, reuses one you already have running. Under `next dev`, `createScenarist()` returns the Scenarist instance, `scenarist.start()` starts MSW in the server process, and the scenario route switches scenarios. You don’t need to set `NODE_ENV`. The [example app](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example) starts its server the same way, with `next dev --webpack` on Next.js 16. Next.js 14 and 15 have no `--webpack` flag and use webpack by default, so run `next dev` there. **Can the tests run against `next build && next start`?** No. `next build` resolves the adapter’s `production` export condition, so `createScenarist()` returns `undefined` and MSW never starts. The scenario route then answers `405`, and `switchScenario` throws `Failed to switch scenario: 405`. Setting `NODE_ENV=test` does not change this. To test the production build itself, use a separate Playwright config that does not switch scenarios and points your app at real or stand-in backends. The example app does this with `playwright.production.config.ts`, which builds the app, runs `next start`, and asserts that the scenario route answers `405`. ## 5. Set Up Playwright Fixtures tests/fixtures.ts
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };
```
## 6. Write Tests tests/products.spec.ts
```typescript
import { test, expect } from './fixtures'; // ✅ Import from fixtures
test('premium users see premium pricing', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // ✅ Type-safe! Autocomplete works
await page.goto('/products');
// Your Server Component executes and renders
// Auth0 API returns premium tier, Stripe/SendGrid fall back to default
await expect(page.getByText('Premium Plan')).toBeVisible();
await expect(page.getByText('$50.00')).toBeVisible();
});
test('standard users see standard pricing', async ({ page, switchScenario }) => {
await switchScenario(page, 'default'); // Default scenario (happy path)
await page.goto('/products');
await expect(page.getByText('Standard Plan')).toBeVisible();
await expect(page.getByText('$25.00')).toBeVisible();
});
```
**Example Server Component** that the tests above exercise: app/products/page.tsx
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
const plans = {
standard: { name: 'Standard Plan', price: '$25.00' },
premium: { name: 'Premium Plan', price: '$50.00' },
};
export default async function ProductsPage() {
// Scenarist answers this call with the Auth0 mock from the test's scenario
const response = await fetch('https://api.auth0.com/userinfo', {
// Forward the test ID, or the call falls back to the default scenario
headers: getScenaristHeadersFromReadonlyHeaders(await headers()),
cache: 'no-store',
});
const user = await response.json();
const plan = user.tier === 'premium' ? plans.premium : plans.standard;
return (
{plan.name}
{plan.price}
);
}
```
Every `fetch` your server code makes to a mocked API must forward the Scenarist headers and set `cache: 'no-store'`, so Next.js never answers it from its fetch cache instead of the mock for the active scenario ([why](/frameworks/nextjs-app-router/rsc/troubleshooting/#pitfall-3-nextjs-caching)). See [Forwarding Headers to External APIs](#forwarding-headers-to-external-apis) for Server Components and Route Handlers. ## Forwarding Headers to External APIs **Why header forwarding matters:** When your Server Components or Route Handlers call external APIs (that you’re mocking with Scenarist), you must forward the test ID header so MSW knows which scenario to use. ### Server Components (ReadonlyHeaders) Server Components use `headers()` from `next/headers`, which returns `ReadonlyHeaders` (not a `Request` object). Use the `getScenaristHeadersFromReadonlyHeaders` helper: app/products/page.tsx
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
export default async function ProductsPage() {
// Get headers from Next.js Server Component
const headersList = await headers();
// Forward Scenarist headers to external API
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList), // ✅ For ReadonlyHeaders
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
cache: 'no-store', // Never serve a cached response to a different test
});
const { data: products } = await response.json();
return (
{products.map(product => (
{product.name}
${(product.price / 100).toFixed(2)}
))}
);
}
```
### Route Handlers (Request object) Route Handlers have access to the `Request` object. Use the `getScenaristHeaders` helper: app/api/products/route.ts
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/app';
export async function GET(request: Request) {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(request), // ✅ For Request objects
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
cache: 'no-store', // Never serve a cached response to a different test
});
const data = await response.json();
return Response.json(data);
}
```
### When to use which helper | Context | Helper Function | Import | | ----------------- | ----------------------------------------------------- | ------------------------------- | | Server Components | `getScenaristHeadersFromReadonlyHeaders(headersList)` | `@scenarist/nextjs-adapter/app` | | Route Handlers | `getScenaristHeaders(request)` | `@scenarist/nextjs-adapter/app` | Both helpers extract the test ID header (`x-scenarist-test-id`) for forwarding to external APIs. ## What Makes App Router Setup Special **Server Components Actually Execute** - Unlike traditional mocking, your React Server Components render and run your application logic. **Route Handlers Run Normally** - Your validation, error handling, and business logic all execute. **Test Isolation** - Each test gets isolated scenario state. Run tests in parallel with zero interference. **No App Restart** - Switch scenarios instantly during test execution. ## Next Steps * **[Example App on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example)** - Clone and run the complete working example * **[Testing Database Apps →](/guides/testing-database-apps/)** - Learn how to test Next.js apps that use both databases and external APIs * **[Architecture →](/concepts/architecture/)** - Learn how Scenarist works under the hood
# Testing React Server Components
> Complete guide to testing React Server Components with Scenarist - data fetching, stateful mocks, sequences, streaming, authentication, server actions, and error handling
React Server Components (RSC) represent a fundamental shift in how React applications render - but they also create new testing challenges. This guide shows you how to effectively test RSC using Scenarist and Playwright. ## Why Server Components Need Different Testing ### The Testing Gap Where do Server Components fit in the testing pyramid? They don’t fit neatly into traditional categories: * **Not isolated units** - They fetch data, read cookies, access headers, and depend on server infrastructure * **Not traditional integration tests** - They render UI, not just return data * **Full E2E is too slow** - Spinning up browsers for every scenario combination doesn’t scale ### The Jest Problem
```typescript
// This FAILS in Jest:
import { render } from '@testing-library/react';
import ProductsPage from './app/products/page';
test('renders products', async () => {
render(); // Error: Objects are not valid as a React child (found: [object Promise])
});
```
Jest and React Testing Library cannot render async Server Components because: 1. **Server Components return Promises** - RTL expects synchronous React elements 2. **Server-only APIs** - `headers()`, `cookies()` from `next/headers` throw outside Next.js 3. **No browser environment** - Server Components have no DOM to render into From the [Next.js Testing Documentation](https://nextjs.org/docs/app/building-your-application/testing): > “Since async Server Components are new to the React ecosystem, some tools do not fully support them. In the meantime, we recommend using End-to-End Testing over Unit Testing for async components.” ### The Scenarist Solution Scenarist + Playwright fills this gap by testing your server-side code **as it actually runs** - in a real Next.js environment with actual server-side rendering, middleware execution, and session handling:
```typescript
// This WORKS with Scenarist + Playwright:
import { test, expect } from './fixtures';
test('premium users see discounted pricing', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products?tier=premium');
// Everything executes: middleware, session checks, RSC data fetching, rendering
await expect(page.getByText('£99.99')).toBeVisible();
});
```
### What This Enables for RSC Testing Server Components with Scenarist gives you capabilities that unit tests cannot provide: | RSC Challenge | Unit Tests | Scenarist + Playwright | | ------------------------------ | ---------------------------- | ---------------------------------- | | Async component rendering | ❌ RTL can’t render Promises | ✅ Full server-side rendering | | `headers()` / `cookies()` APIs | ❌ Throw outside Next.js | ✅ Real Next.js execution | | Data fetching in components | ❌ Must mock fetch globally | ✅ Real fetch, mocked external APIs | | Error boundaries | ❌ Must mock error conditions | ✅ Real error propagation | **Why this matters for RSC specifically:** * **No mocking Next.js internals** - `headers()`, `cookies()`, and other server APIs work naturally * **Real server-side rendering** - HTML is generated exactly as in production * **Actual component composition** - Parent/child RSC relationships execute correctly * **Fast scenario switching** - Test many data fetching scenarios without server restarts ## Setup Requirements for App Router Before testing RSC patterns, ensure your setup includes header forwarding. This is critical because Server Components need to forward the test ID header to external APIs. ### Header Forwarding in Server Components Server Components use `headers()` from `next/headers`, which returns `ReadonlyHeaders`. Use the dedicated helper: app/products/page.tsx
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
export default async function ProductsPage() {
const headersList = await headers();
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList), // Forward test ID
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
cache: 'no-store', // Never serve a cached response to a different test
});
const products = await response.json();
return ;
}
```
For complete setup instructions, see [Getting Started with Next.js App Router](/frameworks/nextjs-app-router/getting-started/). *** ## RSC Testing Patterns This guide covers seven patterns for testing React Server Components. Choose based on your testing needs: [Data Fetching Patterns](/frameworks/nextjs-app-router/rsc/data-fetching/)Core patterns: fetching data, stateful mocks, and sequences [Streaming & Suspense](/frameworks/nextjs-app-router/rsc/streaming/)Testing Suspense boundaries and streaming content [User Interactions](/frameworks/nextjs-app-router/rsc/interactions/)Authentication, Server Actions, and error boundaries [Troubleshooting](/frameworks/nextjs-app-router/rsc/troubleshooting/)Common pitfalls and debugging tips ### Pattern Overview | Pattern | Page | Use Case | | -------------------- | ----------------------------------------------------------------- | ------------------------------------------------- | | **Data Fetching** | [Data Fetching](/frameworks/nextjs-app-router/rsc/data-fetching/) | Basic RSC data fetching with request matching | | **Stateful Mocks** | [Data Fetching](/frameworks/nextjs-app-router/rsc/data-fetching/) | Shopping carts, state that builds across requests | | **Sequences** | [Data Fetching](/frameworks/nextjs-app-router/rsc/data-fetching/) | Polling, retry logic, multi-step workflows | | **Streaming** | [Streaming](/frameworks/nextjs-app-router/rsc/streaming/) | Suspense boundaries, progressive loading | | **Authentication** | [Interactions](/frameworks/nextjs-app-router/rsc/interactions/) | Protected routes, session handling | | **Server Actions** | [Interactions](/frameworks/nextjs-app-router/rsc/interactions/) | Form submissions, mutations | | **Error Boundaries** | [Interactions](/frameworks/nextjs-app-router/rsc/interactions/) | Error handling and recovery | *** ## Next Steps * **[Data Fetching Patterns](/frameworks/nextjs-app-router/rsc/data-fetching/)** - Start here for core RSC testing patterns * **[Next.js App Router Getting Started](/frameworks/nextjs-app-router/getting-started/)** - Complete setup guide * **[Example App](/frameworks/nextjs-app-router/example-app/)** - Full working example with all patterns
# Data Fetching Patterns
> Core patterns for testing React Server Components - data fetching, stateful mocks, and sequences
This page covers the foundational patterns for testing React Server Components with Scenarist. These patterns form the basis for all RSC testing. ## Pattern 1: Data Fetching in Server Components The most common RSC pattern is fetching data server-side. Scenarist makes this testable by intercepting the fetch calls and returning scenario-defined responses. ### Example: Products Page **Server Component:** [`app/products/page.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/products/page.tsx) app/products/page.tsx
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
type ProductsPageProps = {
searchParams: Promise<{ tier?: string }>;
};
async function fetchProducts(tier: string = 'standard'): Promise {
const headersList = await headers();
const response = await fetch('http://localhost:3001/products', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList),
'x-user-tier': tier, // Application context for API
},
cache: 'no-store',
});
return response.json();
}
export default async function ProductsPage({ searchParams }: ProductsPageProps) {
const { tier = 'standard' } = await searchParams;
const data = await fetchProducts(tier);
return (
Products
{data.products.map((product) => (
{product.name}
£{product.price.toFixed(2)}
))}
);
}
```
### Scenario Definition **Scenarios:** [`lib/scenarios.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/lib/scenarios.ts) lib/scenarios.ts
```typescript
import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app';
export const premiumUserScenario: ScenaristScenario = {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier pricing',
mocks: [
{
method: 'GET',
url: 'http://localhost:3001/products',
match: {
headers: { 'x-user-tier': 'premium' },
},
response: {
status: 200,
body: {
products: [
{ id: 1, name: 'Product A', price: 99.99, tier: 'premium' },
{ id: 2, name: 'Product B', price: 199.99, tier: 'premium' },
],
},
},
},
],
};
export const standardUserScenario: ScenaristScenario = {
id: 'standardUser',
name: 'Standard User',
description: 'Standard tier pricing',
mocks: [
{
method: 'GET',
url: 'http://localhost:3001/products',
match: {
headers: { 'x-user-tier': 'standard' },
},
response: {
status: 200,
body: {
products: [
{ id: 1, name: 'Product A', price: 149.99, tier: 'standard' },
{ id: 2, name: 'Product B', price: 249.99, tier: 'standard' },
],
},
},
},
],
};
```
### Test Implementation **Test:** [`tests/playwright/products-server-components.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/products-server-components.spec.ts) tests/playwright/products-server-components.spec.ts
```typescript
import { test, expect } from './fixtures';
test.describe('Products Page - React Server Components', () => {
test('should render products with premium tier pricing', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products?tier=premium');
// Verify Server Component rendered
await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
// Verify premium pricing from mocked API
await expect(page.getByText('£99.99')).toBeVisible();
});
test('should render products with standard tier pricing', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'standardUser');
await page.goto('/products?tier=standard');
// Verify standard pricing from mocked API
await expect(page.getByText('£149.99')).toBeVisible();
});
test('should switch tiers at runtime without app restart', async ({
page,
switchScenario,
}) => {
// Start with premium
await switchScenario(page, 'premiumUser');
await page.goto('/products?tier=premium');
await expect(page.getByText('£99.99')).toBeVisible();
// Switch to standard - no restart needed!
await switchScenario(page, 'standardUser');
await page.goto('/products?tier=standard');
await expect(page.getByText('£149.99')).toBeVisible();
});
});
```
*** ## Pattern 2: Stateful Mocks with RSC Stateful mocks capture data from one request and inject it into later responses. This is essential for testing flows like shopping carts where state builds up across multiple requests. **State is isolated per test ID**, so parallel tests never conflict—each test maintains its own cart state. ### Example: Server-Side Cart **Server Component:** [`app/cart-server/page.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/cart-server/page.tsx) app/cart-server/page.tsx
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
import { getAppBaseURL } from '@/lib/app-base-url';
type CartResponse = {
// null until the first PATCH captures state
readonly items?: ReadonlyArray | null;
};
type CartItem = {
readonly id: string;
readonly name: string;
readonly quantity: number;
};
const PRODUCT_NAMES: Record = {
'prod-1': 'Product A',
'prod-2': 'Product B',
'prod-3': 'Product C',
};
// Turn the raw productId array from state into items with quantities
const aggregateCartItems = (
productIds: ReadonlyArray | null | undefined,
): ReadonlyArray => {
const counts = (productIds ?? []).reduce>(
(acc, id) => ({ ...acc, [id]: (acc[id] ?? 0) + 1 }),
{},
);
return Object.entries(counts).map(([id, quantity]) => ({
id,
name: PRODUCT_NAMES[id] ?? `Unknown Product (${id})`,
quantity,
}));
};
async function fetchCart(): Promise {
const headersList = await headers();
// Calls the app's own /api/cart route, which forwards the headers to
// GET http://localhost:3001/cart
const response = await fetch(new URL('/api/cart', getAppBaseURL()), {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList),
},
cache: 'no-store',
});
return response.json();
}
export default async function CartServerPage() {
const cartData = await fetchCart();
const cartItems = aggregateCartItems(cartData.items);
return (
Shopping Cart
{cartItems.length === 0 ? (
Your cart is empty
) : (
{cartItems.map((item) => (
{item.name}
Quantity: {item.quantity}
))}
)}
);
}
```
### Stateful Scenario Definition lib/scenarios.ts
```typescript
import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app';
export const cartWithStateScenario: ScenaristScenario = {
id: 'cartWithState',
name: 'Shopping Cart with State',
description: 'Stateful cart that captures and injects items',
mocks: [
// GET /cart - Inject cartItems from state (null initially)
{
method: 'GET',
url: 'http://localhost:3001/cart',
response: {
status: 200,
body: {
items: '{{state.cartItems}}', // Template injection from state
},
},
},
// PATCH /cart - Capture full items array into state
{
method: 'PATCH',
url: 'http://localhost:3001/cart',
captureState: {
cartItems: 'body.items', // Capture from request body
},
response: {
status: 200,
body: {
items: '{{state.cartItems}}', // Echo back the captured items
},
},
},
],
};
```
### Test Implementation **Test:** [`tests/playwright/cart-server-components.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/cart-server-components.spec.ts) tests/playwright/cart-server-components.spec.ts
```typescript
import { test, expect } from './fixtures';
test.describe('Cart Server Page - Stateful Mocks', () => {
test('should show empty cart initially', async ({ page, switchScenario }) => {
await switchScenario(page, 'cartWithState');
await page.goto('/cart-server');
await expect(page.getByText('Your cart is empty')).toBeVisible();
});
test('should display cart item after adding product', async ({
page,
switchScenario,
}) => {
const testId = await switchScenario(page, 'cartWithState');
// Add product through API route
// Note: page.request uses a separate context, so include test ID header
await page.request.post('http://localhost:3002/api/cart/add', {
headers: {
'Content-Type': 'application/json',
'x-scenarist-test-id': testId,
},
data: { productId: 'prod-1' },
});
// Navigate to cart - Server Component fetches with same test ID
await page.goto('/cart-server');
// State was captured from POST and injected into GET response
await expect(page.getByText('Product A')).toBeVisible();
await expect(page.getByText('Quantity: 1')).toBeVisible();
});
test('should aggregate quantities for same product', async ({
page,
switchScenario,
}) => {
const testId = await switchScenario(page, 'cartWithState');
// Add same product 3 times
for (let i = 0; i < 3; i++) {
await page.request.post('http://localhost:3002/api/cart/add', {
headers: {
'Content-Type': 'application/json',
'x-scenarist-test-id': testId,
},
data: { productId: 'prod-1' },
});
}
await page.goto('/cart-server');
// Should show aggregated quantity
await expect(page.getByText('Quantity: 3')).toBeVisible();
});
});
```
*** ## Pattern 3: Polling & Sequences in RSC Sequences return different responses on successive requests - perfect for testing polling scenarios, retry logic, or multi-step workflows. ### Example: Job Polling Page **Server Component:** [`app/polling/page.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/polling/page.tsx) app/polling/page.tsx
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
type JobStatus = {
readonly jobId: string;
readonly status: 'pending' | 'processing' | 'complete';
readonly progress: number;
};
async function fetchJobStatus(jobId: string): Promise {
const headersList = await headers();
const response = await fetch(`http://localhost:3001/github/jobs/${jobId}`, {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList),
},
cache: 'no-store',
});
return response.json();
}
export default async function PollingPage({ searchParams }) {
const { jobId = '123' } = await searchParams;
const job = await fetchJobStatus(jobId);
return (
Job Status
{job.status.toUpperCase()}
Progress: {job.progress}%
);
}
```
### Sequence Scenario Definition lib/scenarios.ts
```typescript
import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app';
export const githubPollingScenario: ScenaristScenario = {
id: 'githubPolling',
name: 'GitHub Job Polling',
description: 'Polling sequence: pending → processing → complete',
mocks: [
{
method: 'GET',
url: 'http://localhost:3001/github/jobs/:id',
sequence: {
responses: [
{
status: 200,
body: { jobId: '123', status: 'pending', progress: 0 },
},
{
status: 200,
body: { jobId: '123', status: 'processing', progress: 50 },
},
{
status: 200,
body: { jobId: '123', status: 'complete', progress: 100 },
},
],
repeat: 'last', // After exhaustion, keep returning 'complete'
},
},
],
};
```
### Test Implementation **Test:** [`tests/playwright/polling-server-components.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/polling-server-components.spec.ts) tests/playwright/polling-server-components.spec.ts
```typescript
import { test, expect } from './fixtures';
test.describe('Polling Page - Sequences with Server Components', () => {
test('should show pending status on first request', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'githubPolling');
await page.goto('/polling?jobId=123');
// First sequence position: pending
await expect(page.getByText('PENDING')).toBeVisible();
await expect(page.getByText('0%')).toBeVisible();
});
test('should advance through sequence on page reloads', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'githubPolling');
// First request: pending
await page.goto('/polling?jobId=123');
await expect(page.getByText('PENDING')).toBeVisible();
// Second request: processing
await page.reload();
await expect(page.getByText('PROCESSING')).toBeVisible();
await expect(page.getByText('50%')).toBeVisible();
// Third request: complete
await page.reload();
await expect(page.getByText('COMPLETE')).toBeVisible();
await expect(page.getByText('100%')).toBeVisible();
});
test('should repeat last response after sequence exhaustion', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'githubPolling');
// Advance through all sequence positions
await page.goto('/polling?jobId=123'); // pending
await page.reload(); // processing
await page.reload(); // complete
// Verify complete
await expect(page.getByText('COMPLETE')).toBeVisible();
// Fourth request - should still be complete (repeat: 'last')
await page.reload();
await expect(page.getByText('COMPLETE')).toBeVisible();
});
});
```
### Sequence Repeat Modes | Mode | Behavior | Use Case | | --------- | ----------------------------- | ------------------------------ | | `'last'` | Repeat final response forever | Polling until completion | | `'cycle'` | Loop back to first response | Cyclical patterns (weather) | | `'none'` | Fall through to next mock | Rate limiting after N attempts | *** ## Next Steps * **[Streaming & Suspense](/frameworks/nextjs-app-router/rsc/streaming/)** - Testing Suspense boundaries and streaming content * **[User Interactions](/frameworks/nextjs-app-router/rsc/interactions/)** - Authentication, Server Actions, and error boundaries * **[Troubleshooting](/frameworks/nextjs-app-router/rsc/troubleshooting/)** - Common pitfalls and debugging tips
# User Interactions
> Testing authentication flows, Server Actions, and error boundaries in React Server Components
This page covers patterns for testing user interactions in React Server Components: authentication flows, form submissions via Server Actions, and error handling with error boundaries. ## Authentication Flows Authentication is a critical RSC pattern - protected routes must check auth status server-side before rendering. Scenarist makes this testable by letting you switch between authenticated and unauthenticated states. ### Example: Protected Route with Auth Check **Auth Helper:** [`lib/auth.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/lib/auth.ts) lib/auth.ts
```typescript
import { z } from 'zod';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
import type { ReadonlyHeaders } from 'next/dist/server/web/spec-extension/adapters/headers';
const UserSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string(),
});
type User = z.infer;
type AuthResult =
| { readonly authenticated: true; readonly user: User }
| { readonly authenticated: false; readonly error: string };
export const checkAuth = async (
headersList: ReadonlyHeaders,
): Promise => {
const response = await fetch('http://localhost:3001/auth/me', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList),
},
cache: 'no-store', // Don't cache auth checks
});
if (!response.ok) {
return { authenticated: false, error: 'Authentication required' };
}
const data: unknown = await response.json();
const user = UserSchema.parse(data);
return { authenticated: true, user };
};
```
**Protected Layout:** [`app/protected/layout.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/protected/layout.tsx) app/protected/layout.tsx
```typescript
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { checkAuth } from '@/lib/auth';
type ProtectedLayoutProps = {
children: React.ReactNode;
};
export default async function ProtectedLayout({
children,
}: ProtectedLayoutProps) {
const headersList = await headers();
const auth = await checkAuth(headersList);
if (!auth.authenticated) {
// Redirect to login with the original URL
redirect('/login?from=/protected');
}
// User is authenticated - render with user context
return (
);
}
```
## Scenario Definition lib/scenarios.ts
```typescript
import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app';
export const streamingScenario: ScenaristScenario = {
id: 'streaming',
name: 'Streaming Demo',
description: 'Demonstrates Suspense boundary with streaming RSC',
mocks: [
{
method: 'GET',
url: 'http://localhost:3001/products',
match: {
headers: { 'x-user-tier': 'standard' },
},
response: {
status: 200,
body: {
products: [
{ id: 1, name: 'Product A', price: 149.99, tier: 'standard' },
{ id: 2, name: 'Product B', price: 249.99, tier: 'standard' },
{ id: 3, name: 'Product C', price: 349.99, tier: 'standard' },
],
},
},
},
],
};
export const streamingPremiumUserScenario: ScenaristScenario = {
id: 'streamingPremiumUser',
name: 'Streaming Demo (Premium)',
description: 'Streaming with premium tier products',
mocks: [
{
method: 'GET',
url: 'http://localhost:3001/products',
match: {
headers: { 'x-user-tier': 'premium' },
},
response: {
status: 200,
body: {
products: [
{ id: 1, name: 'Product A', price: 99.99, tier: 'premium' },
{ id: 2, name: 'Product B', price: 199.99, tier: 'premium' },
{ id: 3, name: 'Product C', price: 299.99, tier: 'premium' },
],
},
},
},
],
};
```
## Test Implementation **Test:** [`tests/playwright/streaming.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/streaming.spec.ts) tests/playwright/streaming.spec.ts
```typescript
import { test, expect } from './fixtures';
test.describe('Streaming Page - Suspense Boundaries', () => {
test('should render products after Suspense boundary resolves', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'streaming');
await page.goto('/streaming');
// Wait for products to render (Suspense resolved)
await expect(page.getByRole('article')).toHaveCount(3);
// Verify product names are visible
await expect(page.getByRole('heading', { name: 'Product A' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Product B' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Product C' })).toBeVisible();
});
test('should render standard tier products with standard pricing', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'streaming');
await page.goto('/streaming?tier=standard');
// Standard price for Product A is £149.99
await expect(page.getByText('£149.99')).toBeVisible();
// Verify tier badge shows standard
const firstProduct = page.getByRole('article').first();
await expect(firstProduct.getByText('standard', { exact: false })).toBeVisible();
});
test('should render premium tier products with premium pricing', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'streamingPremiumUser');
await page.goto('/streaming?tier=premium');
// Premium price for Product A is £99.99 (lower than standard)
await expect(page.getByText('£99.99')).toBeVisible();
// Verify tier badge shows premium
const firstProduct = page.getByRole('article').first();
await expect(firstProduct.getByText('premium', { exact: false })).toBeVisible();
});
test('should show loading skeleton initially before products load', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'streaming');
await page.goto('/streaming', { waitUntil: 'domcontentloaded' });
const skeleton = page.getByLabel('Loading products');
// Handle race condition: skeleton may or may not be visible
// depending on how fast the response comes back
await Promise.race([
skeleton.waitFor({ state: 'visible', timeout: 1000 }).catch(() => {}),
page.getByRole('article').first().waitFor({ state: 'visible' }),
]);
// Eventually, products should appear
await expect(page.getByRole('article')).toHaveCount(3);
// Skeleton should no longer be visible
await expect(skeleton).not.toBeVisible();
});
});
```
## Key Points for Streaming Tests | Aspect | Consideration | | ----------------------- | ----------------------------------------------------------------------------------- | | **Fallback visibility** | May be brief; use race conditions or `waitUntil: 'domcontentloaded'` | | **Scenario isolation** | Different scenarios (standard/premium) verify correct data flows through | | **Header forwarding** | Async component must forward test ID via `getScenaristHeadersFromReadonlyHeaders()` | | **Aria labels** | Add `aria-label` to skeleton for reliable test selection | *** ## Next Steps * **[User Interactions](/frameworks/nextjs-app-router/rsc/interactions/)** - Authentication, Server Actions, and error boundaries * **[Troubleshooting](/frameworks/nextjs-app-router/rsc/troubleshooting/)** - Common pitfalls and debugging tips * **[Data Fetching Patterns](/frameworks/nextjs-app-router/rsc/data-fetching/)** - Core patterns: fetching, stateful mocks, sequences
# Troubleshooting
> Common pitfalls and debugging tips for testing React Server Components with Scenarist
This page covers common issues you may encounter when testing React Server Components with Scenarist, along with debugging tips. ## Common Pitfalls ### Pitfall 1: Missing Header Forwarding **Symptom:** Tests fail with “no mock matched” or default responses instead of scenario-specific ones. **Cause:** Server Component fetches don’t include the test ID header. **Fix:** Always forward headers:
```typescript
// ✅ Correct
const headersList = await headers();
const response = await fetch(url, {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList),
},
cache: 'no-store',
});
// ❌ Wrong - missing header forwarding
const response = await fetch(url);
```
### Pitfall 2: page.request Doesn’t Include Test ID **Symptom:** API calls from `page.request.post()` don’t use the correct scenario. **Cause:** `page.request` uses a separate HTTP context from the browser page. **Fix:** Explicitly include the test ID header:
```typescript
test('adds item to cart', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'cartWithState'); // ✅ Capture testId
await page.request.post('http://localhost:3002/api/cart/add', {
headers: {
'Content-Type': 'application/json',
'x-scenarist-test-id': testId, // ✅ Explicitly include
},
data: { productId: 'prod-1' },
});
});
```
### Pitfall 3: Next.js Caching **Symptom:** Same response returned despite scenario changes. **Cause:** Next.js caches fetch responses by default. **Fix:** Disable caching for testable fetches:
```typescript
const response = await fetch(url, {
headers: { ... },
cache: 'no-store', // ✅ Disable caching
});
```
### Pitfall 4: Sequence Not Advancing **Symptom:** Same response returned on every request. **Cause:** Requests going to a different mock or different test ID. **Fix:** Ensure URL matches exactly and headers are forwarded:
```typescript
// Scenario mock
{ method: 'GET', url: 'http://localhost:3001/github/jobs/:id', sequence: {...} }
// Component fetch - must match URL pattern
await fetch(`http://localhost:3001/github/jobs/${jobId}`, { // ✅ Matches pattern
headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList) },
cache: 'no-store',
});
```
*** ## Debugging Tips Scenarist has built-in logging that shows exactly what’s happening with scenario switching, mock matching, and state management. This is far more useful than generic network debugging. ### 1. Enable Scenarist Logging Add a console logger to your Scenarist setup:
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({
enabled: true,
scenarios,
// Enable logging at info level
logger: createConsoleLogger({ level: 'info' }),
});
```
This shows key events like scenario switches and mock selections:
```plaintext
12:34:56.789 INF 🎬 [test-abc-123] scenario | scenario_switched scenarioId=premiumUser
12:34:56.801 INF 🎯 [test-abc-123] matching | mock_selected mockIndex=2 specificity=5
```
### 2. Debug Mock Matching Issues When a mock isn’t being selected, enable debug logging for the `matching` category:
```typescript
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching'],
});
```
You’ll see: * `mock_candidates_found` - How many mocks could potentially match * `mock_match_evaluated` - Each mock’s evaluation result * `mock_selected` - Which mock was chosen and why * `mock_no_match` - When no mock matched (with the URL that failed) ### 3. Environment Variable Pattern For easy toggling without code changes:
```typescript
import {
createScenarist,
createConsoleLogger,
noOpLogger,
} from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({
enabled: true,
scenarios,
// Enable via SCENARIST_LOG=1
logger: process.env.SCENARIST_LOG
? createConsoleLogger({ level: 'debug' })
: noOpLogger,
});
```
Then run tests with logging:
```bash
SCENARIST_LOG=1 pnpm test
```
### 4. Check for URL Mismatches Ensure your mock URLs exactly match what your components are fetching:
```typescript
// Mock definition
{
method: 'GET',
url: 'http://localhost:3001/products', // Must match exactly
// ...
}
// Component fetch - common mistakes:
await fetch('http://localhost:3001/products'); // ✅ Matches
await fetch('http://localhost:3001/products/'); // ❌ Trailing slash
await fetch('http://localhost:3001/Products'); // ❌ Case mismatch
await fetch('/products'); // ❌ Relative URL
```
### 5. Verify Header Forwarding in Server Components Add temporary logging to verify headers are being forwarded:
```typescript
export default async function MyPage() {
const headersList = await headers();
const scenaristHeaders = getScenaristHeadersFromReadonlyHeaders(headersList);
// Temporary debug logging
console.log('Scenarist headers:', scenaristHeaders);
const response = await fetch('http://localhost:3001/api/data', {
headers: {
...scenaristHeaders,
},
cache: 'no-store',
});
// ...
}
```
### 6. Debug State in Playwright Tests The `@scenarist/playwright-helpers` package provides fixtures to inspect test state directly from your Playwright tests. They read from the state endpoint configured by `scenaristStateEndpoint` (default `/__scenarist__/state`). In the App Router, create `app/api/%5F%5Fscenarist%5F%5F/state/route.ts` exporting `GET = scenarist?.createStateEndpoint()` and set `scenaristStateEndpoint: '/api/__scenarist__/state'` in your Playwright config, as the example app does:
```typescript
// Import from your configured fixtures file
import { test, expect } from './fixtures';
test('checkout flow updates cart state', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'cartWithState');
// Check initial state
const initialState = await debugState(page);
console.log('Initial state:', initialState);
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
// Verify state after action
const updatedState = await debugState(page);
console.log('Updated state:', updatedState);
expect(updatedState.checkoutStarted).toBe(true);
});
```
For async flows where state changes after a delay, use `waitForDebugState`:
```typescript
test('polling updates job status', async ({ page, switchScenario, waitForDebugState }) => {
await switchScenario(page, 'jobPolling');
await page.goto('/jobs/123');
// Wait for state to indicate job completion
const state = await waitForDebugState(
page,
(s) => s.jobStatus === 'completed',
{ timeout: 10000 } // Wait up to 10 seconds
);
expect(state.jobStatus).toBe('completed');
});
```
*** ## Quick Reference: Common Issues | Issue | Likely Cause | Solution | | ----------------------- | ------------------------------ | ---------------------------------------------- | | “No mock matched” | Missing header forwarding | Add `getScenaristHeadersFromReadonlyHeaders()` | | Wrong scenario response | `page.request` missing test ID | Explicitly pass `x-scenarist-test-id` header | | Stale responses | Next.js caching | Add `cache: 'no-store'` to fetch | | Sequence stuck | URL mismatch or wrong test ID | Verify URL pattern and header forwarding | | Intermittent failures | Parallel test isolation | Ensure each test uses unique test ID | *** ## Next Steps * **[Testing RSC Overview](/frameworks/nextjs-app-router/rsc/)** - Return to the main RSC testing guide * **[Data Fetching Patterns](/frameworks/nextjs-app-router/rsc/data-fetching/)** - Core patterns for RSC testing * **[Debugging with Logs](/reference/logging/)** - Detailed logging configuration
# Next.js Pages Router
> Using Scenarist with Next.js Pages Router (API Routes, getServerSideProps, getStaticProps)
## Testing Next.js Pages Router with Scenarist The Pages Router is Next.js’s traditional routing system using the pages directory. Scenarist enables testing API routes and server-side rendering without complex mocking, allowing you to test your application code with real HTTP requests. ### The Challenge Testing Pages Router applications traditionally requires choosing between: * **Unit testing API routes** in isolation (misses integration with middleware and framework features) * **Mocking framework features** (getServerSideProps, request/response objects) which creates distance from production * **Testing each API route separately** with mocked dependencies (creates test maintenance burden) **Specific Pages Router challenges:** * API routes need testing with different external API scenarios * getServerSideProps executes server-side and requires mocking fetch or external APIs * getStaticProps needs testing with various data states * Framework internals must be mocked for unit tests ### How Scenarist Helps Scenarist enables HTTP-level testing for Pages Router applications: * **Test API routes** with real HTTP requests and different external API scenarios * **Test getServerSideProps** without mocking Next.js internals * **Test getStaticProps** against your default scenario (it has no request to carry a test ID) * **Run parallel tests** without interference * **No restarts** - each test switches scenario at runtime against one running server * **Automatic singleton protection** - Handles Next.js module duplication for you (no `globalThis` boilerplate needed) ## Pages Router Features Supported ### API Routes Test API routes with different scenarios: pages/api/checkout.ts
```typescript
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/pages";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const response = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: getScenaristHeaders(req), // Forward the test ID
body: JSON.stringify(req.body),
});
const data = await response.json();
res.status(200).json(data);
}
// Test with different payment scenarios
test("processes successful payment", async ({ page, switchScenario }) => {
const testId = await switchScenario(page, "paymentSuccess");
const response = await page.request.post("/api/checkout", {
headers: { "x-scenarist-test-id": testId }, // page.request needs the test ID explicitly
data: { amount: 5000, token: "tok_test" },
});
expect(response.ok()).toBe(true);
});
```
### getServerSideProps Test server-side rendering with different external API responses: pages/products.tsx
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export async function getServerSideProps(context) {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: getScenaristHeaders(context.req), // Forward the test ID
});
const { data: products } = await response.json();
return { props: { products } };
}
export default function ProductsPage({ products }) {
return ;
}
// Test without mocking Next.js internals
test('renders products from external API', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await expect(page.getByText('Premium Product')).toBeVisible();
});
```
### getStaticProps `getStaticProps` receives no request object, so it cannot forward the test ID header. Its external calls resolve against the default test ID (`default-test`) and use your `default` scenario rather than a per-test scenario. In production builds it runs at build time: pages/index.tsx
```typescript
export async function getStaticProps() {
const response = await fetch('https://api.products.example.com/featured');
const { data: featured } = await response.json();
return {
props: { featured },
revalidate: 60 // ISR
};
}
export default function HomePage({ featured }) {
return ;
}
// Test against the default scenario's featured products
test('renders featured products', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Featured')).toBeVisible();
});
```
## Getting Started Ready to integrate Scenarist into your Next.js Pages Router application? [**Get started with Pages Router →**](/frameworks/nextjs-pages-router/getting-started/) ## Key Benefits **API Routes Execute Normally** - Your validation, error handling, and business logic all run as they would in production. **Server-Side Rendering Works** - Your getServerSideProps and getStaticProps fetch data from mocked external APIs. **Test Isolation** - Each test gets isolated scenario state. Run tests in parallel with zero interference. **No App Restart** - Switch scenarios instantly during test execution. **Real HTTP Requests** - Tests make actual HTTP requests to your Next.js app, exercising middleware and routing. ## Next Steps * [Pages Router Getting Started →](/frameworks/nextjs-pages-router/getting-started/) - Set up Scenarist in your Pages Router app * [Next.js General Guide →](/frameworks/nextjs/) - Learn about Next.js testing with Scenarist
# Next.js Pages Router Example App
> Working example demonstrating Scenarist with Next.js Pages Router
## Overview The Next.js Pages Router example demonstrates HTTP-level testing for API routes, getServerSideProps, and client components using Scenarist. **GitHub:** [apps/nextjs-pages-router-example](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example) ## What It Demonstrates This example app showcases all major Scenarist features with Pages Router: ### Core Features * **API Routes** - Test Next.js API routes with different external API responses * **getServerSideProps** - Test server-side rendering with mocked external APIs * **Client Components** - Test client-side hydration with backend scenarios * **Runtime Scenario Switching** - Multiple scenarios running concurrently ### Dynamic Response Features * **Request Matching** - Different responses based on request content (tier-based pricing) * **Sequences** - Polling scenarios (pending → processing → complete) * **Stateful Mocks** - Shopping cart with state capture and injection * **Feature Composition** - Checkout flow combining matching and stateful mocks ## Installation ### Prerequisites * Node.js 22+ * pnpm 11+ ### Clone and Install
```bash
# Clone the repository
git clone https://github.com/citypaul/scenarist.git
cd scenarist
# Install dependencies
pnpm install
# Navigate to Pages Router example
cd apps/nextjs-pages-router-example
```
## Running the Example ### Development Mode
```bash
# Start the Next.js dev server
pnpm dev
```
Visit to see the app. ### Run Tests
```bash
# Run all tests
pnpm test
# Run tests in UI mode
pnpm test:e2e:ui
# Run specific test file
pnpm test products-server-side
```
## Key Files ### Scenarist Setup **`lib/scenarist.ts`** - Scenarist configuration
```typescript
import { createScenarist } from '@scenarist/nextjs-adapter/pages';
import { scenarios } from './scenarios';
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Auto-start MSW server for server-side interception (scenarist is undefined in production builds)
if (typeof window === 'undefined' && scenarist) {
scenarist.start();
}
```
**`pages/api/__scenario__.ts`** - Scenario control endpoint
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { scenarist } from '../../lib/scenarist';
// scenarist is undefined in production builds or when enabled is false - fall back to a 405 handler
export default scenarist?.createScenarioEndpoint() ??
((_req: NextApiRequest, res: NextApiResponse) => {
res.status(405).end();
});
```
This creates the `/api/__scenario__` endpoint used by tests to switch scenarios. `pages/api/__scenarist__/state.ts` exports `scenarist?.createStateEndpoint()` for the `/api/__scenarist__/state` debug endpoint. ### Scenario Definitions **`lib/scenarios.ts`** - All scenario definitions ([view on GitHub](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-pages-router-example/lib/scenarios.ts)) Key scenarios: **`default`** - Default baseline behavior **`premiumUser`** - Premium tier with request matching
```typescript
premiumUser: {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier pricing (£99.99)',
mocks: [{
method: 'GET',
url: 'http://localhost:3001/products',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { products: buildProducts('premium') } }
}]
}
```
**`githubPolling`** - Polling sequence (pending → processing → complete)
```typescript
githubPolling: {
id: 'githubPolling',
name: 'GitHub Job Polling',
description: "Async job polling sequence (repeat: 'last')",
mocks: [{
method: 'GET',
url: 'http://localhost:3001/github/jobs/:id',
sequence: {
responses: [
{ status: 200, body: { jobId: '123', status: 'pending', progress: 0 } },
{ status: 200, body: { jobId: '123', status: 'processing', progress: 50 } },
{ status: 200, body: { jobId: '123', status: 'complete', progress: 100 } }
],
repeat: 'last'
}
}]
}
```
**`cartWithState`** - Stateful shopping cart
```typescript
cartWithState: {
id: 'cartWithState',
name: 'Shopping Cart with State',
description: 'Stateful shopping cart that captures and injects cart items',
mocks: [
// (also mocks GET /products so the add-to-cart buttons render)
{
method: 'GET',
url: 'http://localhost:3001/cart',
response: {
status: 200,
body: { items: '{{state.cartItems}}' }
}
},
{
method: 'PATCH',
url: 'http://localhost:3001/cart',
captureState: {
cartItems: 'body.items'
},
response: { status: 200, body: { items: '{{state.cartItems}}' } }
}
]
}
```
**`checkout`** - Feature composition (matching + state)
```typescript
checkout: {
id: 'checkout',
name: 'Checkout with Shipping',
description: 'Demonstrates matching + stateful composition',
mocks: [
// UK free shipping (US and FR variants follow the same shape)
{
method: 'POST',
url: 'http://localhost:3001/checkout/shipping',
match: { body: { country: 'UK' } },
captureState: { country: 'body.country', address: 'body.address' },
response: { status: 200, body: { country: 'UK', shippingCost: 0 } }
},
// Order with captured address
{
method: 'POST',
url: 'http://localhost:3001/checkout/order',
response: {
status: 200,
body: {
shippingAddress: {
country: '{{state.country}}',
address: '{{state.address}}'
}
}
}
}
]
}
```
### API Route Examples **`pages/api/products.ts`** - Products API route with tier-based pricing
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const tier = req.headers['x-user-tier'] || 'standard';
// This fetch is mocked by Scenarist
const response = await fetch('http://localhost:3001/products', {
headers: {
...getScenaristHeaders(req), // Forward the test ID
'x-user-tier': tier,
},
});
const data = await response.json();
res.status(200).json(data);
}
```
**`pages/api/cart/add.ts`** - Add-to-cart API route (state captured)
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// GET current cart - state injected by Scenarist
const current = await fetch('http://localhost:3001/cart', {
headers: getScenaristHeaders(req),
}).then((r) => r.json());
// PATCH full items array - state captured by Scenarist
const response = await fetch('http://localhost:3001/cart', {
method: 'PATCH',
headers: { ...getScenaristHeaders(req), 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [...(current.items || []), req.body.productId], // raw productIds
}),
});
const data = await response.json();
res.status(200).json({ success: true, items: data.items });
}
```
`pages/api/cart.ts` serves `GET /api/cart` the same way, forwarding `getScenaristHeaders(req)` to `GET http://localhost:3001/cart`. ### Test Examples **`tests/playwright/products-server-side.spec.ts`** - getServerSideProps with request matching
```typescript
test('should render premium products server-side', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/?tier=premium');
// getServerSideProps fetches http://localhost:3001/products with x-user-tier header
// Scenarist returns mock matching { headers: { 'x-user-tier': 'premium' } }
const firstProduct = page.getByRole('article').first();
await expect(firstProduct.getByText('£99.99')).toBeVisible();
});
```
**`tests/playwright/sequences.spec.ts`** - Polling with sequences
```typescript
test('polling updates status through sequence', async ({ page, switchScenario }) => {
await switchScenario(page, 'githubPolling');
await page.goto('/sequences');
const checkStatus = page.getByRole('button', { name: 'Check Job Status' });
const jobStatus = page.getByRole('status').first();
// First request: pending
await checkStatus.click();
await expect(jobStatus).toContainText('Status: pending');
// Second request: processing
await checkStatus.click();
await expect(jobStatus).toContainText('Status: processing');
// Third request: complete
await checkStatus.click();
await expect(jobStatus).toContainText('Status: complete');
});
```
**`tests/playwright/cart-server-side.spec.ts`** - Stateful mocks with API routes
```typescript
test('should render cart items server-side after adding products', async ({ page, switchScenario }) => {
await switchScenario(page, 'cartWithState');
// Add products via the UI - POST /api/cart/add, state captured
await page.goto('/');
const addButtons = page.getByRole('button', { name: /Add .* to cart/ });
const cartCount = page.getByLabel('Cart item count');
await addButtons.nth(0).click();
await expect(cartCount).toHaveText('1');
await addButtons.nth(1).click();
await expect(cartCount).toHaveText('2');
// Cart page is server-rendered - state injected
await page.goto('/cart');
const cartList = page.getByRole('list', { name: 'Shopping cart items' });
await expect(cartList.getByRole('listitem')).toHaveCount(2);
});
```
**`tests/playwright/checkout.spec.ts`** - Feature composition
```typescript
test('should provide free shipping for UK and capture address', async ({ page, switchScenario }) => {
await switchScenario(page, 'checkout');
await page.goto('/checkout');
// Calculate shipping - matches UK, captures address
await page.getByLabel('Country').selectOption('UK');
await page.getByLabel('Address').fill('123 Test Street');
await page.getByLabel('City').fill('London');
await page.getByLabel('Postcode').fill('SW1A 1AA');
await page.getByRole('button', { name: 'Calculate Shipping' }).click();
const shippingCost = page.getByRole('status').filter({ hasText: 'Shipping' });
await expect(shippingCost).toContainText('£0.00');
// Place order - injects captured address
await page.getByRole('button', { name: 'Place Order' }).click();
const confirmation = page.getByRole('region', { name: 'Order confirmation' });
await expect(confirmation).toContainText('123 Test Street');
await expect(confirmation).toContainText('UK');
});
```
## Architecture ### How It Works 1. **Setup** - Pages Router app includes Scenarist API endpoint 2. **Test starts** - Calls `switchScenario()` to set active scenario 3. **HTTP request** - Test makes request to Next.js page or API route 4. **Route execution** - API routes and getServerSideProps execute normally 5. **External API call** - Intercepted by MSW with scenario-defined response 6. **Test assertion** - Verifies API response or rendered output ### Test Isolation Each test gets a unique test ID: * Scenario switching: `POST /api/__scenario__` with `x-scenarist-test-id` header * All requests include `x-scenarist-test-id` header automatically (Playwright helper) * Server routes requests to correct scenario based on test ID * Parallel tests don’t interfere with each other ### File Structure ## Common Patterns ### Testing API Routes
```typescript
// API route with external API call
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await fetch('https://api.external.com/data', {
headers: getScenaristHeaders(req), // Forward the test ID
});
const data = await response.json();
res.json(data);
}
// Test with different scenarios
test('standard data', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'default');
const response = await page.request.get('/api/data', {
headers: { 'x-scenarist-test-id': testId }
});
expect(await response.json()).toMatchObject({ tier: 'standard' });
});
test('premium data', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'premiumUser');
const response = await page.request.get('/api/data', {
headers: { 'x-scenarist-test-id': testId }
});
expect(await response.json()).toMatchObject({ tier: 'premium' });
});
```
### Testing getServerSideProps
```typescript
// Page with getServerSideProps
export async function getServerSideProps({ req }) {
const response = await fetch('https://api.external.com/products', {
headers: getScenaristHeaders(req), // Forward the test ID
});
const { products } = await response.json();
return { props: { products } };
}
// Test with different data scenarios
test('renders products from getServerSideProps', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await expect(page.getByText('Premium Product')).toBeVisible();
});
```
### Testing Request Matching Use request content to determine response:
```typescript
// Scenario with tier-based pricing
mocks: [{
method: 'GET',
url: 'http://localhost:3001/products',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { products: buildProducts('premium') } }
}, {
method: 'GET',
url: 'http://localhost:3001/products',
// No match criteria - fallback for standard tier
response: { status: 200, body: { products: buildProducts('standard') } }
}]
```
### Testing Polling Scenarios Use sequences to simulate async operations:
```typescript
// Scenario with polling sequence
mocks: [{
method: 'GET',
url: 'http://localhost:3001/github/jobs/:id',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete' } }
],
repeat: 'last' // After sequence exhausts, repeat last response
}
}]
```
## Next Steps * [Pages Router Getting Started →](/frameworks/nextjs-pages-router/getting-started/) - Integrate Scenarist into your Pages Router app * [Request Matching →](/scenarios/request-matching/) - Learn about request content matching * [Sequences →](/scenarios/response-sequences/) - Learn about response sequences * [Stateful Mocks →](/scenarios/stateful-mocks/) - Learn about state capture and injection
# Next.js Pages Router - Getting Started
> Set up Scenarist with Next.js Pages Router in 5 minutes
Test your Next.js Pages Router application with API routes, getServerSideProps, and getStaticProps all executing. No mocking of Next.js internals required. ## Installation
```bash
npm install @scenarist/nextjs-adapter msw
npm install -D @playwright/test @scenarist/playwright-helpers
```
`msw` (v2) is a required peer dependency of `@scenarist/nextjs-adapter`, which supports Next.js 14, 15 and 16. ## 1. Define Scenarios lib/scenarios.ts
```typescript
import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/nextjs-adapter/pages';
// ✅ RECOMMENDED - Default scenario with complete happy path
const defaultScenario: ScenaristScenario = {
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' },
},
},
// SendGrid: Email sent successfully
{
method: 'POST',
url: 'https://api.sendgrid.com/v3/mail/send',
response: {
status: 202,
body: { message_id: 'msg_123' },
},
},
],
};
// Specialized scenario: Override ONLY Auth0 for premium user
const premiumUserScenario: ScenaristScenario = {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user, everything else succeeds',
mocks: [
// Override: Auth0 returns premium tier
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_456', email: 'premium@example.com', tier: 'premium' },
},
},
// Stripe and SendGrid automatically fall back to default (happy path)
],
};
export const scenarios = {
default: defaultScenario,
premiumUser: premiumUserScenario,
} as const satisfies ScenaristScenarios;
```
## 2. Set Up Scenarist lib/scenarist.ts
```typescript
import { createScenarist } from '@scenarist/nextjs-adapter/pages';
import { scenarios } from './scenarios';
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Start MSW in the Next.js server process (scenarist is undefined in production builds)
if (typeof window === 'undefined' && scenarist) {
scenarist.start();
}
```
Create One Instance Use the `export const scenarist` pattern shown above, and import `scenarist` wherever you need it. Call `createScenarist()` in one module only. **Why this matters:** Scenarist handles a Next.js-specific issue for you. It has a [well-documented singleton problem](https://github.com/vercel/next.js/discussions/68572) where webpack bundles the same module multiple times, breaking classic singleton patterns. This is compounded by [MSW's challenges with Next.js's process model](https://github.com/mswjs/msw/issues/1644)—Next.js keeps multiple Node.js processes that make global module patches difficult to maintain. **Scenarist solves this automatically.** The Next.js adapter includes built-in `globalThis` singleton guards that ensure only one MSW instance exists, regardless of how Next.js loads your modules. You don't need to understand Next.js internals or implement manual workarounds—just use `export const scenarist = createScenarist(...)` and Scenarist handles the complexity. Without protection, this causes: * `[MSW] Multiple handlers with the same URL` warnings * Intermittent 500 errors from MSW * Different tests getting wrong scenarios * Scenarios not switching properly **How Scenarist solves this:** The first `createScenarist()` call stores its instance in `global.__scenarist_instance_pages`. Every later call returns that same instance, even when Next.js loads your module more than once, so only one instance and one MSW server ever exist. Later calls ignore the options you pass them, so a second `createScenarist()` call with different scenarios has no effect. The exception is `enabled: false`, which always returns `undefined`. ## 3. Create Scenario Control Endpoint pages/api/\_\_scenario\_\_.ts
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { scenarist } from '@/lib/scenarist';
// scenarist is undefined in production builds or when enabled is false, and
// Next.js requires a default export function, so fall back to a handler that answers 405
export default scenarist?.createScenarioEndpoint() ??
((_req: NextApiRequest, res: NextApiResponse) => {
res.status(405).end();
});
```
## 4. Set Up Playwright Fixtures tests/fixtures.ts
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };
```
## 5. Write Tests ### Testing Server-Side Rendering tests/products.spec.ts
```typescript
import { test, expect } from './fixtures'; // ✅ Import from fixtures
test('premium users see premium pricing', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // ✅ Type-safe! Autocomplete works
await page.goto('/products');
// Your getServerSideProps runs, Auth0 returns premium tier
await expect(page.getByText('Premium Plan')).toBeVisible();
await expect(page.getByText('$50.00')).toBeVisible();
});
test('standard users see standard pricing', async ({ page, switchScenario }) => {
await switchScenario(page, 'default'); // Default scenario (happy path)
await page.goto('/products');
await expect(page.getByText('Standard Plan')).toBeVisible();
await expect(page.getByText('$25.00')).toBeVisible();
});
```
**Example Page with getServerSideProps:** pages/products.tsx
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export async function getServerSideProps({ req }) {
// This fetch is mocked by Scenarist (the forwarded test ID selects the scenario)
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(req),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
});
const { data: products } = await response.json();
return { props: { products } };
}
export default function ProductsPage({ products }) {
return (
{products.map(product => (
{product.name}
${(product.price / 100).toFixed(2)}
))}
);
}
```
### Testing API Routes tests/checkout.spec.ts
```typescript
test('processes payment via API route', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'default');
// Your API route validation runs (page.request needs the test ID explicitly)
const response = await page.request.post('/api/checkout', {
headers: { 'x-scenarist-test-id': testId },
data: { amount: 5000, token: 'tok_test' },
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.status).toBe('succeeded');
});
```
**Example API Route:** pages/api/checkout.ts
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Validation runs normally
if (!req.body.amount || !req.body.token) {
return res.status(400).json({ error: 'Missing required fields' });
}
// External API call is mocked by Scenarist
const response = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: {
...getScenaristHeaders(req),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
});
const data = await response.json();
res.status(200).json(data);
}
```
## Forwarding Headers to External APIs **Why header forwarding matters:** When your API routes or getServerSideProps call external APIs (that you’re mocking with Scenarist), you must forward the test ID header so MSW knows which scenario to use. ### API Routes Use the `getScenaristHeaders` helper to safely extract Scenarist headers: pages/api/products.ts
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Forward Scenarist headers to external API
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(req),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
});
const data = await response.json();
res.status(200).json(data);
}
```
### getServerSideProps The same helper works in getServerSideProps: pages/products.tsx
```typescript
import type { GetServerSideProps } from 'next';
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(req),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
});
const { data: products } = await response.json();
return { props: { products } };
};
export default function ProductsPage({ products }) {
return (
{products.map(product => (
{product.name}
${(product.price / 100).toFixed(2)}
))}
);
}
```
The `getScenaristHeaders` helper extracts the test ID header (`x-scenarist-test-id`) for forwarding to external APIs. ## What Makes Pages Router Setup Special **API Routes Execute Normally** - Your validation, error handling, and business logic all run as they would in production. **Server-Side Rendering Works** - Your getServerSideProps and getStaticProps fetch data from mocked external APIs. **Test Isolation** - Each test gets isolated scenario state. Run tests in parallel with zero interference. **No App Restart** - Switch scenarios instantly during test execution. ## Next Steps * **[Example App on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example)** - Clone and run the complete working example * **[Testing Database Apps →](/guides/testing-database-apps/)** - Learn how to test Next.js apps that use both databases and external APIs * **[Architecture →](/concepts/architecture/)** - Learn how Scenarist works under the hood
# Testing Best Practices
> Practical patterns for organizing scenarios, structuring tests, and avoiding common pitfalls
This guide covers practical patterns that make Scenarist tests maintainable as your test suite grows. For the underlying philosophy, see [Testing Philosophy](/concepts/philosophy/). ## Scenario Organization ### Group by Business Domain, Not by API Organize scenarios around **what users experience**, not which APIs you’re mocking:
```plaintext
lib/scenarios/
├── checkout/
│ ├── success.ts # Complete checkout flow succeeds
│ ├── payment-declined.ts # Card declined at payment step
│ └── inventory-error.ts # Item goes out of stock
├── user-tiers/
│ ├── free-user.ts # Limited features
│ ├── premium-user.ts # All features unlocked
│ └── enterprise-user.ts # Admin + team features
└── errors/
├── api-timeout.ts # External APIs are slow
├── rate-limited.ts # API returns 429
└── server-error.ts # Generic 500 responses
```
**Why this matters:** * Scenarios describe **user journeys**, not technical details * Easy to find the right scenario when writing a test * New team members understand the test intent immediately ### The Default Scenario Pattern Your `default` scenario should represent the **happy path baseline**—everything works: lib/scenarios/default.ts
```typescript
import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/core';
const defaultScenario: ScenaristScenario = {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed with valid responses',
mocks: [
// Stripe: Payment succeeds
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_default', status: 'succeeded', amount: 5000 },
},
},
// Auth0: Standard authenticated user
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_default', email: 'user@example.com', tier: 'standard' },
},
},
// SendGrid: Email sent successfully
{
method: 'POST',
url: 'https://api.sendgrid.com/v3/mail/send',
response: { status: 202, body: { message_id: 'msg_default' } },
},
],
};
export const scenarios = {
default: defaultScenario,
// Other scenarios inherit from default automatically
} as const satisfies ScenaristScenarios;
```
**Key insight:** Specialized scenarios only need to define **what changes**. Unmocked endpoints automatically fall back to the default scenario. ### Minimal Override Scenarios Don’t duplicate mocks. Override only what changes:
```typescript
// ✅ GOOD - Override only Auth0, others fall back to default
const premiumUserScenario: ScenaristScenario = {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user with all features',
mocks: [
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_premium', email: 'premium@example.com', tier: 'premium' },
},
},
// Stripe and SendGrid automatically use default scenario
],
};
// ❌ BAD - Duplicates all mocks even when unchanged
const premiumUserScenarioBad: ScenaristScenario = {
id: 'premiumUser',
mocks: [
{ method: 'GET', url: 'https://api.auth0.com/userinfo', response: { /* ... */ } },
{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { /* same as default */ } },
{ method: 'POST', url: 'https://api.sendgrid.com/v3/mail/send', response: { /* same as default */ } },
],
};
```
**Benefits of minimal overrides:** * Less code to maintain * Clear about what the scenario actually tests * Default scenario changes propagate automatically ## Feature Selection Guide ### When to Use Request Matching Use **match criteria** when response depends on request content:
```typescript
// Different prices based on user tier header
{
method: 'GET',
url: '/api/pricing',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { price: 79.99 } },
}
// Filter products by query parameter
{
method: 'GET',
url: '/api/products',
match: { query: { category: 'electronics' } },
response: { status: 200, body: { products: [/* electronics */] } },
}
```
### When to Use Sequences Use **sequences** for APIs that return different responses on subsequent calls:
```typescript
// Polling API: pending → processing → complete
{
method: 'GET',
url: '/api/job/:id/status',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete', result: { /* ... */ } } },
],
repeat: 'last', // Stay at "complete" for all subsequent calls
},
}
```
**Common sequence patterns:** | Pattern | `repeat` mode | Use case | | ------------------ | ------------- | ----------------------------------- | | Progress indicator | `'last'` | Show final state forever | | Retry logic | `'cycle'` | Keep failing then succeeding | | Rate limiting | `'none'` | First N requests succeed, then fail | ### When to Use Stateful Mocks Use **state capture** when responses depend on previous requests. State is isolated per test ID, so parallel tests each maintain their own state without conflicts:
```typescript
// Capture item added to cart
{
method: 'POST',
url: '/api/cart/items',
captureState: { 'cartItems[]': 'body.item' },
response: { status: 200, body: { success: true } },
}
// Return captured items in cart
{
method: 'GET',
url: '/api/cart',
response: {
status: 200,
body: {
items: '{{state.cartItems}}',
itemCount: '{{state.cartItems.length}}',
},
},
}
```
State Resets on Scenario Switch All captured state is cleared when switching scenarios. This ensures tests are isolated—each test starts fresh. ## Test Structure Patterns ### Factory Functions for Test Data Use factory functions instead of `let` and `beforeEach`: * ✅ Good - Factory Function tests/fixtures.ts
```typescript
const createTestContext = () => ({
testId: `test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
baseURL: 'http://localhost:3000',
});
// tests/checkout.spec.ts
test('processes payment successfully', async ({ page, switchScenario }) => {
const ctx = createTestContext();
await switchScenario(page, 'default');
await page.goto(`${ctx.baseURL}/checkout`);
// ... test code
});
```
* ❌ Bad - Shared State
```typescript
// Shared mutable state leads to test pollution
let testId: string;
beforeEach(() => {
testId = `test-${Date.now()}`;
});
test('processes payment', async ({ page }) => {
// testId could be modified by parallel tests
});
```
### One Scenario Switch Per Test Switch scenarios at the beginning of each test, not multiple times:
```typescript
// ✅ GOOD - Single scenario for entire test journey
test('premium checkout flow', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await page.click('[data-testid="add-to-cart"]');
await page.goto('/checkout');
await page.click('[data-testid="submit-payment"]');
await expect(page.getByText('Order Confirmed')).toBeVisible();
});
// ❌ AVOID - Multiple scenario switches mid-test
test('confusing flow', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await switchScenario(page, 'paymentError'); // Why switch here?
await page.goto('/checkout');
// What is this test actually verifying?
});
```
**Exception:** Testing scenario transitions explicitly (e.g., “user upgrades mid-session”) may warrant multiple switches, but make the intent clear. ### Descriptive Test Names Test names should describe the **user experience**, not the scenario:
```typescript
// ✅ GOOD - Describes what user sees
test('premium users see 20% discount on checkout', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
// ...
});
test('declined card shows clear error message', async ({ page, switchScenario }) => {
await switchScenario(page, 'paymentDeclined');
// ...
});
// ❌ BAD - Describes implementation
test('premiumUser scenario works', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
// ...
});
```
## Anti-Patterns to Avoid ### Over-Mocking Don’t mock every API in every scenario:
```typescript
// ❌ BAD - Mocking APIs not relevant to this test
const checkoutErrorScenario = {
id: 'checkoutError',
mocks: [
{ method: 'POST', url: '/api/charges', response: { status: 402 } }, // Relevant
{ method: 'GET', url: '/api/user', response: { /* ... */ } }, // Not needed
{ method: 'GET', url: '/api/products', response: { /* ... */ } }, // Not needed
{ method: 'POST', url: '/api/analytics', response: { /* ... */ } }, // Not needed
],
};
// ✅ GOOD - Only mock what the test cares about
const checkoutErrorScenario = {
id: 'checkoutError',
mocks: [
{ method: 'POST', url: '/api/charges', response: { status: 402 } },
// Other APIs use default scenario
],
};
```
### Testing Implementation Details Don’t verify internal behavior—verify user outcomes:
```typescript
// ❌ BAD - Testing that Stripe was called
test('calls Stripe API', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/checkout');
await page.click('button[type="submit"]');
// How would you even verify this without coupling to implementation?
});
// ✅ GOOD - Testing what user sees
test('displays success message after payment', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/checkout');
await page.click('button[type="submit"]');
await expect(page.getByText('Payment successful')).toBeVisible();
});
```
### Scenarios with Hidden Logic Avoid creating scenarios that require understanding hidden conditional logic:
```typescript
// ❌ BAD - What does this scenario do? Need to read source.
const scenario = {
id: 'complex',
mocks: [{
method: 'GET',
url: '/api/data',
match: {
headers: { 'x-flag-a': 'true' },
query: { mode: { contains: 'special' } },
body: { nested: { field: { startsWith: 'prefix' } } },
},
response: { status: 200, body: { /* ... */ } },
}],
};
// ✅ GOOD - Name explains what it tests
const scenario = {
id: 'specialModeWithFeatureFlagA',
name: 'Special Mode with Feature Flag A Enabled',
description: 'Tests the special mode behavior when feature flag A is active',
mocks: [{
method: 'GET',
url: '/api/data',
match: {
headers: { 'x-feature-flag': 'flagA' },
query: { mode: 'special' },
},
response: { status: 200, body: { /* ... */ } },
}],
};
```
## Scaling Your Test Suite ### Scenario Composition For complex tests, compose smaller scenarios:
```typescript
// Base scenarios
const authMocks = {
authenticated: { method: 'GET', url: '/api/user', response: { status: 200, body: { /* ... */ } } },
unauthenticated: { method: 'GET', url: '/api/user', response: { status: 401 } },
};
const paymentMocks = {
success: { method: 'POST', url: '/api/charges', response: { status: 200, body: { /* ... */ } } },
declined: { method: 'POST', url: '/api/charges', response: { status: 402 } },
};
// Compose into full scenarios
const scenarios = {
default: {
id: 'default',
mocks: [authMocks.authenticated, paymentMocks.success],
},
paymentDeclined: {
id: 'paymentDeclined',
mocks: [authMocks.authenticated, paymentMocks.declined],
},
unauthenticatedCheckout: {
id: 'unauthenticatedCheckout',
mocks: [authMocks.unauthenticated, paymentMocks.success],
},
} as const satisfies ScenaristScenarios;
```
### Documentation Document non-obvious scenarios:
```typescript
const rateLimitedScenario: ScenaristScenario = {
id: 'rateLimited',
name: 'API Rate Limited',
description: `
Simulates hitting API rate limits after 3 successful requests.
Use for testing retry logic and rate limit handling UI.
Sequence: 200 → 200 → 200 → 429 (repeats 429)
`,
mocks: [{
method: 'POST',
url: '/api/data',
sequence: {
responses: [
{ status: 200, body: { success: true } },
{ status: 200, body: { success: true } },
{ status: 200, body: { success: true } },
{ status: 429, body: { error: 'Rate limited', retryAfter: 60 } },
],
repeat: 'last',
},
}],
};
```
## Debugging with Logging When tests fail unexpectedly, enable Scenarist’s logging to see what’s happening:
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
}),
});
```
**Common debugging scenarios:** | Problem | What to Check | Log Category | | ---------------------- | ------------------------- | ------------------------ | | Wrong mock selected | Specificity scores | `matching` (debug level) | | Mock not matching | Match criteria evaluation | `matching` (debug level) | | State not captured | State capture events | `state` (debug level) | | Scenario not switching | Scenario switch events | `scenario` (info level) | ## Next Steps * [Logging Reference](/reference/logging/) - Full logging configuration and API * [Parallel Testing](/testing/parallel-testing/) - Run tests concurrently with isolated scenarios * [Playwright Integration](/testing/playwright-integration/) - Set up type-safe fixtures * [Testing Philosophy](/concepts/philosophy/) - Core principles behind Scenarist
# Parallel Testing
> Run concurrent tests with different backend states using test ID isolation
Scenarist enables **parallel test execution** where multiple tests run simultaneously, each with their own scenario. This is achieved through **test ID isolation** - every test gets a unique identifier that determines which scenario’s responses it receives. ## How Test Isolation Works Each test gets a unique test ID (automatically generated by the Playwright fixture). This test ID is sent with every request, allowing Scenarist to route each test to its own scenario.
```typescript
// Two tests running in parallel, each with different scenarios
test('premium features', async ({ page, switchScenario }) => {
await switchScenario(page, 'premium'); // test-id: abc-123 → premium scenario
await page.goto('/dashboard');
await expect(page.getByText('Advanced Analytics')).toBeVisible();
});
test('free features', async ({ page, switchScenario }) => {
await switchScenario(page, 'free'); // test-id: xyz-789 → free scenario
await page.goto('/dashboard');
await expect(page.getByText('Upgrade to Premium')).toBeVisible();
});
// Both tests run simultaneously without interference
```
### Complete Request Flow Here’s how two tests run in parallel with different scenarios: **The isolation mechanism:** 1. **Each test gets a unique ID** - Generated automatically by the fixture 2. **Test switches scenario once** - `POST /__scenario__` with test ID 3. **All subsequent requests** include the test ID header 4. **Scenarist routes by test ID** - Same URL, different responses per test 5. **Scenario persists** for the entire test journey This enables: * ✅ **Unlimited scenarios** - Premium, free, error, edge cases all in parallel * ✅ **No interference** - Each test isolated by unique test ID * ✅ **One backend server** - All tests share same server instance * ✅ **Fast execution** - No expensive external API calls ## Header Propagation (Critical for Parallel Tests) Most Common Parallel Test Issue When tests fail in parallel but pass sequentially, the root cause is almost always **test ID headers not being propagated** through server-side fetch calls. ### The Problem When your server-side code makes internal fetch calls (e.g., Server Components fetching from API routes), headers don’t automatically propagate:
```typescript
// ❌ BAD - Headers not propagated to internal fetch
export async function Page() {
// This fetch doesn't include the test ID header!
const response = await fetch('http://localhost:3001/api/products');
const data = await response.json();
return
{/* render */}
;
}
```
Without the test ID header, the internal fetch uses the **default scenario** instead of the test’s scenario. In parallel tests, this causes interference. ### Next.js Solution: Header Propagation Helpers **For Server Components** (use `getScenaristHeadersFromReadonlyHeaders`):
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Server Components
export default async function Page() {
const headersList = await headers(); // Get ReadonlyHeaders from Next.js
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList), // Include test ID header
},
cache: 'no-store',
});
const data = await response.json();
return
{/* render */}
;
}
```
**For Route Handlers** (use `getScenaristHeaders`):
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Route Handlers
export async function GET(request: Request) {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(request), // Include test ID header
},
cache: 'no-store',
});
const data = await response.json();
return Response.json(data);
}
```
**What these helpers do:** * Extract test ID from request/headers * Return `{ 'x-scenarist-test-id': 'generated-uuid' }` object * Safe to call in production builds, where no Scenarist instance exists (returns empty object) ### Express Solution: Manual Header Forwarding Express adapter uses AsyncLocalStorage to automatically track test IDs per request. For internal fetch calls, include the header manually:
```typescript
import { SCENARIST_TEST_ID_HEADER } from '@scenarist/express-adapter';
app.get('/api/dashboard', async (req, res) => {
const testId = req.get(SCENARIST_TEST_ID_HEADER);
const response = await fetch('http://localhost:3001/api/user', {
headers: {
[SCENARIST_TEST_ID_HEADER]: testId || '',
},
});
const data = await response.json();
res.json(data);
});
```
## Diagnosing Parallel Test Failures ### Symptoms of Missing Header Propagation **Tests pass individually, fail in parallel:**
```bash
# Pass individually
pnpm exec playwright test --workers=1
# ✅ All tests pass
# Fail in parallel
pnpm exec playwright test --workers=4
# ❌ Some tests fail with wrong data
```
**Wrong data appearing in tests:**
```typescript
// Test expects premium pricing
await expect(page.getByText('£99.99')).toBeVisible();
// ❌ Error: element not found
// But sees standard pricing instead (from default scenario)
await expect(page.getByText('£149.99')).toBeVisible();
// ✅ This passes - wrong scenario!
```
**Flaky results:** * Sometimes premium pricing, sometimes standard * Different results on different runs * Race conditions between parallel tests ### Debugging Steps 1. **Add logging** to see which scenario is active:
```typescript
const testId = req.get('x-scenarist-test-id') ?? 'default-test';
console.log('Test ID:', testId);
console.log('Active scenario:', scenarist.getActiveScenario(testId));
```
2. **Check server logs** for test ID headers on internal fetches 3. **Verify header helpers** are called before every internal fetch 4. **Confirm headers object** includes the test ID ### Fix Checklist When parallel tests fail: 1. ✅ **Next.js:** Add header helpers before all internal fetch calls (use `getScenaristHeadersFromReadonlyHeaders` in Server Components, `getScenaristHeaders` in Route Handlers) 2. ✅ **Express:** Include test ID header in internal fetch calls 3. ✅ **Playwright:** Verify tests call `switchScenario()` before navigation 4. ✅ **Isolation:** Ensure each test switches scenarios independently 5. ✅ **Logging:** Add debug logs to confirm headers are present ## Parallel Test Patterns ### Pattern 1: Independent Scenario Tests Each test operates with its own scenario, no shared state:
```typescript
import { test, expect } from './fixtures';
test.describe('User tiers', () => {
test('premium users see analytics', async ({ page, switchScenario }) => {
await switchScenario(page, 'premium');
await page.goto('/dashboard');
await expect(page.getByText('Analytics')).toBeVisible();
});
test('free users see upgrade prompt', async ({ page, switchScenario }) => {
await switchScenario(page, 'free');
await page.goto('/dashboard');
await expect(page.getByText('Upgrade')).toBeVisible();
});
test('enterprise users see admin panel', async ({ page, switchScenario }) => {
await switchScenario(page, 'enterprise');
await page.goto('/dashboard');
await expect(page.getByText('Admin Panel')).toBeVisible();
});
});
// All three tests run in parallel
```
### Pattern 2: Error Scenario Matrix Test multiple error conditions in parallel:
```typescript
test.describe('Payment errors', () => {
test('handles card declined', async ({ page, switchScenario }) => {
await switchScenario(page, 'cardDeclined');
await page.goto('/checkout');
await page.click('button[type="submit"]');
await expect(page.getByText('Card was declined')).toBeVisible();
});
test('handles insufficient funds', async ({ page, switchScenario }) => {
await switchScenario(page, 'insufficientFunds');
await page.goto('/checkout');
await page.click('button[type="submit"]');
await expect(page.getByText('Insufficient funds')).toBeVisible();
});
test('handles network timeout', async ({ page, switchScenario }) => {
await switchScenario(page, 'stripeTimeout');
await page.goto('/checkout');
await page.click('button[type="submit"]');
await expect(page.getByText('Please try again')).toBeVisible();
});
});
```
### Pattern 3: Multi-Step Journeys Each test covers a complete user journey with its scenario:
```typescript
test('complete checkout flow - premium user', async ({ page, switchScenario }) => {
await switchScenario(page, 'premium');
// Step 1: Browse products
await page.goto('/products');
await expect(page.getByText('Premium Discount: 20%')).toBeVisible();
// Step 2: Add to cart
await page.click('button:has-text("Add to Cart")');
await expect(page.getByText('Cart: 1 item')).toBeVisible();
// Step 3: Checkout
await page.goto('/checkout');
await expect(page.getByText('Total: £79.99')).toBeVisible(); // Discounted
// Step 4: Payment
await page.click('button:has-text("Pay Now")');
await expect(page.getByText('Order Confirmed')).toBeVisible();
});
test('complete checkout flow - standard user', async ({ page, switchScenario }) => {
await switchScenario(page, 'standard');
// Same journey, different scenario - runs in parallel
await page.goto('/products');
await expect(page.getByText('Premium Discount: 20%')).not.toBeVisible();
await page.click('button:has-text("Add to Cart")');
await page.goto('/checkout');
await expect(page.getByText('Total: £99.99')).toBeVisible(); // Full price
await page.click('button:has-text("Pay Now")');
await expect(page.getByText('Order Confirmed')).toBeVisible();
});
```
## Playwright Configuration For optimal parallel test execution: playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig({
// Run tests in parallel
fullyParallel: true,
// Number of parallel workers
workers: process.env.CI ? 4 : undefined, // 4 in CI, auto-detect locally
// Scenarist configuration
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__',
},
});
```
## Next Steps * [Playwright Integration](/testing/playwright-integration/) - Fixture setup and configuration * [Testing Best Practices](/testing/best-practices/) - Scenario organization patterns * [Verification Guide](/reference/verification/) - Troubleshooting test issues
# Playwright Integration
> Set up type-safe Playwright tests with Scenarist fixtures and helpers
Scenarist provides Playwright helpers that enable type-safe scenario switching with autocomplete, automatic test ID management, and clean test organization. ## Quick Start ### 1. Install Playwright Helpers
```bash
npm install --save-dev @scenarist/playwright-helpers
# or
pnpm add -D @scenarist/playwright-helpers
```
### 2. Create Fixtures File Create a `tests/fixtures.ts` file that exports a typed `test` object: tests/fixtures.ts
```typescript
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 };
```
### 3. Use in Tests **Import from fixtures:** tests/my-feature.spec.ts
```typescript
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:**
```typescript
// 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
});
```
## Why Use Fixtures? The fixtures pattern provides several benefits: ### Type-Safe Scenario IDs TypeScript knows which scenario IDs exist and provides autocomplete:
```typescript
await switchScenario(page, 'premiumUser'); // Autocomplete suggests: 'default', 'premiumUser', 'error', etc.
await switchScenario(page, 'typo'); // TypeScript error: 'typo' not in scenarios
```
### Automatic Test ID Management 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](/testing/parallel-testing/). ### Centralized Configuration Configure the scenario endpoint once in your fixtures file or `playwright.config.ts`: playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__', // Default, can be customized
},
});
```
The default matches the Next.js scenario routes. Express serves the endpoint at `/__scenario__`, so Express projects set `scenaristEndpoint: '/__scenario__'`. ### Clean Test Organization All tests import from the same fixtures file, ensuring consistency:
```plaintext
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';
```
## Complete Example **1. Define Scenarios:** lib/scenarios.ts
```typescript
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
```typescript
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
```typescript
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();
});
```
## Composing with Custom Fixtures If you already have custom Playwright fixtures, extend the Scenarist test object: tests/fixtures.ts
```typescript
import type { Page } from '@playwright/test';
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
type CustomFixtures = {
authenticatedPage: Page;
apiToken: string;
};
export const test = withScenarios(scenarios).extend({
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:
```typescript
import { test, expect } from './fixtures';
test('authenticated user sees dashboard', async ({ authenticatedPage, switchScenario }) => {
await switchScenario(authenticatedPage, 'premiumUser');
await expect(authenticatedPage.getByText('Welcome Back')).toBeVisible();
});
```
## Per-Test Configuration Overrides Override endpoint or baseURL for specific tests:
```typescript
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
});
```
## Cross-Origin API Servers 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 ### Configuration playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
// Frontend: http://localhost:3000
// API Server: http://localhost:9090
export default defineConfig({
use: {
baseURL: 'http://localhost:3000', // For Playwright navigation (page.goto)
scenaristEndpoint: 'http://localhost:9090/__scenario__', // Absolute URL to API
},
});
```
### How It Works | Endpoint Type | Example | Behavior | | ------------- | ------------------------------------ | ------------------------------------------------------------------- | | Relative path | `/api/__scenario__` | Prepended with `baseURL` → `http://localhost:3000/api/__scenario__` | | Absolute URL | `http://localhost:9090/__scenario__` | Used directly (ignores `baseURL`) | ### Per-Test Cross-Origin Override You can also override for specific tests:
```typescript
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');
});
```
## Debugging State When tests fail, you often need to inspect the current test state to understand what went wrong. Scenarist provides debug fixtures for this. ### `debugState(page)` Fetch the current test state from the debug endpoint:
```typescript
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?)` Wait for state to meet a condition (useful for async workflows):
```typescript
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) ### Configuration Configure the state endpoint in playwright.config.ts:
```typescript
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__',
scenaristStateEndpoint: '/__scenarist__/state', // Default value (Express)
},
});
```
Next.js apps serve the state route under `/api/`, so set `scenaristStateEndpoint: '/api/__scenarist__/state'` there, as both Next.js example apps do. ## API Reference ### `withScenarios(scenarios)` 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:**
```typescript
const test = withScenarios(scenarios);
```
### `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` - The test ID (for explicit `page.request` calls) **Example:**
```typescript
test('my test', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'premiumUser');
// testId can be used for explicit page.request calls
});
```
### `expect` Re-exported from `@playwright/test` for convenience. Use the same `expect` you’re familiar with.
```typescript
import { test, expect } from './fixtures';
test('my test', async ({ page }) => {
await expect(page.getByText('Hello')).toBeVisible();
});
```
## Next Steps * [Parallel Testing](/testing/parallel-testing/) - Test isolation and concurrent test execution * [Testing Best Practices](/testing/best-practices/) - Patterns for organizing tests and scenarios * [Writing Scenarios](/scenarios/overview/) - Learn how to define scenarios * [Endpoint APIs](/reference/api-endpoints/) - Complete endpoint reference including debug state
# Architecture
> How Scenarist's framework-agnostic core works internally
Scenarist uses **hexagonal architecture** (also called ports and adapters) to maintain complete framework independence. This means the core scenario management logic has zero framework dependencies, while thin adapters integrate with Express and Next.js (App Router and Pages Router). ## Why This Architecture Matters **The Testing Gap Scenarist Fills:** Unit tests CAN test server-side logic, but require extensive code-level mocking (request/response objects, session state, auth context, middleware chains, database connections). These mocks create distance from production—bugs can hide in the gap between mocked and real execution. Browser tests give production-like execution, but testing multiple scenarios requires either complex per-scenario mocking, server restarts (impractical in CI), or hitting real external APIs (slow, flaky, expensive). Result: browser tests typically cover only the happy path. **What developers actually need:** Test backend logic (API routes, validation, middleware, business rules, Server Components) through real HTTP requests with multiple scenarios—without external API dependencies and without server restarts. **How Scenarist’s architecture solves this:** * **Framework-agnostic core** handles scenario switching, test isolation, and response selection * **Thin adapters** integrate with any framework (Express, Next.js, Remix, etc.) * **Your entire backend executes normally** (middleware chains, validation, business logic, Server Components) * **Only external APIs are mocked** (Stripe, SendGrid, Auth0, etc.) This architecture ensures your code runs in production-like conditions (real middleware, real auth, real routing) while still enabling comprehensive scenario testing. ## The Hexagon: Framework-Agnostic Core The internal core package contains all domain logic with **zero dependencies** on any web framework. Users interact with this through adapters - the core is not installed directly: ### Core Responsibilities **1. Scenario Management** * `ScenarioRegistry`: Stores scenario definitions (in-memory or external) * `ScenarioStore`: Tracks active scenario per test ID * `ScenarioManager`: Coordinates registration, switching, and retrieval **2. Test ID Isolation** * `StateManager`: Manages captured state per test ID * `SequenceTracker`: Tracks sequence positions per test ID * Each test gets isolated state via unique test ID header **3. Dynamic Responses** * `ResponseSelector`: Selects responses based on request content matching * Sequence management: Advances position, handles repeat modes * State capture/injection: Template replacement with `{{state.key}}` syntax ### Ports: Behavior Contracts The core defines **interfaces** (ports) that adapters must implement:
```typescript
// Driving port (called by adapters, implemented by core)
interface ScenarioManager {
registerScenario(definition: ScenaristScenario): void;
switchScenario(testId: string, scenarioId: string): ScenaristResult;
getActiveScenario(testId: string): ActiveScenario | undefined;
listScenarios(): ReadonlyArray;
// ... clearScenario, getScenarioById, getState
}
// Driven ports (implemented by adapters)
interface RequestContext {
getTestId(): string;
getHeaders(): Record;
getHostname(): string;
}
// Driven port for observability
interface Logger {
info(category: LogCategory, message: string, context: LogContext, data?: Record): void;
debug(category: LogCategory, message: string, context: LogContext, data?: Record): void;
// ... error, warn, trace methods
}
interface ScenarioRegistry {
register(definition: ScenaristScenario): void;
get(scenarioId: string): ScenaristScenario | undefined;
list(): ReadonlyArray;
}
interface ScenarioStore {
set(testId: string, scenario: ActiveScenario): void;
get(testId: string): ActiveScenario | undefined;
delete(testId: string): void;
}
```
**Why interfaces for ports?** * Signal implementation contracts clearly * Better TypeScript errors when implementing * Conventional in hexagonal architecture * Class-friendly for adapter implementations * (See “Why types for data?” below for complementary rationale) ### Data Types: Immutable Structures All data structures use `type` (not `interface`) with `readonly`:
```typescript
type ScenaristScenario = {
readonly id: string;
readonly name: string;
readonly description: string;
readonly mocks: ReadonlyArray;
};
type ScenaristMock = {
readonly method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
readonly url: string | RegExp;
readonly response?: MockResponse;
readonly sequence?: ResponseSequence;
readonly match?: MatchCriteria;
readonly captureState?: Record;
};
```
**Why types for data?** * Emphasizes immutability * Better for unions, intersections, mapped types * Functional programming alignment * Prevents accidental mutations ## Framework Adapters: Thin Integration Layers Adapters are small (\~100 lines) framework-specific integration layers. They translate between framework concepts and core ports. ### Adapter Responsibilities Each adapter does **three things only**: **1. Extract Request Context (Framework-Specific)**
```typescript
// Express
class ExpressRequestContext implements RequestContext {
constructor(private req: Request, private config: ScenaristConfig) {}
getTestId(): string {
const header = this.req.headers['x-scenarist-test-id'];
return typeof header === 'string' ? header : this.config.defaultTestId;
}
getHeaders(): Record {
return this.req.headers;
}
getHostname(): string {
return this.req.hostname;
}
}
// Next.js App Router
class AppRequestContext implements RequestContext {
constructor(private request: Request, private config: ScenaristConfig) {}
getTestId(): string {
return this.request.headers.get('x-scenarist-test-id') || this.config.defaultTestId;
}
// ... getHeaders(), getHostname()
}
```
**2. Wire Up Scenario Endpoints (Framework-Specific)**
```typescript
// Express
app.post('/__scenario__', async (req, res) => {
const testId = extractTestId(req);
const { scenario } = req.body;
const result = manager.switchScenario(testId, scenario);
if (result.success) {
res.json({ success: true });
} else {
res.status(400).json({ error: result.error.message });
}
});
// Next.js App Router
export async function POST(request: Request) {
const testId = request.headers.get('x-scenarist-test-id') || 'default-test';
const { scenario } = await request.json();
const result = manager.switchScenario(testId, scenario);
return Response.json(
result.success
? { success: true }
: { error: result.error.message },
{ status: result.success ? 200 : 400 }
);
}
```
**3. Delegate Everything Else to Core** All scenario management, response selection, state tracking, and sequence advancement happens in the framework-agnostic core. Adapters are **translation layers only**. ### Why Adapters Stay Thin **Adapter anti-patterns (avoided):** * ❌ Duplicating scenario management logic * ❌ Implementing response selection * ❌ Managing test ID isolation * ❌ Handling state capture/injection **Correct adapter pattern:** * ✅ Extract request context (framework-specific) * ✅ Create endpoint routes (framework-specific) * ✅ Delegate to core for all logic (framework-agnostic) ## MSW Integration Layer The MSW adapter (`@scenarist/msw-adapter`) converts framework-agnostic scenario definitions into MSW handlers: ### MSW Adapter Responsibilities **1. Convert Mock Definitions to MSW Handlers**
```typescript
const mockDefinition: ScenaristMock = {
method: 'GET',
url: '/api/users/:id',
response: {
status: 200,
body: { name: 'John Doe' }
}
};
// MSW Adapter registers one catch-all handler that resolves mocks per request:
http.all('*', async ({ request }) => {
// Extract test ID, find matching mocks (URL + method), select response via core
return HttpResponse.json({ name: 'John Doe' }, { status: 200 });
});
```
**2. URL Pattern Matching** * **Exact match**: `/api/users` (string literal) * **RegExp patterns**: `/\/api\/users\//` (substring match, any origin) * **Path parameters**: `/api/users/:id` (extracted and available in context) **3. Dynamic Handler with Fallback** For each HTTP request: 1. Extract test ID from request headers 2. Get active scenario for test ID from core 3. If scenario exists, use scenario mocks 4. If no scenario, fall back to default scenario 5. Delegate response selection to core’s `ResponseSelector` ### Why MSW Integration is Separate The MSW adapter is **not part of core** because: * MSW is a framework for mocking HTTP (runtime dependency) * Core must remain dependency-free (pure TypeScript) * MSW adapter can be swapped for alternatives (Mirage, Nock, etc.) ## Data Flow: Request to Response Here’s how a request flows through Scenarist: ### Step-by-Step Breakdown **1. Scenario Switching** * Test calls `switchScenario(page, 'premiumUser')` * Playwright helper sends POST to the scenario endpoint (`scenaristEndpoint`, default `/api/__scenario__`) * Adapter extracts test ID, calls core’s `ScenarioManager` * Core stores `{ testId: 'uuid-123', scenarioId: 'premiumUser' }` **2. Page Navigation** * Browser navigates to `/dashboard` with `x-scenarist-test-id: uuid-123` header * Adapter extracts `RequestContext` from framework request * **Your route handler/Server Component executes normally** **3. External API Call** * Your code calls `fetch('https://api.external.com/user')` * MSW intercepts the external call * MSW asks core for active scenario: `uuid-123` → `premiumUser` * MSW delegates response selection to core’s `ResponseSelector` **4. Response Selection** * Core checks match criteria (body, headers, query params) * Core advances sequences if applicable * Core injects state via templates if applicable * Core returns selected response to MSW * MSW returns response to your code **5. Your Code Continues** * Your route handler receives mocked response * Your business logic executes with mocked data * Your Server Component renders with real logic * HTML sent to browser ## Dependency Injection Pattern The core uses **dependency injection** for all port implementations:
```typescript
// ❌ WRONG - Creating implementation internally
export const createScenarioManager = () => {
const registry = new Map(); // Hardcoded!
// ...
};
// ✅ CORRECT - Injecting ports
export const createScenarioManager = ({
registry, // Injected
store, // Injected
}: {
registry: ScenarioRegistry;
store: ScenarioStore;
}): ScenarioManager => {
return {
registerScenario(definition) {
registry.register(definition); // Delegate to injected port
},
// ...
};
};
```
**Why dependency injection matters:** * ✅ Testable (inject mocks for unit testing) * ✅ True hexagonal architecture * ✅ Follows dependency inversion principle * ✅ Clean separation between domain logic and infrastructure ### Default Implementations The core provides default in-memory implementations. Note: This is internal implementation detail - users interact through adapters, not directly with core:
```typescript
// Internal implementation (adapters use this, users don't import directly)
import {
createScenarioManager,
InMemoryScenarioRegistry,
InMemoryScenarioStore,
InMemoryStateManager,
InMemorySequenceTracker,
noOpLogger, // Silent by default
} from '@scenarist/core'; // Internal package
const registry = new InMemoryScenarioRegistry();
const store = new InMemoryScenarioStore();
const stateManager = new InMemoryStateManager();
const sequenceTracker = new InMemorySequenceTracker();
const scenarioManager = createScenarioManager({
registry,
store,
stateManager,
sequenceTracker,
logger: noOpLogger, // Injected dependency
});
```
For debugging, you can inject a `ConsoleLogger` instead. See [Logging Reference](/reference/logging/) for details. ## Declarative Patterns: Why Scenarios Are Data, Not Functions Scenarist enforces **declarative patterns** - scenarios describe WHAT to return, not HOW to decide. This is an intentional constraint that leads to better test scenarios.
```typescript
// ✅ DECLARATIVE - Pure data describing intent
type ScenaristMock = {
readonly method: 'GET' | 'POST';
readonly url: string | RegExp; // String or RegExp pattern
readonly match?: { // Explicit match criteria
body?: Record;
headers?: Record;
};
readonly response: {
readonly status: number;
readonly body?: unknown;
};
};
// ❌ IMPERATIVE - Hidden logic in functions
const handler = (req: Request) => {
if (req.headers.get('x-tier') === 'premium') {
return premiumResponse;
}
return standardResponse; // Logic hidden in function body
};
```
**Why declarative patterns matter:** | Imperative (Functions) | Declarative (Data) | | ------------------------------- | ------------------------------------- | | Logic hidden in function bodies | Intent visible in match criteria | | Can’t inspect without executing | Can validate statically | | Manual if/else ordering | Automatic specificity-based selection | | Closures capture external state | No hidden dependencies | | Routing hacks possible | No routing hacks | **The constraint in action:**
```typescript
// ❌ IMPERATIVE - What we prevent
const handler = (request) => {
const tier = request.headers.get('x-tier');
const referer = request.headers.get('referer');
if (referer?.includes('/premium')) {
return premiumResponse; // Hidden routing hack!
}
if (tier === 'premium') return premiumResponse;
return standardResponse;
};
// ✅ DECLARATIVE - What Scenarist enforces
const mocks = [
{
url: '/api/products',
match: { headers: { 'x-tier': 'premium' } },
response: premiumResponse
},
{
url: '/api/products',
response: standardResponse // Fallback (no match criteria)
}
];
// Intent is visible: "premium tier header → premium response"
```
This mirrors React’s design philosophy: React could allow imperative DOM manipulation, but enforces declarative JSX because it’s clearer, more composable, and easier to reason about. Scenarist makes the same choice for test scenarios. **Runtime conversion:** A single MSW handler interprets the declarative definitions at request time:
```typescript
const buildResponse = async (response: ScenaristResponse): Promise => {
if (response.delay) await delay(response.delay);
return HttpResponse.json(response.body, {
status: response.status,
headers: response.headers,
});
};
// Registered once: http.all('*', ...) selects the matching mock's response, then calls buildResponse
```
## Benefits of Hexagonal Architecture **1. Framework Independence** * Write scenarios once, use with Express and Next.js (App Router and Pages Router) * Switching frameworks doesn’t require rewriting tests * Core improvements benefit all frameworks immediately **2. Testability** * Core has zero dependencies (easy to test) * Adapters are thin (easy to test) * Ports can be mocked for unit testing **3. Extensibility** * Add new frameworks by writing \~100 line adapter * Add new storage backends by implementing ports * Add new features in core, all adapters benefit **4. Maintainability** * Clear separation of concerns * Framework-specific code isolated to adapters * Domain logic centralized in core ## Next Steps * [Scenario Format](/scenarios/basic-structure/) - How to define and structure scenarios * [Dynamic Responses](/scenarios/overview/) - Request matching, sequences, and state * [Framework Guides](/frameworks/express/getting-started/) - Integrating with your framework
# Production Safety
> How Scenarist ensures zero test code in production bundles
Scenarist is **safe to use in production** - your test mocking code will never reach production users. ## How It Works When your production build resolves the `production` export condition, `createScenarist()` resolves to a stub that returns `undefined` without loading any test code, so no Scenarist or MSW code ends up in your production bundle. Next.js production builds resolve this condition automatically; Express builds must enable it (see [Express](#framework-specific-verification) below). Independently of the condition, `createScenarist()` also returns `undefined` when `enabled` is `false`, and the Express adapter returns `undefined` when `NODE_ENV` is `production` (see [Runtime Guards](#runtime-guards)).
```typescript
import { createScenarist } from "@scenarist/express-adapter";
import { scenarios } from "./scenarios";
// In production builds (`production` export condition), or when enabled is false: returns undefined
// In development/test with enabled: true: returns working Scenarist instance
export const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
});
```
### The Production Wrapper Pattern All Scenarist adapters use **conditional package.json exports** to swap in a production entry point that imports nothing:
```typescript
// production.js - resolved via the `production` export condition
export const createScenarist = (_options) => {
return undefined; // ← Returns immediately, no test code loaded
};
// Development/test (default condition): the full implementation is exported
```
This pattern ensures: * ✅ **Zero runtime impact** - Production code paths never execute test logic * ✅ **Nothing to tree-shake** - The production entry point has zero imports * ✅ **Automatic in Next.js** - Next.js production builds resolve the `production` condition; Express builds need it enabled * ✅ **Type-safe** - TypeScript enforces null checks via `| undefined` return type ### Runtime Guards * **`enabled: false`**: every adapter returns `undefined` from `createScenarist()`: no endpoints, no middleware, no interception. * **`NODE_ENV=production` (Express)**: the Express adapter also returns `undefined` when `NODE_ENV` is `production`, even with `enabled: true`. This protects unbundled Express apps started without `--conditions=production`. A runtime guard makes Scenarist inert, but it does not remove code: without the `production` condition, the adapter and MSW are still loaded or bundled. enabled is honoured `enabled` decides whether Scenarist runs, so it must evaluate to `true` in the process that serves your tests. For Express, `process.env.NODE_ENV === "test"` works when your test runner sets it (Vitest and Jest do). **In Next.js, don’t gate on `NODE_ENV === "test"`:** Next.js replaces `process.env.NODE_ENV` in your code with `'development'` under `next dev` and `'production'` under `next build`, even when you start it with `NODE_ENV=test`, so that check is never true. Use `enabled: true`; production builds are excluded by the `production` export condition. ## Bundle Size Impact Production bundles contain **zero Scenarist code** - the production wrapper pattern combined with tree-shaking completely eliminates all test code from your production builds. ### Verification **Critical:** Always verify tree-shaking is working in your production builds. This section provides multiple methods to confirm Scenarist code is eliminated. #### Quick Verification (30 seconds) The fastest way to verify tree-shaking by searching for **MSW runtime functions** (not just the word “msw”):
```bash
# Build for production
NODE_ENV=production npm run build
# Search for MSW runtime code (setupWorker, HttpResponse, handler functions)
# and Scenarist's shared server global, which survives minification
OUT_DIR=dist # your build output directory, e.g. dist, .next or build
test -d "$OUT_DIR" && {
grep -rqE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' "$OUT_DIR"
test $? -eq 1
}
```
**Expected result:** exit code 0. `grep` exits 1 when nothing matches, so the command succeeds only when the output directory exists and contains no matches. A non-zero exit means tree-shaking failed or the directory is missing. **Why this pattern:** Searching for the literal strings “scenarist” or “msw” gives false positives (your own variable names, Zod code, comments). This pattern searches for actual MSW runtime functions like `http.get()`, `HttpResponse.json()`, and worker setup - code that should NEVER appear in production bundles. #### Visual Bundle Analysis Visual analysis tools provide the most comprehensive verification. Choose the tool matching your bundler: **Next.js (Webpack):**
```bash
# Install analyzer
npm install --save-dev @next/bundle-analyzer
# Add to next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// ... your config
});
# Build and analyze
ANALYZE=true NODE_ENV=production npm run build
```
Opens interactive visualization in browser. Search for “scenarist” or “msw” - should find nothing. **Vite:**
```bash
# Install visualizer
npm install --save-dev rollup-plugin-visualizer
# Add to vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
],
});
# Build and analyze
NODE_ENV=production npm run build
```
Opens `stats.html` showing bundle composition. Scenarist/MSW should be absent. **Webpack (standalone):**
```bash
# Install analyzer
npm install --save-dev webpack-bundle-analyzer
# Generate stats during build
webpack --mode production --profile --json > stats.json
# Analyze
npx webpack-bundle-analyzer stats.json
```
**Rollup:**
```bash
# Install visualizer
npm install --save-dev rollup-plugin-visualizer
# Add to rollup.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
filename: 'bundle-stats.html',
open: true,
}),
],
};
# Build
NODE_ENV=production npm run build
```
#### Source Map Analysis (Framework-Agnostic) Works with any bundler:
```bash
# Install source-map-explorer
npm install --save-dev source-map-explorer
# Build with source maps
NODE_ENV=production npm run build
# Analyze (adjust paths to your build output)
npx source-map-explorer 'dist/**/*.js' 'dist/**/*.js.map'
```
Generates treemap showing code origins. Scenarist/MSW should not appear. #### Size Comparison (Before/After) Measure actual bundle size impact:
```bash
# Baseline: Build without Scenarist
# (Comment out scenarist imports temporarily)
NODE_ENV=production npm run build
du -sh dist/ .next/static/ build/
# Note the size
# With Scenarist: Build normally
# (Uncomment scenarist imports)
NODE_ENV=production npm run build
du -sh dist/ .next/static/ build/
# Compare sizes - should be identical or negligible difference
```
**Expected:** ≤ 1KB difference (rounding/metadata only) #### Framework-Specific Verification **Express (Requires the `production` Condition):** Caution The Express adapter’s production entry point is selected through the `production` export condition. Node.js and standalone bundlers such as esbuild do not enable this custom condition by default, so setting `NODE_ENV=production` alone does not remove Scenarist’s code. The runtime guard still returns `undefined`, so no endpoints are mounted, but the adapter and `msw` are loaded, so `msw` must be installed. **Unbundled Deployments:** Run Node with the `production` condition:
```bash
# Deploy directly
NODE_ENV=production node --conditions=production src/server.js
# Without the condition: Scenarist is inert (/__scenario__ returns 404),
# but its code and msw are still loaded
NODE_ENV=production node src/server.js
```
**How it works:** * Node resolves `@scenarist/express-adapter` to its production entry point * `createScenarist()` returns `undefined` at runtime * MSW code **never loads into memory** Verify Node resolves the production entry point, using the same Node flags as your start command (Node.js 20.6+, run from your app’s root):
```bash
# Succeeds only when @scenarist/express-adapter resolves to its zero-import stub
node --conditions=production --input-type=module \
-e "console.log(import.meta.resolve('@scenarist/express-adapter'))" \
| grep -q '/dist/setup/production.js$'
```
Checking open files with `lsof` does not work for this: Node closes module files after loading them, so MSW never shows up even when it is loaded. **Bundled Deployments (esbuild, webpack, Vite, rollup):** Scenarist uses **conditional package.json exports** to provide different entry points:
```json
{
"exports": {
".": {
"production": "./dist/setup/production.js", // Zero dependencies
"default": "./dist/index.js" // Full implementation
}
}
}
```
The `"production"` condition is **custom** (not a Node.js built-in). Bundlers must be configured to recognize it: **esbuild:**
```bash
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js --define:process.env.NODE_ENV='"production"' --conditions=production
```
**webpack:** webpack.config.js
```js
module.exports = {
mode: "production",
resolve: {
conditionNames: ["production", "import", "require"],
},
};
```
**Vite:** vite.config.js
```js
export default {
resolve: {
conditions: ["production"],
},
};
```
**rollup:**
```js
import resolve from "@rollup/plugin-node-resolve";
export default {
plugins: [
resolve({
exportConditions: ["production"],
}),
],
};
```
Caution **WARNING**: The `--conditions` flag is **GLOBAL** and affects ALL packages with conditional exports in your dependency tree, not just Scenarist. This could change behavior of other dependencies unexpectedly. **Verify bundled Express app:**
```bash
# Build production bundle (with --conditions=production)
NODE_ENV=production npm run build
# Search for MSW code (succeeds only if dist/ exists and has no matches)
test -d dist && {
grep -rqE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/
test $? -eq 1
}
```
Minifiers rename MSW’s internal identifiers, so `setupWorker` or `HttpResponse.json` can miss MSW code in a minified bundle. `__scenarist_shared_msw_server` is a global property name that survives minification. **Example verification script:**
```json
{
"scripts": {
"build:production": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js --external:express --define:process.env.NODE_ENV='\"production\"' --minify --conditions=production",
"verify:treeshaking": "npm run build:production && test -d dist && { grep -rqE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\\.(get|post|put|delete|patch)|HttpResponse\\.json)' dist/; test $? -eq 1; }"
}
}
```
Run: `npm run verify:treeshaking` **For detailed bundler configuration examples**, see the [Express Adapter README - Production Tree-Shaking](https://github.com/citypaul/scenarist/tree/main/packages/express-adapter#production-tree-shaking). **Next.js (Detailed):** Next.js has separate client and server bundles. Verify both:
```bash
# Build
NODE_ENV=production npm run build
# Check client and server bundles for Scenarist/MSW runtime code.
# grep exits 1 when nothing matches, so this succeeds (exit 0) only when the
# bundles exist and contain no matches.
test -d .next/static && test -d .next/server && {
grep -rqE --include='*.js' '(__scenarist_shared_msw_server|setupWorker|HttpResponse\.json)' .next/static .next/server
test $? -eq 1
}
```
**Vercel Deployment:** If deployed to Vercel, check production bundle size in deployment logs:
```plaintext
Build Output:
├ ƒ / 1.2 kB 123 B
├ ○ /404 2.1 kB 456 B
└ ƒ /api/products 450 B 78 B
```
Compare with pre-Scenarist deployment - sizes should be nearly identical. #### Automated CI/CD Verification Prevent tree-shaking regressions by adding checks to CI: **GitHub Actions Example:**
```yaml
name: Verify Production Safety
on:
pull_request:
paths:
- "package.json"
- "src/**"
jobs:
verify-bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Build production bundle
run: NODE_ENV=production npm run build
env:
NODE_ENV: production
- name: Verify no MSW runtime code in bundle
run: |
OUT_DIR=dist # your build output directory, e.g. dist, .next or build
status=0
grep -rE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' "$OUT_DIR" || status=$?
if [ "$status" -eq 0 ]; then
echo "❌ ERROR: MSW runtime code found in production bundle!"
echo "Tree-shaking failed. Check the production condition and bundler config."
exit 1
fi
if [ "$status" -ne 1 ]; then
echo "❌ ERROR: could not search $OUT_DIR"
exit 1
fi
echo "✅ Verified: No MSW runtime code in production bundle"
- name: Compare bundle sizes
run: |
# Store baseline size (first run) or compare
BUNDLE_SIZE=$(du -sb dist/ .next/ build/ 2>/dev/null | awk '{sum+=$1} END {print sum}')
echo "Bundle size: $BUNDLE_SIZE bytes"
# Add budget check if needed
```
**npm script:** Add to `package.json` for manual verification:
```json
{
"scripts": {
"verify:production": "NODE_ENV=production npm run build && test -d dist && { grep -rqE '(__scenarist_shared_msw_server|setupWorker|HttpResponse\\.json)' dist/; test $? -eq 1; }"
}
}
```
Run: `npm run verify:production` #### What Success Looks Like **✅ Passing verification:** * Bundle analyzer shows NO `scenarist` or `msw` packages * `grep` search returns no matches (exit code 1) * Bundle size same as before adding Scenarist (±1KB) * Source maps show NO Scenarist source files * Production deployment works normally **❌ Failed verification (tree-shaking didn’t work):** * Bundle contains `scenarist` or `msw` strings * Bundle size significantly larger than expected * `createScenarist` function visible in bundle * MSW handlers appear in production code #### Troubleshooting Failed Verification If verification fails, check these common issues: **1. `production` export condition not resolved:**
```bash
# ❌ Wrong - resolves the full implementation
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js
# ✅ Correct - resolves Scenarist's production entry point
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js --conditions=production
```
**2. Bundler not configured for tree-shaking:** Most bundlers enable tree-shaking by default in production mode. Verify config: **Webpack:** webpack.config.js
```js
module.exports = {
mode: "production", // ← Required
optimization: {
usedExports: true, // ← Should be true (default in production)
sideEffects: true, // ← Should be true (default)
},
};
```
**Vite:**
```js
// vite.config.js - tree-shaking automatic in production
export default defineConfig({
build: {
minify: "terser", // Or 'esbuild' - both tree-shake
},
});
```
**3. Express bundle built without the `production` condition:** Code splitting and `NODE_ENV=production` do not remove Scenarist from an Express bundle. The adapter imports its implementation statically, so only the `production` export condition swaps it for the zero-import stub:
```bash
# ❌ Adapter and MSW code stay in the bundle
esbuild src/server.ts --bundle --platform=node --format=esm --splitting --outdir=dist --define:process.env.NODE_ENV='"production"'
# ✅ Resolves the stub: no Scenarist or MSW code in the bundle
esbuild src/server.ts --bundle --platform=node --format=esm --splitting --outdir=dist --define:process.env.NODE_ENV='"production"' --conditions=production
```
See [Express bundled deployments](#framework-specific-verification) for webpack, Vite, and rollup configuration. **4. package.json sideEffects field:** Check if your bundler respects `sideEffects: false` in Scenarist’s package.json:
```bash
cat node_modules/@scenarist/*/package.json | grep sideEffects
# Should show: "sideEffects": false
```
If this is missing, [file an issue](https://github.com/citypaul/scenarist/issues) - this is a bug. **5. TypeScript compilation settings:** Ensure TypeScript preserves ES modules: tsconfig.json
```json
{
"compilerOptions": {
"module": "ES2020", // ← Or "ESNext", not "CommonJS"
"moduleResolution": "bundler" // ← Or "node16"
}
}
```
CommonJS modules prevent tree-shaking in most bundlers. #### Still Having Issues? If tree-shaking still fails after checking the above: 1. **Check your bundler version** - Update to latest version 2. **Review bundler logs** - Look for warnings about side effects 3. **Minimal reproduction** - Test in fresh project to isolate issue 4. **Open an issue** - [Report it](https://github.com/citypaul/scenarist/issues) with: * Bundler name and version * Framework name and version * Bundler config * Output of `npm ls @scenarist/*` For additional help, see: * [Production Tree-Shaking Verification](/reference/verification/#production-tree-shaking-verification) - Verify the production entry point is used * [Header Propagation in Parallel Tests](/reference/verification/#header-propagation-in-parallel-tests) - Troubleshoot test isolation issues ## Type Safety The return type `ExpressScenarist | undefined` (or equivalent for your adapter) forces you to handle the production case:
```typescript
// TypeScript enforces null checks
if (!scenarist) {
// Production mode - scenarist is undefined
return;
}
// Development/test mode - scenarist is defined
scenarist.start();
```
This prevents accidentally calling test methods in production code. ## Framework-Specific Notes ### Express
```typescript
import { createScenarist } from "@scenarist/express-adapter";
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Type-safe null check required
if (scenarist) {
scenarist.start(); // Only runs in development/test
}
```
### Next.js (App Router & Pages Router) Both Next.js adapters use the **synchronous pattern** with conditional exports for production safety: lib/scenarist.ts
```typescript
import { createScenarist } from "@scenarist/nextjs-adapter/app";
// or: import { createScenarist } from '@scenarist/nextjs-adapter/pages';
// Synchronous - no async/await needed
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// MSW auto-starts in development/test (when scenarist is defined)
if (typeof window === "undefined" && scenarist) {
scenarist.start();
}
```
**Production behavior:** * ✅ **Conditional exports** resolve to `production.js` which returns `undefined` immediately * ✅ **Zero imports** in production.js guarantees tree-shaking * ✅ **Zero configuration:** Works automatically based on `NODE_ENV` **Safe Helper Functions:** To avoid `scenarist?.method() ?? fallback` patterns everywhere, both adapters export safe helper functions:
```typescript
// App Router API Route
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
export async function GET(request: Request) {
const response = await fetch("http://localhost:3001/products", {
headers: {
...getScenaristHeaders(request), // Always safe, no guards needed
"x-user-tier": "premium",
},
cache: "no-store",
});
}
```
```typescript
// App Router Server Component
import { headers } from "next/headers";
import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app";
export default async function ProductsPage() {
const headersList = await headers();
const response = await fetch("http://localhost:3001/products", {
headers: getScenaristHeadersFromReadonlyHeaders(headersList),
cache: "no-store",
});
}
```
```typescript
// Pages Router API Route
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/pages";
export default async function handler(req, res) {
const response = await fetch("http://localhost:3001/products", {
headers: getScenaristHeaders(req),
});
}
```
**Available helpers:** * **App Router:** * `getScenaristHeaders(request)` - Extract Scenarist headers from Request * `getScenaristHeadersFromReadonlyHeaders(headers)` - Extract Scenarist headers from ReadonlyHeaders * `getScenaristTestId(request)` - Extract test ID string from Request * `getScenaristTestIdFromReadonlyHeaders(headers)` - Extract test ID string from ReadonlyHeaders * **Pages Router:** * `getScenaristHeaders(req)` - Extract Scenarist headers from IncomingMessage These helpers access global singletons and return safe defaults in production (empty objects for headers, `'default-test'` for test IDs), eliminating the need for manual undefined checks. ## Common Questions ### Does Scenarist run in production if I forget NODE\_ENV? **Not if `enabled` is tied to your test environment.** In Express, gating `enabled` on `process.env.NODE_ENV === "test"` means an unset `NODE_ENV` makes `createScenarist()` return `undefined`: no endpoints, no interception. (In Next.js use `enabled: true`: Next.js inlines `NODE_ENV`, so a `"test"` check is never true, and production builds are excluded by the `production` export condition.) `enabled` is the switch, not `start()`: MSW only intercepts requests after `scenarist.start()`, but the scenario endpoints are active wherever you mount the middleware (Express) or export the route handlers (Next.js). You should still set `NODE_ENV=production` in production for: * The Express runtime guard (returns `undefined` even with `enabled: true`) * Optimal performance * Correct framework behavior ### What about CI/CD environments? CI/CD environments should use: * `NODE_ENV=test` for running tests * `NODE_ENV=production` for building production bundles Scenarist works in both modes: * **Test mode:** Full functionality for scenario-based tests * **Production builds (`production` export condition):** Returns undefined, so no test code is bundled ### Can I verify tree-shaking before deploying? Yes! See the [Production Tree-Shaking Verification](/reference/verification/#production-tree-shaking-verification) section for: * Step-by-step build verification * Bundle size analysis * Build artifact inspection * Framework-specific verification commands * Red flags and troubleshooting ### What if tree-shaking fails? If your bundler doesn’t tree-shake Scenarist: 1. **Check the `production` condition:** Ensure your bundler (or `node --conditions=production`) resolves it 2. **Check bundler config:** Verify tree-shaking is enabled (it usually is by default) 3. **Check sideEffects:** Scenarist marks itself as side-effect-free in package.json 4. **File an issue:** If tree-shaking still fails, [open an issue](https://github.com/citypaul/scenarist/issues) with your bundler details ## Best Practices 1. **Always use `NODE_ENV=production` for production builds**
```bash
NODE_ENV=production npm run build
```
2. **Add null checks where you use Scenarist**
```typescript
if (scenarist) {
scenarist.start();
}
```
3. **Verify bundle size in CI/CD**
```yaml
- name: Check bundle size
run: npm run build && npm run analyze
```
4. **Monitor production bundles** * Set up bundle size budgets * Alert on unexpected size increases * Review bundle composition regularly ## Supply Chain Security Beyond production safety, Scenarist provides cryptographic verification of package authenticity: * **npm Provenance**: Packages include [npm provenance](https://docs.npmjs.com/generating-provenance-statements) attestations * **GitHub Attestations**: Build provenance attestations verifiable via GitHub CLI * **SBOM**: Software Bill of Materials signed with Sigstore ### Verifying Packages Verify any Scenarist package was built from this repository:
```bash
# Download and verify a package
npm pack @scenarist/core
gh attestation verify scenarist-core-*.tgz -R citypaul/scenarist
```
View all attestations at [github.com/citypaul/scenarist/attestations](https://github.com/citypaul/scenarist/attestations). For complete security documentation, see [SECURITY.md](https://github.com/citypaul/scenarist/blob/main/SECURITY.md). ## Summary Scenarist is designed for production safety: | Aspect | Guarantee | | ------------------ | --------------------------------------------------------- | | Production runtime | No endpoints, no request interception | | Bundle size | Zero Scenarist code after tree-shaking | | Configuration | Next.js: none; Express: enable the `production` condition | | Type safety | TypeScript enforces null checks | | Performance | No runtime overhead in production | **The bottom line:** the `production` export condition removes Scenarist entirely, and the runtime guards (`enabled: false`, and `NODE_ENV=production` in Express) keep it inert when the condition is not applied.
# Testing Apps with Database Access
> How to test applications that use direct database access alongside Scenarist
## The Pattern: Shared Identity for Parallel Test Isolation When testing applications that access multiple data sources (HTTP APIs + databases), the key insight is simple: **Use a single identifier to partition ALL data sources.**
```plaintext
┌─────────────────────────────────────────────────────────┐
│ Test Runner │
├─────────────────────────────────────────────────────────┤
│ Test A (x-scenarist-test-id: abc-123) │
│ │ │
│ ├─→ HTTP API ──→ Scenarist ──→ Mocks for "abc-123" │
│ │ │
│ └─→ Database ──→ Your Code ──→ Data for "abc-123" │
├─────────────────────────────────────────────────────────┤
│ Test B (x-scenarist-test-id: xyz-789) │
│ │ │
│ ├─→ HTTP API ──→ Scenarist ──→ Mocks for "xyz-789" │
│ │ │
│ └─→ Database ──→ Your Code ──→ Data for "xyz-789" │
└─────────────────────────────────────────────────────────┘
Both tests run in PARALLEL with completely isolated state.
```
This is the same pattern used in distributed systems for request tracing, multi-tenancy, and session management. The test ID is just another form of correlation ID. *** ## What Scenarist Provides vs. What You Implement | Responsibility | Tool | What It Does | | ------------------------ | ------------- | ------------------------------------------------------ | | HTTP API mocking | **Scenarist** | Intercepts fetch calls, returns mocks based on test ID | | Database state isolation | **Your code** | Partitions database queries/data based on test ID | Scenarist scripts * **Third-party HTTP APIs**: Stripe, Auth0, SendGrid * **Other services your server calls** over HTTP * **Any outgoing HTTP request** from your server code Scenarist leaves alone * **Database queries**: Prisma, Drizzle, raw SQL * **Your own API routes and Server Actions**: they’re your code, so let them run for real * **File system operations**: `fs.readFile` and similar *** ## Implementing Database Isolation There are multiple valid approaches to partition database state by test ID. Each has different trade-offs: ### Approach A: Repository Pattern with In-Memory Store Abstract database access behind interfaces, inject test implementations that partition data by test ID. **Best for:** Fast parallel tests, clean architecture, any ORM **Trade-offs:** Requires refactoring database access; doesn’t test real SQL [Detailed Guide →](./repository-pattern) ### Approach B: Test Fixtures with Direct Seeding Seed database directly in test setup, using test ID to isolate data.
```typescript
test('premium user sees discounts', async ({ page }) => {
const testId = generateTestId();
// Seed directly (if tests have DB access)
await db.users.create({
testId,
tier: 'premium',
// ...
});
// Or seed via HTTP endpoint
await page.request.post('/test/seed', {
headers: { 'x-scenarist-test-id': testId },
data: { scenarioId: 'premiumUser' }
});
await page.goto('/products');
// ...
});
```
**Best for:** When tests have direct database access (e.g., same process) **Trade-offs:** Tests coupled to database schema; requires cleanup logic ### Approach C: Row-Level Security or Test ID Columns Add `test_id` column to tables, filter all queries automatically.
```sql
-- PostgreSQL RLS policy
CREATE POLICY test_isolation ON users
USING (test_id = current_setting('app.test_id'));
```
**Best for:** When you can’t change application code **Trade-offs:** Schema changes in production; RLS overhead ### Approach D: Database Snapshots/Migrations Pre-seed database with known state, reset between tests. **Best for:** Testing specific SQL behavior; integration tests **Trade-offs:** Slower; harder to parallelize *** ## Our Recommendation: Repository Pattern For teams that want **scalable parallel testing** of real server-side code, we recommend the repository pattern because: 1. **Same isolation model as Scenarist** — Test ID partitions both HTTP and database 2. **Fast execution** — In-memory stores are orders of magnitude faster than real databases 3. **Clean architecture** — Benefits extend beyond testing (infrastructure flexibility, SOLID principles) 4. **ORM agnostic** — Works with Prisma, Drizzle, TypeORM, raw SQL [Full Repository Pattern Guide →](./repository-pattern) The Pattern vs. The Implementation The **pattern** is “use test ID to partition database state.” The **repository pattern** is one way to implement that pattern. It’s the approach we show in our examples because it provides the cleanest architecture, but it’s not the only way. If your constraints don’t allow refactoring to repositories, consider the other approaches above. *** ## Why Database Testing Is Different Scenarist intercepts HTTP requests via MSW because every HTTP library eventually calls the same underlying APIs. **Databases have no equivalent universal interception point.** Each ORM and driver is a different API surface: * Prisma: `prisma.user.findMany()` * Drizzle: `db.select().from(users)` * TypeORM: `userRepository.find()` * Raw SQL: `pg.query('SELECT * FROM users')`
```typescript
// ❌ Scenarist CANNOT mock this - no HTTP request
export async function fetchProducts() {
return await db.products.findMany(); // Direct database call
}
```
That’s why **you implement** database isolation, using whatever approach fits your architecture. The examples show one way; adapt it to your needs. *** ## Quick Decision Guide | Your Situation | Approach | | ------------------------------------------------- | --------------------------------------------------------------------------- | | App only calls external HTTP APIs (no database) | Scenarist alone; no database strategy needed | | Want scalable parallel tests + clean architecture | [Repository Pattern](./repository-pattern) | | Tests have direct database access | Test fixtures with direct seeding | | Can’t change application code | Row-level security or test ID columns | | Need to test actual SQL queries, constraints | [Testcontainers](./testcontainers-hybrid) | | Small test suite, prototyping | [Sequential Execution](./parallelism-options#option-1-sequential-execution) | *** ## Next Steps * [Repository Pattern Guide](./repository-pattern) — Full implementation walkthrough * [Testcontainers Hybrid](./testcontainers-hybrid) — Testing real database behavior * [All Parallelism Options](./parallelism-options) — Detailed comparison of approaches
# Database Testing Parallelism Options
> Compare strategies for parallel database testing—trade-offs between code changes, schema changes, complexity, and speed
## The Core Problem Databases have no equivalent to HTTP’s test ID header. Without isolation, parallel tests corrupt each other’s data. [We recommend the repository pattern](/guides/testing-database-apps/) for most teams. This page compares all options. ## Options Comparison | Approach | Parallelism | No Code Changes | No Schema Changes | ORM Agnostic | Complexity | | ------------------------------ | ----------- | --------------- | ----------------- | ------------ | ---------- | | Sequential execution | ❌ | ✅ | ✅ | ✅ | Low | | ⭐ Repository pattern | ✅ | ❌ | ✅ | ✅ | Medium | | Sharding (container per shard) | ✅ | ✅ | ✅ | ✅ | Medium | | PostgreSQL RLS | ✅ | ✅ | ❌ | ❌ | High | | Schema per test | ✅ | ✅ | ✅ | ✅ | Very High | **No option gives you everything.** Choose based on your priorities. ## Option 1: Sequential Execution **Accept the limitation.** Run database tests sequentially, HTTP tests in parallel. playwright.config.ts
```typescript
export default defineConfig({
projects: [
{
name: 'database-tests',
testMatch: '**/db-*.spec.ts',
fullyParallel: false, // Sequential
},
{
name: 'api-tests',
testMatch: '**/api-*.spec.ts',
fullyParallel: true, // Parallel (Scenarist handles isolation)
},
],
});
```
**Best for:** * Quick prototypes where test speed doesn’t matter yet * Teams who know their test suite will remain small * Temporary solution while planning migration to repository pattern **Trade-offs:** * ✅ No code changes * ✅ No schema changes * ✅ Works with any ORM * ❌ Doesn’t scale—CI time grows linearly with test count * ❌ Limits your ability to run comprehensive test suites ## Option 2: Repository Pattern (Recommended) Abstract database access behind interfaces and inject test implementations.
```typescript
// Define repository interface
interface UserRepository {
findById(id: string): Promise;
create(user: User): Promise;
findByEmail(email: string): Promise;
}
// Production implementation
class PrismaUserRepository implements UserRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
async create(user: User) {
return this.prisma.user.create({ data: user });
}
async findByEmail(email: string) {
return this.prisma.user.findFirst({ where: { email } });
}
}
// Test implementation with test ID isolation
class InMemoryUserRepository implements UserRepository {
private store = new Map>();
constructor(private getTestId: () => string) {}
private getTestStore() {
const testId = this.getTestId();
if (!this.store.has(testId)) {
this.store.set(testId, new Map());
}
return this.store.get(testId)!;
}
async findById(id: string) {
return this.getTestStore().get(id) ?? null;
}
async create(user: User) {
this.getTestStore().set(user.id, user);
return user;
}
async findByEmail(email: string) {
for (const user of this.getTestStore().values()) {
if (user.email === email) return user;
}
return null;
}
}
```
```typescript
// Inject based on environment
const userRepository = process.env.NODE_ENV === 'test'
? new InMemoryUserRepository(() => getTestIdFromHeader())
: new PrismaUserRepository(prisma);
```
**Best for:** * Teams wanting true test-level parallelism * Growing codebases that need scalable testing * Teams valuing clean architecture and infrastructure flexibility **Trade-offs:** * ✅ True parallelism with test ID isolation * ✅ No schema changes * ✅ ORM agnostic (swap implementations freely) * ✅ In-memory tests are fast * ✅ Infrastructure flexibility (change databases/ORMs later) * Requires abstracting database access behind interfaces * Separate tests needed for real database behavior [**Learn more about the Repository Pattern →**](./repository-pattern) ## Option 3: Sharding (Container per Shard) Run multiple PostgreSQL containers, one per Playwright shard. This provides parallelism at the shard level (not test level)—tests within each shard still run sequentially against their container. playwright.config.ts
```typescript
export default defineConfig({
workers: 4, // 4 parallel workers
});
// globalSetup.ts
export default async function globalSetup() {
const workerId = process.env.TEST_WORKER_INDEX || '0';
// Each worker gets its own container
const container = await new PostgreSqlContainer()
.withDatabase(`test_${workerId}`)
.start();
process.env.DATABASE_URL = container.getConnectionUrl();
}
```
Run with sharding:
```bash
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
```
**Best for:** * CI/CD with good parallelization support * Teams with container orchestration experience * Teams who want parallelism without code changes **Trade-offs:** * ✅ Shard-level parallelism * ✅ No code or schema changes * ❌ Not test-level parallelism (tests within shard still sequential) * ❌ Resource-intensive (CPU, memory, disk) * ❌ Complex CI/CD setup * ❌ Slower container startup (multiplied by shard count) ## Option 4: PostgreSQL Row-Level Security (RLS) Use PostgreSQL’s built-in RLS to isolate by test ID without application code changes.
```sql
-- Add test_id to tables
ALTER TABLE users ADD COLUMN test_id TEXT;
-- Create policy
CREATE POLICY test_isolation ON users
USING (test_id = current_setting('app.test_id', true));
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
```
```typescript
// Set session variable per request
app.use(async (req, res, next) => {
const testId = req.headers['x-scenarist-test-id'];
await prisma.$executeRaw`SELECT set_config('app.test_id', ${testId}, true)`;
next();
});
```
**Best for:** * PostgreSQL users only * Teams comfortable with database-level security * Applications where RLS is already used **Trade-offs:** * ✅ True parallelism * ✅ No application code changes (after setup) * ✅ Database handles isolation * ❌ PostgreSQL only (not portable) * ❌ Schema changes required * ❌ Complex to debug * ❌ Performance overhead ## Option 5: Schema Per Test Create a database schema for each test, complete isolation.
```typescript
// Before each test
const testId = generateTestId();
await prisma.$executeRaw`CREATE SCHEMA test_${testId}`;
await prisma.$executeRaw`SET search_path TO test_${testId}`;
// Run migrations in new schema
execSync('npx prisma migrate deploy');
// After test
await prisma.$executeRaw`DROP SCHEMA test_${testId} CASCADE`;
```
**Best for:** * Complete isolation requirements * Teams with existing schema management tooling * Debugging complex test failures **Trade-offs:** * ✅ Complete isolation * ✅ No code changes * ✅ Works with any ORM * ❌ Very slow (migrations per test) * ❌ Complex connection management * ❌ High resource usage ## Decision Guide ### Recommended: Repository Pattern For most teams that want scalable, parallel testing of real server-side code through browser-based tests, the repository pattern is the best approach. It provides true test-level parallelism with the same test ID isolation model as Scenarist, and the architectural benefits (clean separation, infrastructure flexibility) extend well beyond testing. ### When to Choose Sequential Execution If you’re building a quick prototype, or you’re certain your application and test suite will remain small. Be aware that this approach doesn’t scale—you’ll need to migrate later if your test suite grows. ### When to Choose Sharding If CI/CD time is critical but you don’t want to change your application code. Requires good infrastructure and Docker orchestration. Note that tests within each shard still run sequentially. ### When to Choose PostgreSQL RLS If you need row-level isolation (e.g., testing multi-tenant applications) and you’re already using PostgreSQL. Accept the schema changes and complexity. ### When to Choose Schema Per Test Rarely. Only if you need complete isolation and can tolerate very slow test execution. ## Recommended Strategy A scalable approach for teams building comprehensive test suites: 1. **Use Scenarist for HTTP mocking** - External APIs (Stripe, Auth0, SendGrid) 2. **Use the repository pattern for database access** - True test-level parallelism with test ID isolation 3. **Test repository implementations with Testcontainers** - Verify real database behavior (queries, constraints, transactions) This gives you the best of both worlds: fast parallel tests for most scenarios, plus real database validation where it matters. ## Next Steps * [Repository Pattern Guide](./repository-pattern) - Test-level parallelism with clean architecture * [Testcontainers Hybrid Guide](./testcontainers-hybrid) - Test real database behavior * [Testcontainers Documentation](https://testcontainers.com/) - Learn container management * [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) - Understand RLS
# Repository Pattern for Parallel Database Testing
> Use the repository pattern to achieve true test parallelism with test ID isolation—no schema changes, any ORM, fast in-memory execution
## Two Concepts Working Together It’s important to distinguish between two separate concepts: ### 1. The Repository Pattern (Architectural) The [repository pattern](https://martinfowler.com/eaaCatalog/repository.html) is a well-established architectural practice that abstracts database access behind interfaces. It’s not specific to testing—it provides [significant benefits](#beyond-testing-why-the-repository-pattern-is-good-practice) including infrastructure flexibility, better separation of concerns, and alignment with SOLID principles. ### 2. Test ID Isolation (Testing Technique) Test ID isolation partitions data by a unique identifier per test, enabling parallel execution without interference. This is the same technique Scenarist uses for HTTP mocking. **This guide combines both:** We use the repository pattern as the mechanism to implement test ID isolation for databases. *** ## Why Use the Repository Pattern for Test Isolation? The repository pattern is particularly well-suited for test ID isolation because: 1. **Interface injection** — Swap production (real DB) for test (in-memory with partitioning) implementations 2. **Clean boundaries** — Database access is already abstracted, making partitioning natural 3. **No schema changes** — Isolation happens in application code, not database 4. **Fast execution** — In-memory stores are orders of magnitude faster than real databases ## How It Works The repository pattern separates *what* your code does from *how* data is persisted. Your business logic depends on an interface (the “what”), while concrete implementations handle the database details (the “how”). **The key insight:** You can swap implementations based on environment: * **Production:** Real implementation that executes actual database queries (Prisma, Drizzle, TypeORM) * **Tests:** In-memory implementation that stores data in JavaScript Maps, partitioned by test ID This gives you the same isolation model as Scenarist’s HTTP mocking: | Scenarist (HTTP) | Repository Pattern (Database) | | -------------------------------------------- | -------------------------------------------- | | `x-scenarist-test-id` header identifies test | `x-scenarist-test-id` header identifies test | | MSW returns scenario-specific responses | Repository returns test-specific data | | Each test gets isolated mock state | Each test gets isolated data store | **The flow:** 1. Test sends request with `x-scenarist-test-id: abc-123` 2. Middleware extracts test ID and stores it in `AsyncLocalStorage` 3. Repository retrieves test ID and uses it as partition key 4. Data operations only affect that test’s partition 5. Parallel tests have completely isolated data stores Because the interface is the same, your business logic doesn’t know or care which implementation is running—it just calls `userRepository.findById()`. The test isolation happens transparently. ## Working Example: Next.js App Router ## Example Implementation (Express) Here’s a complete working example showing how test ID flows from Playwright through Express to isolated in-memory repositories: ### 1. Repository Interface src/repositories/user-repository.ts
```typescript
export type User = {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
};
export type CreateUserInput = {
email: string;
name: string;
};
export interface UserRepository {
findById(id: string): Promise;
findByEmail(email: string): Promise;
findAll(): Promise;
create(data: CreateUserInput): Promise;
}
```
### 2. Production Implementation (Prisma) src/repositories/prisma-user-repository.ts
```typescript
import { PrismaClient } from '@prisma/client';
import type { UserRepository, User, CreateUserInput } from './user-repository';
export class PrismaUserRepository implements UserRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: string): Promise {
return this.prisma.user.findUnique({ where: { id } });
}
async findByEmail(email: string): Promise {
return this.prisma.user.findFirst({ where: { email } });
}
async findAll(): Promise {
return this.prisma.user.findMany();
}
async create(data: CreateUserInput): Promise {
return this.prisma.user.create({ data });
}
}
```
### 3. Test Implementation with Test ID Isolation This is where the magic happens. The test implementation partitions data by test ID: src/repositories/in-memory-user-repository.ts
```typescript
import type { UserRepository, User, CreateUserInput } from './user-repository';
export class InMemoryUserRepository implements UserRepository {
// Map>
private store = new Map>();
private idCounter = new Map();
constructor(private getTestId: () => string) {}
private getTestStore(): Map {
const testId = this.getTestId();
if (!this.store.has(testId)) {
this.store.set(testId, new Map());
this.idCounter.set(testId, 0);
}
return this.store.get(testId)!;
}
private generateId(): string {
const testId = this.getTestId();
const counter = (this.idCounter.get(testId) ?? 0) + 1;
this.idCounter.set(testId, counter);
return `user-${counter}`;
}
async findById(id: string): Promise {
return this.getTestStore().get(id) ?? null;
}
async findByEmail(email: string): Promise {
for (const user of this.getTestStore().values()) {
if (user.email === email) return user;
}
return null;
}
async findAll(): Promise {
return Array.from(this.getTestStore().values());
}
async create(data: CreateUserInput): Promise {
const user: User = {
id: this.generateId(),
...data,
createdAt: new Date(),
updatedAt: new Date(),
};
this.getTestStore().set(user.id, user);
return user;
}
}
```
### 4. Dependency Injection Container src/container.ts
```typescript
import { AsyncLocalStorage } from 'node:async_hooks';
import { PrismaClient } from '@prisma/client';
import type { UserRepository } from './repositories/user-repository';
import { PrismaUserRepository } from './repositories/prisma-user-repository';
import { InMemoryUserRepository } from './repositories/in-memory-user-repository';
// AsyncLocalStorage carries test ID through async request lifecycle
const testIdStorage = new AsyncLocalStorage();
export const getTestId = (): string => {
return testIdStorage.getStore() ?? 'default-test';
};
export const runWithTestId = (testId: string, fn: () => T): T => {
return testIdStorage.run(testId, fn);
};
// Create repositories based on environment
const prisma = new PrismaClient();
export const createRepositories = (): { userRepository: UserRepository } => {
const isTest = process.env.NODE_ENV === 'test';
const userRepository: UserRepository = isTest
? new InMemoryUserRepository(getTestId)
: new PrismaUserRepository(prisma);
return { userRepository };
};
```
### 5. Express Middleware to Extract Test ID src/middleware/test-id-middleware.ts
```typescript
import type { Request, Response, NextFunction } from 'express';
import { runWithTestId } from '../container';
export const testIdMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
const testId = (req.headers['x-scenarist-test-id'] as string) ?? 'default-test';
runWithTestId(testId, () => {
next();
});
};
```
### 6. Express App Setup src/app.ts
```typescript
import express from 'express';
import { testIdMiddleware } from './middleware/test-id-middleware';
import { createRepositories } from './container';
const app = express();
const { userRepository } = createRepositories();
app.use(express.json());
app.use(testIdMiddleware);
app.get('/users', async (req, res) => {
const users = await userRepository.findAll();
res.json({ users });
});
app.post('/users', async (req, res) => {
const { email, name } = req.body;
const existing = await userRepository.findByEmail(email);
if (existing) {
return res.status(400).json({ error: 'Email already registered' });
}
const user = await userRepository.create({ email, name });
res.status(201).json({ user });
});
export { app };
```
### 7. Playwright Tests tests/user-registration.spec.ts
```typescript
import { test, expect } from './fixtures'; // withScenarios(scenarios)
test.describe('User Registration', () => {
test('should register new user', async ({ page, switchScenario }) => {
// Scenarist mocks external email API
await switchScenario(page, 'emailSuccess');
// Repository pattern isolates database by test ID
// (test ID automatically flows from x-scenarist-test-id header set by Scenarist)
await page.goto('/register');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="name"]', 'Test User');
await page.click('button[type="submit"]');
await expect(page.getByText('Welcome, Test User')).toBeVisible();
});
test('should show error for duplicate email', async ({ page, switchScenario }) => {
await switchScenario(page, 'emailSuccess');
// First registration
await page.goto('/register');
await page.fill('[name="email"]', 'duplicate@example.com');
await page.fill('[name="name"]', 'First User');
await page.click('button[type="submit"]');
// Second registration with same email
await page.goto('/register');
await page.fill('[name="email"]', 'duplicate@example.com');
await page.fill('[name="name"]', 'Second User');
await page.click('button[type="submit"]');
await expect(page.getByText('Email already registered')).toBeVisible();
});
});
// These tests run in PARALLEL with full isolation:
// - Test A (x-scenarist-test-id: abc-123) → store['abc-123']
// - Test B (x-scenarist-test-id: def-456) → store['def-456']
// - Same email in different tests → no conflict
```
## How the Test ID Flows 1. **Playwright** sends request with `x-scenarist-test-id` header (set automatically by Scenarist) 2. **Express middleware** extracts the test ID and stores it in `AsyncLocalStorage` 3. **Repository** calls `getTestId()` to retrieve the test ID from `AsyncLocalStorage` 4. **Data partitioning** ensures each test ID maps to its own isolated data store This is the same pattern Scenarist uses internally—`AsyncLocalStorage` carries context through the async request lifecycle. ## Why This Approach Excels ### True Parallelism with Full Isolation Each test gets its own isolated data store, keyed by test ID. Tests run concurrently without interference—the same isolation model as Scenarist’s HTTP mocking. ### Fast Execution In-memory repositories are orders of magnitude faster than real databases: * No network round-trips * No disk I/O * No connection pool overhead * No query parsing/planning ### Infrastructure Flexibility The repository pattern decouples your application from specific persistence technologies. Today you’re using PostgreSQL with Prisma—tomorrow you might migrate to: * A different database (MySQL, MongoDB) * A different ORM (Drizzle, TypeORM) * A different architecture (microservices, event sourcing) Your business logic remains unchanged because it depends on interfaces, not implementations. ### ORM Agnostic The interface is your contract. Swap between Prisma, Drizzle, TypeORM, Knex, or raw SQL without changing tests:
```typescript
// All these implement the same interface
const prismaRepo = new PrismaUserRepository(prisma);
const drizzleRepo = new DrizzleUserRepository(db);
const knexRepo = new KnexUserRepository(knex);
const testRepo = new InMemoryUserRepository(getTestId);
```
### No Schema Changes Unlike PostgreSQL RLS or test ID columns, the repository pattern requires no database schema modifications. Your production database remains clean. ## Trade-offs to Consider ### Requires Abstracting All Database Access Every database call must go through a repository interface. For existing codebases, this can be significant refactoring:
```typescript
// Before: Direct ORM calls scattered throughout code
const user = await prisma.user.findUnique({ where: { id } });
// After: All access through repositories
const user = await userRepository.findById(id);
```
### Two Implementations to Maintain You must maintain both production and test implementations. When the interface changes, both must be updated:
```typescript
// Add new method to interface
interface UserRepository {
// ... existing methods
findByRole(role: string): Promise; // NEW
}
// Must implement in BOTH:
// - PrismaUserRepository
// - InMemoryUserRepository
```
### Doesn’t Test Real Database Behavior The in-memory implementation doesn’t execute actual SQL. Potential issues you might miss: * Query performance problems * Database constraints (unique, foreign keys) * Transaction isolation issues * ORM-specific edge cases Mitigation Strategy **Test the repository implementation itself in isolation.** Create a separate test suite that runs your production repository (e.g., `PrismaUserRepository`) against a real database using [Testcontainers](./testcontainers-hybrid): tests/repositories/prisma-user-repository.test.ts
```typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
import { PrismaUserRepository } from '../../src/repositories/prisma-user-repository';
const createTestContext = async () => {
const container = await new PostgreSqlContainer().start();
const prisma = new PrismaClient({
datasources: { db: { url: container.getConnectionUri() } }
});
await prisma.$executeRaw`CREATE TABLE users (...)`; // Run migrations
const repository = new PrismaUserRepository(prisma);
return {
container,
prisma,
repository,
cleanup: async () => {
await prisma.$disconnect();
await container.stop();
},
};
};
describe('PrismaUserRepository', () => {
it('should enforce unique email constraint', async () => {
const { repository, cleanup } = await createTestContext();
try {
await repository.create({ email: 'test@example.com', name: 'User 1' });
await expect(repository.create({ email: 'test@example.com', name: 'User 2' }))
.rejects.toThrow(/unique constraint/i);
} finally {
await cleanup();
}
});
});
```
This gives you: * **Fast parallel tests** for business logic (in-memory repositories) * **Confidence in SQL correctness** (repository implementation tests against real database) * **Clear separation** between “does my business logic work?” and “does my SQL work?” ## When to Choose This Approach **The repository pattern is worth adopting if you value:** * **Test parallelism** - Run hundreds of database tests concurrently * **Infrastructure flexibility** - Ability to change databases, ORMs, or architectures without rewriting business logic * **Fast feedback loops** - In-memory tests complete in milliseconds * **Clean architecture** - Better separation of concerns between domain logic and persistence * **Long-term maintainability** - Codebase that’s easier to understand, test, and evolve **The investment required:** * **Existing codebases:** Refactoring direct ORM calls to use repositories takes time. Start with new features and gradually migrate existing code. * **Two implementations:** You maintain production and test repositories. TypeScript ensures they stay in sync. * **Learning curve:** If your team is new to dependency injection, there’s initial learning investment. **Consider simpler alternatives if:** * You have a small test suite where sequential execution is fast enough * You need to test specific database behavior (query performance, constraints, transactions) * The refactoring cost outweighs the parallelism benefit for your current project timeline ## Beyond Testing: Why the Repository Pattern is Good Practice The repository pattern is widely adopted because it follows fundamental software design principles: ### Separation of Concerns Your business logic focuses on *what* to do, not *how* to persist data. The `UserService` doesn’t know or care whether data comes from PostgreSQL, MongoDB, or an API—it just calls `userRepository.findById()`. ### Dependency Inversion High-level business logic depends on abstractions (interfaces), not concrete implementations. This is the “D” in SOLID principles. Your domain code depends on `UserRepository` (interface), not `PrismaUserRepository` (implementation). ### Single Responsibility Each class has one job: * `PrismaUserRepository` → translates domain operations to Prisma queries * `UserService` → implements business rules * `UserController` → handles HTTP requests ### Open/Closed Principle Add new persistence strategies without modifying existing code. Need to cache frequently accessed data? Create a `CachedUserRepository` that wraps the real one. Need to log all database access? Create a `LoggingUserRepository` decorator. ### Type Safety TypeScript interfaces ensure your production and test implementations have matching signatures. If you add a method to the interface, both implementations must implement it. ## Further Reading * [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) - Martin Fowler’s original definition * [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) - Alistair Cockburn’s ports and adapters * [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) - Uncle Bob’s architectural principles * [Testing Without Mocks](https://www.jamesshore.com/v2/projects/testing-without-mocks/testing-without-mocks) - James Shore on “Nullable Infrastructure” * [Domain-Driven Design Reference](https://www.domainlanguage.com/ddd/reference/) - Eric Evans on repositories in DDD ## Next Steps * [Parallelism Options Overview](./parallelism-options) - Compare all approaches * [Testcontainers Hybrid](./testcontainers-hybrid) - Test real database behavior when needed * [Scenarist Getting Started](/getting-started/installation/) - Set up HTTP mocking
# Testcontainers + Scenarist Hybrid
> Test database-heavy Next.js apps without code changes using Testcontainers and Scenarist together
This guide shows how to test Next.js apps that use direct database access by combining [Testcontainers](https://testcontainers.com/) for real database testing with Scenarist for mocking external APIs. **No code changes required.** ## What is Testcontainers? [Testcontainers](https://testcontainers.com/) is a library that provides lightweight, throwaway Docker containers for testing. It spins up real database instances (PostgreSQL, MySQL, MongoDB) as Docker containers during your tests, pre-configured with your schema and seed data. Each test suite gets a fresh database instance that’s automatically destroyed after tests complete. This enables testing against actual database queries, migrations, transactions, and constraints without maintaining a shared test database or complex cleanup scripts. **Learn more:** [Testcontainers Documentation](https://testcontainers.com/getting-started/) ## Overview The hybrid approach uses two complementary tools: * **Testcontainers:** Spins up real Docker database containers with seeded scenarios * **Scenarist:** Mocks external API dependencies (Stripe, Auth0, SendGrid) Together, you get: * ✅ Real database queries and migrations tested * ✅ External APIs mocked for different scenarios * ✅ No code changes to your application * ✅ Realistic integration testing **Best for:** Teams that cannot/won’t refactor to add API routes ## Architecture
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// Server Component (unchanged - no API routes needed!)
export default async function CheckoutPage() {
// Database call - testcontainer provides real PostgreSQL with seeded data
const user = await db.user.findUnique({ where: { id: userId } });
// External API - Scenarist intercepts and mocks
const headersList = await headers();
const response = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: getScenaristHeadersFromReadonlyHeaders(headersList),
body: JSON.stringify({ amount: user.cartTotal }),
});
if (!response.ok) {
return ;
}
const payment = await response.json();
return ;
}
```
**What each tool handles:** * **Testcontainers:** User data, products, cart items (real PostgreSQL) * **Scenarist:** Stripe payment responses, Auth0 tokens, SendGrid emails (mocked) ## Installation Install Testcontainers for your database:
```bash
# For PostgreSQL
npm install -D @testcontainers/postgresql
# For MySQL
npm install -D @testcontainers/mysql
# For MongoDB
npm install -D @testcontainers/mongodb
```
Scenarist is already installed from [Getting Started](/getting-started/installation/). ## Step-by-Step Implementation ### Step 1: Create Database Seeding Functions Create functions to seed test data into database containers: tests/helpers/seed-database.ts
```typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
export async function seedPremiumUser(container: StartedPostgreSqlContainer) {
const connectionString = container.getConnectionUrl();
const prisma = new PrismaClient({
datasources: { db: { url: connectionString } },
});
await prisma.user.create({
data: {
id: 'user-premium-123',
email: 'premium@example.com',
tier: 'premium',
cartTotal: 500,
},
});
await prisma.$disconnect();
}
export async function seedStandardUser(container: StartedPostgreSqlContainer) {
const connectionString = container.getConnectionUrl();
const prisma = new PrismaClient({
datasources: { db: { url: connectionString } },
});
await prisma.user.create({
data: {
id: 'user-standard-456',
email: 'standard@example.com',
tier: 'standard',
cartTotal: 200,
},
});
await prisma.$disconnect();
}
```
### Step 2: Set Up Container in Tests Create a container setup function and use it in tests: tests/helpers/setup-container.ts
```typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { execSync } from 'child_process';
export async function createTestContainer(): Promise {
// Start PostgreSQL container
const container = await new PostgreSqlContainer('postgres:16').start();
// Set DATABASE_URL for Next.js app
process.env.DATABASE_URL = container.getConnectionUrl();
// Run migrations
execSync('npx prisma migrate deploy', { env: process.env });
return container;
}
// tests/checkout.spec.ts
import { test, expect } from './fixtures'; // withScenarios(scenarios)
import { createTestContainer } from './helpers/setup-container';
import { seedPremiumUser, seedStandardUser } from './helpers/seed-database';
test.describe('Checkout flow', () => {
const setup = test.beforeAll(async () => {
const container = await createTestContainer();
return { container };
});
test.afterAll(async () => {
const { container } = await setup;
await container.stop();
});
// Tests use container from setup
});
```
### Step 3: Write Tests with Database + API Mocking Combine database seeding with Scenarist scenario switching:
```typescript
test.describe('Checkout flow', () => {
const setup = test.beforeAll(async () => {
const container = await createTestContainer();
return { container };
});
test.afterAll(async () => {
const { container } = await setup;
await container.stop();
});
test('premium user with successful payment', async ({ page, switchScenario }) => {
const { container } = await setup;
// Testcontainer: Seed premium user in real database
await seedPremiumUser(container);
// Scenarist: Mock Stripe success
await switchScenario(page, 'stripeSuccess');
await page.goto('/checkout');
// Test sees:
// - Premium user from real database (tier='premium', cartTotal=500)
// - Successful payment from Scenarist mock
await expect(page.getByText('Payment successful')).toBeVisible();
await expect(page.getByText('£500.00')).toBeVisible();
});
test('standard user with declined payment', async ({ page, switchScenario }) => {
const { container } = await setup;
// Testcontainer: Seed standard user in real database
await seedStandardUser(container);
// Scenarist: Mock Stripe decline
await switchScenario(page, 'stripeDeclined');
await page.goto('/checkout');
// Test sees:
// - Standard user from real database (tier='standard', cartTotal=200)
// - Declined payment from Scenarist mock
await expect(page.getByText('Payment declined')).toBeVisible();
await expect(page.getByText('£200.00')).toBeVisible();
});
});
```
### Step 4: Define Scenarist Scenarios Mock external APIs (Stripe, Auth0, SendGrid): lib/scenarios.ts
```typescript
import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app';
export const stripeSuccessScenario: ScenaristScenario = {
id: 'stripeSuccess',
name: 'Stripe Payment Success',
description: 'Stripe charges succeed',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
id: 'ch_123',
status: 'succeeded',
amount: 50000, // $500.00
},
},
},
],
};
export const stripeDeclinedScenario: ScenaristScenario = {
id: 'stripeDeclined',
name: 'Stripe Payment Declined',
description: 'Stripe declines the card',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 402,
body: {
error: {
code: 'card_declined',
message: 'Your card was declined',
},
},
},
},
],
};
```
## Complete Example Here’s a full test showing database setup, seeding, and API mocking using functional patterns:
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
import { scenarios } from '../lib/scenarios';
// Test ID header is set automatically; scenaristEndpoint defaults to '/api/__scenario__'
const test = withScenarios(scenarios);
test.describe('Checkout flow with database', () => {
const setup = test.beforeAll(async () => {
// Start database container
const container = await new PostgreSqlContainer('postgres:16')
.withDatabase('testdb')
.withUsername('testuser')
.withPassword('testpass')
.start();
// Configure Next.js to use container database
process.env.DATABASE_URL = container.getConnectionUrl();
// Initialize Prisma client
const prisma = new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL } },
});
// Run migrations
execSync('npx prisma migrate deploy', { env: process.env });
return { container, prisma };
});
test.afterAll(async () => {
const { prisma, container } = await setup;
await prisma.$disconnect();
await container.stop();
});
test.beforeEach(async () => {
const { prisma } = await setup;
// Clean database before each test
await prisma.user.deleteMany();
await prisma.order.deleteMany();
});
test('premium user can checkout with valid payment', async ({ page, switchScenario }) => {
const { prisma } = await setup;
// Seed database: premium user
await prisma.user.create({
data: {
id: 'user-123',
email: 'premium@example.com',
tier: 'premium',
cart: {
create: {
items: [
{ productId: 'prod-1', quantity: 2, price: 150 },
{ productId: 'prod-2', quantity: 1, price: 200 },
],
},
},
},
});
// Mock external API: Stripe success
await switchScenario(page, 'stripeSuccess');
// Test flow
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
// Fill payment form
await page.getByLabel('Card Number').fill('4242424242424242');
await page.getByLabel('Expiry').fill('12/25');
await page.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Pay £500' }).click();
// Verify success
await expect(page.getByText('Payment successful')).toBeVisible();
await expect(page.getByText('Order #')).toBeVisible();
// Verify database updated
const order = await prisma.order.findFirst({
where: { userId: 'user-123' },
});
expect(order?.status).toBe('completed');
});
});
```
## When to Use This Approach **✅ Use when:** * You want to test actual database queries and migrations * You have external API dependencies to mock * You cannot/don’t want to add API routes * Container startup overhead is acceptable for your workflow * You need realistic integration testing **❌ Don’t use when:** * You don’t have Docker available (CI/CD constraint) * Test speed is critical (hundreds of tests) * Parallel test execution is important * Your app only uses external HTTP APIs (use Scenarist directly) ## Trade-offs ### Advantages **✅ No code changes required** * Server Components call database directly (as designed) * No API route layer needed * Production code unchanged **✅ Test real database behavior** * Actual SQL queries executed * Database constraints validated * Migrations tested * Transactions work correctly **✅ Realistic integration testing** * Database + external APIs together * Closest to production environment * Catches integration bugs ### Disadvantages **⚠️ Slower tests** * Container startup overhead * Database seeding per test * Best suited for focused test suites rather than hundreds of tests **⚠️ Docker required** * CI/CD must support Docker * Developers need Docker installed * More complex local setup **⚠️ Database seeding complexity** * Must maintain seed data scripts * Schema changes break seeds * Cleanup between tests required **⚠️ Sequential test execution required** * Database state is shared (unlike HTTP mocks which are stateless) * Scenarist isolates via test ID in HTTP headers, but databases have no equivalent mechanism * Parallel tests would corrupt each other’s data without application code changes * For parallelism, you’d need multiple containers (resource-intensive) or database-level isolation (schemas per test) ## Performance Optimization Tips **Reuse containers across tests:**
```typescript
test.beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16').start();
});
test.beforeEach(async () => {
// Only clean data, don't restart container
await prisma.user.deleteMany();
});
test.afterAll(async () => {
// Stop container once at end
await container.stop();
});
```
**Use database transactions for cleanup:**
```typescript
test.beforeEach(async () => {
await prisma.$transaction([
prisma.order.deleteMany(),
prisma.user.deleteMany(),
]);
});
```
**Cache container image:**
```bash
# Pull image once before running tests
docker pull postgres:16
```
## Next Steps * [Testcontainers Documentation](https://testcontainers.com/) - Learn more about Testcontainers * [Next.js Testing Overview](../) - Back to database testing overview * [Next.js App Router Getting Started](/frameworks/nextjs-app-router/getting-started/) - Set up Scenarist for App Router
# Endpoint APIs
> Complete reference for scenario switching, status, and debug state endpoints
Scenarist provides HTTP endpoints for scenario management and debugging: * `POST /__scenario__` — Switch the active scenario for a test * `GET /__scenario__` — Check which scenario is active * `GET /__scenarist__/state` — Retrieve test state for debugging These endpoints are framework-agnostic and work consistently across Express, Next.js, and all supported frameworks. ## POST /**scenario** - Switch Scenario Switch the active scenario for a test ID. ### Request **Method:** `POST` **URL:** `/__scenario__` (configurable via `endpoints.setScenario`) **Headers:**
```plaintext
Content-Type: application/json
x-scenarist-test-id:
```
**Body:**
```typescript
{
scenario: string; // Required: scenario ID to activate
}
```
### Responses **Success (200):**
```typescript
{
success: true;
testId: string; // The test ID used for routing
scenarioId: string; // The activated scenario ID
}
```
**Example:**
```json
{
"success": true,
"testId": "test-abc123",
"scenarioId": "payment-error"
}
```
**Validation Error (400):**
```typescript
{
error: string; // Error message
details?: unknown; // Validation error details (Zod errors)
}
```
**Example:**
```json
{
"error": "Invalid request body",
"details": [
{
"expected": "string",
"code": "invalid_type",
"path": ["scenario"],
"message": "Invalid input: expected string, received undefined"
}
]
}
```
**Scenario Not Found (400):**
```typescript
{
error: string; // "Scenario 'xyz' not found. Did you forget to register it?"
}
```
**Internal Server Error (500):**
```typescript
{
error: string; // "Internal server error"
}
```
### Example Usage **With [Playwright helpers](/testing/playwright-integration/):**
```typescript
import { test } from './fixtures'; // withScenarios(scenarios)
test('my test', async ({ page, switchScenario }) => {
// Helper handles POST request automatically
await switchScenario(page, 'payment-error');
// Sends POST to scenaristEndpoint (default '/api/__scenario__') with auto-generated test ID
});
```
**Manual (curl):**
```bash
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-H "x-scenarist-test-id: test-123" \
-d '{"scenario": "payment-error"}'
```
**Manual (fetch):**
```typescript
const response = await fetch('http://localhost:3000/__scenario__', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-scenarist-test-id': 'test-123',
},
body: JSON.stringify({ scenario: 'payment-error' }),
});
const result = await response.json();
// { success: true, testId: 'test-123', scenarioId: 'payment-error' }
```
**With supertest:**
```typescript
import request from 'supertest';
import app from './app';
const response = await request(app)
.post('/__scenario__')
.set('x-scenarist-test-id', 'test-123')
.send({ scenario: 'payment-error' });
expect(response.status).toBe(200);
expect(response.body.scenarioId).toBe('payment-error');
```
## GET /**scenario** - Get Active Scenario Retrieve the currently active scenario for a test ID. ### Request **Method:** `GET` **URL:** `/__scenario__` (configurable via `endpoints.getScenario`) **Headers:**
```plaintext
x-scenarist-test-id:
```
**No body required.** ### Responses **Success (200):**
```typescript
{
testId: string; // The test ID
scenarioId: string; // The active scenario ID
scenarioName?: string; // The scenario's human-readable name (if found)
}
```
**Example:**
```json
{
"testId": "test-abc123",
"scenarioId": "payment-error",
"scenarioName": "Payment Error Scenarios"
}
```
**No Active Scenario (404):**
```typescript
{
error: string; // "No active scenario for this test ID"
testId: string; // The test ID that was queried
}
```
**Example:**
```json
{
"error": "No active scenario for this test ID",
"testId": "test-xyz789"
}
```
### Example Usage **With fetch:**
```typescript
const response = await fetch('http://localhost:3000/__scenario__', {
headers: {
'x-scenarist-test-id': 'test-123',
},
});
const result = await response.json();
// { testId: 'test-123', scenarioId: 'payment-error', scenarioName: '...' }
```
**With curl:**
```bash
curl http://localhost:3000/__scenario__ \
-H "x-scenarist-test-id: test-123"
```
**With supertest:**
```typescript
const response = await request(app)
.get('/__scenario__')
.set('x-scenarist-test-id', 'test-123');
expect(response.status).toBe(200);
expect(response.body.scenarioId).toBe('payment-error');
```
## GET /**scenarist**/state - Debug State Retrieve the current test state for debugging. This endpoint exposes state set by `afterResponse.setState()` in your mocks, useful for debugging async workflows and verifying state transitions. ### Request **Method:** `GET` **URL:** `/__scenarist__/state` (configurable via `endpoints.getState`) **Headers:**
```plaintext
x-scenarist-test-id:
```
**No body required.** ### Response **Success (200):**
```typescript
{
testId: string; // The test ID
state: Record; // Current state object (empty {} if no state)
}
```
**Example:**
```json
{
"testId": "test-abc123",
"state": {
"cart.items": 2,
"cart.total": 59.98,
"user.tier": "premium"
}
}
```
### Example Usage **With [Playwright helpers](/testing/playwright-integration/#debugging-state) (recommended):**
```typescript
import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'checkout');
await page.goto('/cart');
await page.click('#add-item');
// Fetch current state
const state = await debugState(page);
console.log('Cart state:', state);
// → { 'cart.items': 1, 'cart.total': 29.99 }
expect(state['cart.items']).toBe(1);
});
```
**Manual (fetch):**
```typescript
const response = await fetch('http://localhost:3000/__scenarist__/state', {
headers: {
'x-scenarist-test-id': 'test-123',
},
});
const { state } = await response.json();
// { 'cart.items': 1, 'cart.total': 29.99 }
```
**With curl:**
```bash
curl http://localhost:3000/__scenarist__/state \
-H "x-scenarist-test-id: test-123"
```
### When to Use The debug state endpoint is useful for: * **Debugging test failures**: See what state was set by mocks during a test run * **Async workflows**: Wait for state to reach a condition before asserting (use `waitForDebugState`) * **State-aware mocking verification**: Confirm that `afterResponse.setState()` is capturing expected values ## Test ID Extraction All endpoints extract the test ID from the request header:
```typescript
// The header name is standardized to 'x-scenarist-test-id'
// Use SCENARIST_TEST_ID_HEADER constant from your adapter package
import { SCENARIST_TEST_ID_HEADER } from '@scenarist/express-adapter';
```
**Test ID behavior:** * **Header present:** Uses value from header (e.g., `'test-abc123'`) * **Header missing:** Uses default test ID (e.g., `'default-test'`) * **Empty string:** App Router treats it as missing and uses the default; Express and Pages Router use the empty string as-is **Example:**
```typescript
// Request with header
Headers: { 'x-scenarist-test-id': 'test-123' }
// Uses test ID: 'test-123'
// Request without header
// Uses test ID: 'default-test'
```
## Request Validation The `POST /__scenario__` endpoint validates the request body using Zod:
```typescript
import { z } from 'zod';
const ScenarioRequestSchema = z.object({
scenario: z.string().min(1), // Required, non-empty string
});
```
**Valid requests:**
```json
{ "scenario": "payment-error" }
```
**Invalid requests:**
```json
{} // Missing 'scenario'
{ "scenario": "" } // Empty string
{ "scenario": 123 } // Wrong type
```
## Framework-Specific Implementations While the API is consistent across frameworks, the implementation differs: ### Express
```typescript
// Express endpoint handler
export const handleSetScenario = (manager: ScenarioManager, config: ScenaristConfig) => {
return (req: Request, res: Response): void => {
const { scenario } = ScenarioRequestSchema.parse(req.body);
const context = new ExpressRequestContext(req, config);
const testId = context.getTestId();
const result = manager.switchScenario(testId, scenario);
if (!result.success) {
res.status(400).json({ error: result.error.message });
return;
}
res.status(200).json({
success: true,
testId,
scenarioId: scenario,
});
};
};
```
### Next.js App Router
```typescript
// Next.js App Router endpoint handler
export const POST = async (req: Request): Promise => {
const body = await req.json();
const context = new AppRequestContext(req, config);
const result = await handlePostLogic(body, context, manager);
if (!result.success) {
return Response.json(
{ error: result.error, details: result.details },
{ status: result.status }
);
}
return Response.json(
{
success: true,
testId: result.testId,
scenarioId: result.scenarioId,
},
{ status: 200 }
);
};
```
### Next.js Pages Router
```typescript
// Next.js Pages Router endpoint handler
export default async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const { scenario } = ScenarioRequestSchema.parse(req.body);
const context = new PagesRequestContext(req, config);
const testId = context.getTestId();
const result = manager.switchScenario(testId, scenario);
if (!result.success) {
return res.status(400).json({ error: result.error.message });
}
return res.status(200).json({
success: true,
testId,
scenarioId: scenario,
});
}
// GET handler...
};
```
## Configuration Endpoint paths are configurable:
```typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
// Customize endpoint paths
endpoints: {
setScenario: '/__scenario__', // POST endpoint (default)
getScenario: '/__scenario__', // GET endpoint (default)
// Or customize:
// setScenario: '/__test/switch',
// getScenario: '/__test/status',
},
});
```
**Why customize endpoint paths?** * Security: Make the endpoints harder to find if a production build ever includes them * Compatibility: Avoid conflicts with existing routes * Convention: Match your team’s naming standards **Note:** The test ID header is standardized to `'x-scenarist-test-id'` and is not configurable. Use the `SCENARIST_TEST_ID_HEADER` constant from your adapter package. ## Scenario Manager Coordination Both endpoints delegate to `ScenarioManager`:
```typescript
// POST /__scenario__ flow
const result = manager.switchScenario(testId, scenarioId);
// 1. Looks up scenario definition by ID
// 2. Returns error if not found
// 3. Stores active scenario for test ID
// 4. Resets sequences and state for test ID
// 5. Returns success
// GET /__scenario__ flow
const activeScenario = manager.getActiveScenario(testId);
// 1. Retrieves active scenario for test ID
// 2. Returns undefined if none set
const scenarioDefinition = manager.getScenarioById(activeScenario.scenarioId);
// 3. Looks up full definition for metadata
```
## State and Sequence Reset When switching scenarios, state and sequences are reset:
```typescript
manager.switchScenario(testId, scenarioId);
// Internally calls:
// - sequenceTracker.reset(testId) // All sequence positions cleared
// - stateManager.reset(testId) // All captured state cleared
```
**Why reset?** * Clean slate for new scenario * Prevents state bleeding between scenarios * Ensures idempotent test execution **Example:**
```typescript
// Test 1
await switchScenario(page, 'shopping-cart');
// Add items, state captured: { cartItems: ['item-1', 'item-2'] }
// Switch to error scenario
await switchScenario(page, 'payment-error');
// State reset: { } (empty)
// Sequences reset: all positions → 0
```
## Security Considerations **Production Safety:** * When `enabled: false`, `createScenarist()` returns `undefined`, so no endpoints are registered (Express responds 404) * When the `production` export condition is resolved, the adapter’s `createScenarist()` resolves to a stub that returns `undefined`. Next.js production builds resolve it automatically; Express needs a bundler option such as esbuild’s `--conditions=production` or `node --conditions=production`. See [Production Safety](/concepts/production-safety/) * Express also returns `undefined` when `NODE_ENV` is `production`, even with `enabled: true`, so unbundled apps started without the condition do not mount the endpoints * For defense in depth, also consider: * Custom endpoint paths (non-standard) * Network-level blocking (firewall) * Environment-based enablement (never `true` in production) **Test Isolation:** * Each test ID is isolated * No cross-test interference * Test IDs should be unique (use UUIDs) **Header Validation:** * Test ID read from the `x-scenarist-test-id` header * Missing header → uses default test ID * Empty header → uses default test ID (App Router); used as-is (Express, Pages Router) ## Error Handling All endpoints return structured errors:
```typescript
// Validation error
{
error: 'Invalid request body',
details: [/* Zod validation errors */]
}
// Scenario not found
{
error: "Scenario 'xyz' not found. Did you forget to register it?"
}
// No active scenario
{
error: 'No active scenario for this test ID',
testId: 'test-123'
}
// Internal error
{
error: 'Internal server error'
}
```
**Best practice:** Always check response status before parsing body. ## Complete Example Full workflow with both endpoints:
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from './scenarios';
export const testWithScenarios = withScenarios(scenarios);
testWithScenarios('payment flow', async ({ page, switchScenario }) => {
// 1. Switch to payment success scenario
const testId = await switchScenario(page, 'payment-success');
// POST /__scenario__ called with:
// { scenario: 'payment-success' }
// Response: { success: true, testId: 'test-abc123', scenarioId: 'payment-success' }
// 2. Verify active scenario (optional)
const response = await page.request.get('http://localhost:3000/__scenario__', {
headers: { 'x-scenarist-test-id': testId },
});
const activeScenario = await response.json();
// { testId: 'test-abc123', scenarioId: 'payment-success', scenarioName: '...' }
// 3. Test with success scenario
await page.goto('/checkout');
await page.click('button:has-text("Pay")');
await expect(page.getByText('Payment successful')).toBeVisible();
// 4. Switch to error scenario
await switchScenario(page, 'payment-error');
// POST /__scenario__ called with:
// { scenario: 'payment-error' }
// Sequences and state reset
// 5. Test with error scenario
await page.goto('/checkout');
await page.click('button:has-text("Pay")');
await expect(page.getByText('Payment failed')).toBeVisible();
});
```
## Next Steps * [Writing Scenarios →](/scenarios/overview/) - Learn the complete scenario structure * [Default Scenarios →](/scenarios/default-scenarios/) - Understand override behavior * [Ephemeral Endpoints →](/reference/ephemeral-endpoints/) - Understand test-only activation
# Ephemeral Endpoints
> Test-only activation, production safety, and test ID isolation in Scenarist
Scenarist creates **ephemeral endpoints** that only exist when Scenarist is enabled in development and test. When `enabled` is `false`, and in production, they are never registered, so they add zero overhead. This ensures scenario switching infrastructure never leaks into production. ## The `enabled` Flag The `enabled` flag controls whether Scenarist’s testing infrastructure is active. When it is `false`, `createScenarist()` returns `undefined` in every adapter.
```typescript
import { createScenarist } from "@scenarist/express-adapter";
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Only in test environment
scenarios,
});
```
Production has its own guards that apply regardless of `enabled`: the adapter’s `production` export condition makes `createScenarist()` return `undefined` without loading any test code, and the Express adapter also returns `undefined` when `NODE_ENV` is `production`. See [Production Safety](/concepts/production-safety/). Make sure enabled is true where your tests run `enabled` is evaluated in the process that serves requests. For Express, `process.env.NODE_ENV === "test"` works when your test runner sets it (Vitest and Jest do). In Next.js, use `enabled: true`: Next.js replaces `process.env.NODE_ENV` with `'development'` or `'production'` at build time, so a `"test"` check is never true, and production builds are excluded by the `production` export condition. ### When `enabled: true` (Development and Test) **Endpoints are active:** * `POST /__scenario__` accepts scenario switch requests * `GET /__scenario__` returns active scenario * Both endpoints process requests normally **Middleware extracts test IDs:** * Reads `x-scenarist-test-id` header from requests * Routes requests to correct scenario * Maintains test isolation **MSW is registered:** * Handlers created from scenario definitions * External API calls intercepted * Responses returned based on active scenario ### When `enabled: false`, and in Production `createScenarist()` returns `undefined`. **Endpoints are not registered.** Requests to them get whatever your app returns for an unknown route:
```typescript
// Express: POST /__scenario__ → 404 Not Found (no middleware installed)
// Next.js: undefined handlers (App Router) or your fallback handler (Pages Router) → 405 Method Not Allowed
```
**No middleware is installed** (`createScenarist()` returned `undefined`, so there is no `scenarist.middleware`): * Extracts no headers * Adds no overhead * Passes requests through unchanged **MSW is not registered:** * No handlers created * No interception occurs * External APIs called normally **Result: Zero production overhead.** The testing infrastructure simply doesn’t exist. ## Production Safety Guarantees Scenarist provides multiple layers of production safety: ### 1. Production Entry Point The `production` export condition swaps in a stub whose `createScenarist()` returns `undefined` and imports nothing. Make sure your production build resolves it; the [Production Safety guide](/concepts/production-safety/) shows how to verify this for each framework. The Express adapter also returns `undefined` when `NODE_ENV` is `production`, even with `enabled: true`. This protects unbundled Express apps started without `--conditions=production`; the adapter and `msw` are still loaded in that case, but nothing is mounted or intercepted. ### 2. Configuration Check
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Express: false in production; in Next.js use enabled: true
scenarios,
});
```
**Best practice:** Derive `enabled` from the environment so it is never `true` in production. ### 3. Endpoint Availability As an extra precaution, the endpoints can be moved to non-standard paths:
```typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
endpoints: {
setScenario: "/__internal_test_scenario_switch__", // Obscure path
getScenario: "/__internal_test_scenario_status__",
},
});
```
### 4. Middleware Safety The middleware only exists when `createScenarist()` returns an instance:
```typescript
// Express example
if (scenarist) {
app.use(scenarist.middleware);
}
// When enabled is false, or in production, scenarist is undefined
// - Middleware never installed
// - No header extraction
// - No performance impact
```
### 5. MSW Registration MSW handlers are only registered when you call `scenarist.start()`, so guard the call:
```typescript
// Next.js example
if (scenarist) {
scenarist.start(); // Registers MSW handlers
}
// When enabled is false, or in production: handlers never registered
```
## Test ID Isolation Each test gets a unique test ID that routes requests to the correct scenario: ### How Test IDs Work
```typescript
// Test 1
test("handles success", async ({ page, switchScenario }) => {
const testId = await switchScenario(page, "success");
// testId = 'test-abc123' (auto-generated UUID)
await page.goto("/api/payment");
// Request includes header: x-scenarist-test-id: test-abc123
// Scenarist routes to 'success' scenario for this test ID
});
// Test 2 (runs in parallel)
test("handles error", async ({ page, switchScenario }) => {
const testId = await switchScenario(page, "error");
// testId = 'test-def456' (different UUID)
await page.goto("/api/payment");
// Request includes header: x-scenarist-test-id: test-def456
// Scenarist routes to 'error' scenario for this test ID
});
```
**Tests run in parallel without interference** because each has its own test ID and scenario. ### Test ID Lifecycle 1. **Test starts**: No test ID or scenario set 2. **`switchScenario()` called**: Generates unique test ID (UUID) 3. **Scenario activated**: Test ID → Scenario mapping stored 4. **Request made**: Test ID header included automatically 5. **Scenario selected**: Based on test ID from header 6. **Response returned**: From active scenario for that test ID 7. **Test ends**: The test ID is never reused; its scenario mapping stays in memory until the server restarts ### Test ID Header The test ID header is standardized to `'x-scenarist-test-id'`. Use the `SCENARIST_TEST_ID_HEADER` constant from your adapter package:
```typescript
import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";
// SCENARIST_TEST_ID_HEADER === 'x-scenarist-test-id'
```
**All requests must include this header** for scenario routing to work. ### Automatic Test ID Propagation [Playwright helpers](/testing/playwright-integration/) automatically include the test ID header:
```typescript
// tests/fixtures.ts - Set up once
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "./scenarios";
export const test = withScenarios(scenarios);
export { expect };
// tests/my-test.spec.ts - Use in tests
import { test, expect } from "./fixtures"; // ✅ Import from fixtures
test("my test", async ({ page, switchScenario }) => {
await switchScenario(page, "success"); // ✅ Type-safe scenario IDs
// Helper automatically:
// 1. Generates unique test ID
// 2. Calls POST /__scenario__ with test ID header
// 3. Sets page.setExtraHTTPHeaders({ 'x-scenarist-test-id': testId })
await page.goto("/profile");
// All navigation requests include x-scenarist-test-id header
await page.request.post("/api/data", { data: { foo: "bar" } });
// API requests need explicit test ID (Playwright limitation):
const testId = await switchScenario(page, "success");
await page.request.post("/api/data", {
headers: { "x-scenarist-test-id": testId }, // Manual header for page.request
data: { foo: "bar" },
});
});
```
### Default Test ID When no test ID header is present, Scenarist uses a default test ID:
```typescript
// Request without x-scenarist-test-id header
GET / api / user;
// No x-scenarist-test-id header
// Scenarist uses default test ID: 'default-test'
// Routes to 'default' scenario
```
**Use case:** Manual testing during development. Open browser, navigate to app, no test ID needed. Change the fallback with `defaultTestId`. To treat a missing header as an error instead, set `defaultTestId: ''` and choose an [`onMissingTestId`](/reference/errors/#configurable-error-behaviors) behavior:
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true
scenarios,
defaultTestId: "", // No fallback
errorBehaviors: { onMissingTestId: "throw" }, // Respond 500 with MISSING_TEST_ID
});
```
## How Ephemeral Endpoints Enable Parallel Tests Traditional E2E testing forces **sequential execution** because of shared global state:
```typescript
// ❌ Traditional approach - shared MSW handlers
beforeAll(() => {
server.use(http.get("/api/user", () => HttpResponse.json({ role: "admin" })));
});
test("test 1: admin view", () => {
// Uses global handler: admin
});
test("test 2: guest view", () => {
// PROBLEM: Still sees admin handler!
// Must run sequentially or reset handlers between tests
});
```
Scenarist enables **parallel execution** via test ID isolation:
```typescript
// ✅ Scenarist approach - isolated scenarios
test("test 1: admin view", async ({ page, switchScenario }) => {
await switchScenario(page, "admin-user");
// Test ID: test-1 → Scenario: admin-user
});
test("test 2: guest view", async ({ page, switchScenario }) => {
await switchScenario(page, "guest-user");
// Test ID: test-2 → Scenario: guest-user
// Runs in parallel with test 1, no interference
});
```
**10x faster test suites** because tests run in parallel instead of sequentially. ## Runtime Scenario Switching Ephemeral endpoints enable **runtime scenario switching** without restarting your application: ### Traditional Approach (Slow)
```bash
# Test success case
NODE_ENV=test SCENARIO=success npm start
# Run test
# Kill server
# Test error case
NODE_ENV=test SCENARIO=error npm start
# Run test
# Kill server
# Test edge case
NODE_ENV=test SCENARIO=edge npm start
# Run test
# Kill server
```
**Problem:** Each scenario requires full server restart. Slow and painful. ### Scenarist Approach (Fast)
```typescript
// Start server once
// NODE_ENV=test npm start
// Switch scenarios at runtime
test("success case", async ({ page, switchScenario }) => {
await switchScenario(page, "success");
// Instant scenario switch, no restart
});
test("error case", async ({ page, switchScenario }) => {
await switchScenario(page, "error");
// Instant scenario switch, no restart
});
test("edge case", async ({ page, switchScenario }) => {
await switchScenario(page, "edge");
// Instant scenario switch, no restart
});
```
**Benefit:** Server runs continuously. Tests switch scenarios instantly via HTTP requests to `/__scenario__` endpoint. ## Manual Testing with Scenarios Ephemeral endpoints also enable **manual testing** with different scenarios:
```bash
# Start your app in test mode
NODE_ENV=test npm start
# Switch to error scenario via curl
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-H "x-scenarist-test-id: manual-testing" \
-d '{"scenario": "payment-error"}'
# Open browser and manually test error scenario
# All requests with x-scenarist-test-id: manual-testing see error scenario
# Switch to success scenario
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-H "x-scenarist-test-id: manual-testing" \
-d '{"scenario": "payment-success"}'
# Manually test success scenario
```
**Use cases:** * Demo different scenarios to stakeholders * Debug edge cases locally * Validate UI behavior across scenarios ## Architecture: How It Works ### Request Flow
```plaintext
1. Test calls switchScenario('payment-error')
↓
2. Helper generates test ID: 'test-abc123'
↓
3. Helper calls: POST /__scenario__
Headers: { x-scenarist-test-id: 'test-abc123' }
Body: { scenario: 'payment-error' }
↓
4. Endpoint stores: test-abc123 → payment-error
↓
5. Test makes request: GET /api/payment
Headers: { x-scenarist-test-id: 'test-abc123' }
↓
6. Middleware extracts test ID from header
↓
7. ScenarioManager looks up active scenario for test ID
↓
8. MSW handler finds matching mock in scenario
↓
9. Response returned from mock definition
```
### Scenario Storage Scenarios are stored in-memory (current implementation):
```typescript
// Simplified internal structure
const scenarioStore = new Map();
// After switchScenario('test-abc123', 'payment-error')
scenarioStore.set("test-abc123", {
scenarioId: "payment-error",
});
// On request with x-scenarist-test-id: test-abc123
const activeScenario = scenarioStore.get("test-abc123");
// { scenarioId: 'payment-error' }
```
**Why in-memory?** This is the right choice for scenario-based testing - scenarios only need to persist for the duration of a test run. The test ID isolation ensures parallel tests don’t interfere with each other. ### State and Sequence Isolation State capture and sequence positions are also isolated by test ID:
```typescript
// Internal structure (simplified)
const stateStore = new Map>();
const sequenceStore = new Map();
// Test ID: test-abc123
stateStore.set("test-abc123", { cartItems: ["item-1", "item-2"] });
sequenceStore.set("test-abc123:scenario:mockIndex", { position: 2 });
// Test ID: test-def456
stateStore.set("test-def456", { cartItems: ["item-3"] });
sequenceStore.set("test-def456:scenario:mockIndex", { position: 0 });
// Tests have independent state and sequence positions
```
## Configuration Reference Complete configuration options related to ephemeral endpoints:
```typescript
const scenarist = createScenarist({
// createScenarist() returns undefined when false
enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true
// Scenario definitions
scenarios: {
default: defaultScenario,
// ... other scenarios
},
// Endpoint paths (configurable)
endpoints: {
setScenario: "/__scenario__", // POST: switch scenario
getScenario: "/__scenario__", // GET: get active scenario
},
// Note: Test ID header is standardized to 'x-scenarist-test-id'
// Use SCENARIST_TEST_ID_HEADER constant from your adapter package
});
```
## Next Steps * [Writing Scenarios →](/scenarios/overview/) - Learn the complete scenario structure * [Default Scenarios →](/scenarios/default-scenarios/) - Understand override behavior * [Endpoint APIs →](/reference/api-endpoints/) - Reference for GET/POST /**scenario**
# Error Handling
> Error types, error codes, and configurable error behaviors in Scenarist
Scenarist provides comprehensive error handling with rich context, actionable hints, and configurable behaviors. All errors extend `ScenaristError` and include machine-readable codes for programmatic handling. ## Error Codes | Code | Description | When Thrown | | -------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `SCENARIO_NOT_FOUND` | Attempted to switch to a scenario that hasn’t been registered | `switchScenario()` with unknown ID (returned as `result.error`, not thrown) | | `DUPLICATE_SCENARIO` | Attempted to register a scenario with an ID that already exists | `registerScenario()` with duplicate ID | | `NO_MOCK_FOUND` | No mock matched the incoming request | Unmatched request with `errorBehaviors.onNoMockFound: 'throw'` | | `SEQUENCE_EXHAUSTED` | Sequence with `repeat: 'none'` has no more responses and no fallback mock | Request to exhausted sequence with `errorBehaviors.onSequenceExhausted: 'throw'` | | `MISSING_TEST_ID` | No test ID could be resolved for the request | Request without a test ID header, with `defaultTestId: ''` and `errorBehaviors.onMissingTestId: 'throw'` | | `VALIDATION_ERROR` | Scenario definition failed schema validation | `registerScenario()` with invalid definition | ## ScenaristError All Scenarist errors extend `ScenaristError`, which provides:
```typescript
class ScenaristError extends Error {
readonly code: string; // Machine-readable error code
readonly context: ErrorContext; // Rich context information
}
type ErrorContext = {
readonly testId?: string; // Test that triggered the error
readonly scenarioId?: string; // Scenario involved
readonly requestInfo?: { // Request details
method: string;
url: string;
};
readonly hint?: string; // Actionable guidance
};
```
### Example Error `switchScenario()` returns a result instead of throwing. The error is typed as `Error`; at runtime it is a `ScenaristError`:
```typescript
import { ScenaristError } from '@scenarist/core';
const result = scenarist.switchScenario('test-123', scenarioId);
if (!result.success && result.error instanceof ScenaristError) {
console.log(result.error.code); // 'SCENARIO_NOT_FOUND'
console.log(result.error.message); // "Scenario '...' not found..."
console.log(result.error.context);
// {
// testId: 'test-123',
// scenarioId: '...',
// hint: 'Make sure to register the scenario before switching...'
// }
}
```
The Express adapter type-checks scenario IDs against your scenarios object; the Next.js adapters accept any string. Over HTTP, `POST /__scenario__` with an unknown scenario responds `400` with the error message. ## Configurable Error Behaviors Control how Scenarist handles request-time error conditions using `errorBehaviors`:
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test',
scenarios,
logger: createConsoleLogger({ level: 'warn' }),
errorBehaviors: {
onNoMockFound: 'warn', // Log a warning, then strictMode decides
onSequenceExhausted: 'throw', // Respond 500 with SEQUENCE_EXHAUSTED
},
});
```
### Behavior Options ignore **Default.** Continue silently. `strictMode` decides what happens next. warn Log a warning through the configured `logger`, then let `strictMode` decide. Without a `logger`, this behaves like `ignore`. throw Respond with HTTP 500 and a JSON body containing the error `code` and `message`. The request your application made receives that response. A `throw` response looks like this:
```json
{
"error": "Internal mock server error",
"message": "No mock matched for GET https://api.example.com/users",
"code": "NO_MOCK_FOUND"
}
```
### Behavior Defaults All behaviors default to `ignore`, so an unmatched request behaves exactly as `strictMode` says:
```typescript
const DEFAULT_ERROR_BEHAVIORS = {
onNoMockFound: 'ignore',
onSequenceExhausted: 'ignore',
onMissingTestId: 'ignore',
};
```
In-process tests see your own requests too MSW intercepts every outgoing request in the process, including requests your test makes to your own app with supertest. With `strictMode: true` or `onNoMockFound: 'throw'`, those requests are also treated as unmatched. Use these options when your app runs in a separate process (for example, Playwright against a running server), or mock the app’s own URL. ### When onMissingTestId Applies Adapters fall back to `defaultTestId` (`'default-test'`) when a request has no `x-scenarist-test-id` header, so by default a test ID is always resolved and `onMissingTestId` never fires. To make a missing header an error, disable the fallback:
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test', // Express; in Next.js use enabled: true
scenarios,
defaultTestId: '',
errorBehaviors: { onMissingTestId: 'throw' },
});
```
### Multiple Scenarist Instances When several instances are started in one process (for example, App Router and Pages Router adapters in the same Next.js app), each request is offered to every instance. A `throw` from one instance is used only if no other instance has a mock for the request. ## Validation Errors Scenario definitions are validated at registration time using Zod schemas. Validation errors include the path to the invalid field:
```typescript
try {
manager.registerScenario({
id: 'test',
name: '', // Empty name - validation error
description: 'Test scenario',
mocks: [
{
method: 'GET',
url: '', // Empty URL - validation error
response: { status: 600 }, // Invalid status - validation error
},
],
});
} catch (error) {
console.log(error.code); // 'VALIDATION_ERROR'
console.log(error.message);
// 'Invalid scenario definition for 'test': name: Too small: expected string to have >=1 characters, mocks.0.url: Too small: expected string to have >=1 characters, mocks.0.response.status: Too big: expected number to be <=599'
}
```
### Validation Rules | Field | Rule | | --------------------------------- | -------------------------------- | | `id` | Non-empty string | | `name` | Non-empty string | | `url` | Non-empty string or valid RegExp | | `status` | Integer between 100-599 | | `sequence.responses` | At least one response | | `stateResponse.conditions[].when` | At least one key | | `afterResponse.setState` | At least one key | ## Error Handling Patterns ### Test Setup Validation Validate your test setup by catching registration errors:
```typescript
describe('API tests', () => {
const scenarios = {
default: createDefaultScenario(),
error: createErrorScenario(),
};
// Validate all scenarios at test suite startup
beforeAll(() => {
for (const [id, scenario] of Object.entries(scenarios)) {
try {
manager.registerScenario(scenario);
} catch (error) {
if (error instanceof ScenaristError) {
throw new Error(
`Invalid scenario '${id}': ${error.message}\nHint: ${error.context.hint}`
);
}
throw error;
}
}
});
});
```
### Graceful Degradation Use `warn` locally to surface issues without blocking, and `throw` in CI:
```typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: createConsoleLogger({ level: 'warn' }),
errorBehaviors: {
onNoMockFound: process.env.CI ? 'throw' : 'warn',
onSequenceExhausted: process.env.CI ? 'throw' : 'warn',
},
});
```
### Asserting on Request-Time Errors Request-time errors (`NO_MOCK_FOUND`, `SEQUENCE_EXHAUSTED`, `MISSING_TEST_ID`) are not thrown into your application code. With `throw`, the external request your app made receives a 500 response whose body carries the `code`, so your app’s own error handling runs:
```typescript
import { ErrorCodes } from '@scenarist/core';
// Errors thrown while handling a mocked request do not propagate to your code.
// The request receives a 500 JSON response: { error, message, code }
const response = await fetch('https://api.example.com/users');
if (response.status === 500) {
const body = await response.json();
switch (body.code) {
case ErrorCodes.NO_MOCK_FOUND:
console.log('No mock matched:', body.message);
break;
case ErrorCodes.SEQUENCE_EXHAUSTED:
console.log('Sequence exhausted for mock');
break;
default:
console.log('Scenarist error:', body.message);
}
}
```
Switching errors are returned, not thrown: check `result.success` (see [Example Error](#example-error)). Registration errors (`DUPLICATE_SCENARIO`, `VALIDATION_ERROR`) are thrown as `ScenaristError` when scenarios are registered, which adapters do inside `createScenarist()`. See [Validation Errors](#validation-errors). ## API Reference ### ErrorCodes
```typescript
export const ErrorCodes = {
SCENARIO_NOT_FOUND: 'SCENARIO_NOT_FOUND',
DUPLICATE_SCENARIO: 'DUPLICATE_SCENARIO',
NO_MOCK_FOUND: 'NO_MOCK_FOUND',
SEQUENCE_EXHAUSTED: 'SEQUENCE_EXHAUSTED',
MISSING_TEST_ID: 'MISSING_TEST_ID',
VALIDATION_ERROR: 'VALIDATION_ERROR',
} as const;
```
### ErrorBehaviors
```typescript
type ErrorBehavior = 'throw' | 'warn' | 'ignore';
type ErrorBehaviors = {
readonly onNoMockFound: ErrorBehavior;
readonly onSequenceExhausted: ErrorBehavior;
readonly onMissingTestId: ErrorBehavior;
};
```
### ScenaristError
```typescript
class ScenaristError extends Error {
constructor(
message: string,
options: {
code: string;
context: ErrorContext;
}
);
readonly code: string;
readonly context: ErrorContext;
}
```
## Next Steps * [Debugging with Logs](/reference/logging/) - Configure logging for error debugging * [Testing Best Practices](/testing/best-practices/) - Patterns for robust tests * [API Endpoints](/reference/api-endpoints/) - Understand the mock server API
# Debugging with Logs
> Configure and use Scenarist's logging infrastructure for debugging scenario matching and observability
Scenarist provides a flexible logging infrastructure for debugging scenario matching, state management, and request handling. Logging is **disabled by default** (silent) and can be enabled when needed. ## Quick Start
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
// Or: import { createScenarist, createConsoleLogger } from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({
enabled: true,
scenarios,
// Enable logging with pretty format at info level
logger: createConsoleLogger({ level: 'info' }),
});
```
## Logger Types Scenarist includes two logger implementations: NoOpLogger **Default.** Silent - no output. Zero overhead for production-like tests. ConsoleLogger Human-readable or JSON output. Great for debugging scenario matching issues. ## ConsoleLogger The `ConsoleLogger` provides human-readable or JSON output to the console. ### Basic Usage
```typescript
import { createConsoleLogger } from '@scenarist/express-adapter';
// Pretty format (default) - colored, human-readable
const logger = createConsoleLogger({ level: 'info' });
// JSON format - for log aggregation tools
const logger = createConsoleLogger({ level: 'info', format: 'json' });
// Debug level with category filtering
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
});
```
### Configuration
```typescript
type ConsoleLoggerConfig = {
/** Minimum log level to output */
level: 'error' | 'warn' | 'info' | 'debug' | 'trace';
/** Output format (default: 'pretty') */
format?: 'pretty' | 'json';
/** Filter to specific categories (default: all categories) */
categories?: LogCategory[];
};
```
### Log Levels Levels are hierarchical - each level includes all less verbose levels: | Level | Description | Use Case | | ------- | ----------------- | --------------------------------------------- | | `error` | Critical failures | Scenario not found, invalid config | | `warn` | Potential issues | No mock matched, sequence exhausted | | `info` | Key events | Scenario switched, mock selected | | `debug` | Decision logic | Match criteria evaluation, specificity scores | | `trace` | Verbose details | Request/response bodies, template replacement | ### Log Categories Filter logs to specific areas of concern: | Category | Description | Example Messages | | ----------- | -------------------- | ------------------------------------------------------------------------------------ | | `lifecycle` | Startup, shutdown | None emitted yet | | `scenario` | Scenario switching | `scenario_switched`, `scenario_not_found`, `scenario_registered`, `scenario_cleared` | | `matching` | Mock selection | `mock_selected`, `mock_no_match`, `mock_candidates_found`, `mock_match_evaluated` | | `sequence` | Sequence state | `sequence_exhausted` | | `state` | State updates | `state_set`, `state_response_resolved` | | `template` | Template replacement | None emitted yet | | `request` | Request lifecycle | None emitted yet |
```typescript
// Only show matching and scenario logs
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
});
```
### Output Formats * Pretty Format
```plaintext
12:34:56.789 INF [test-user-login] 🎬 scenario scenario_switched
12:34:56.801 DBG [test-user-login] 🎯 matching mock_match_evaluated mockIndex=0 matched=false hasCriteria=true
12:34:56.802 DBG [test-user-login] 🎯 matching mock_match_evaluated mockIndex=1 matched=true hasCriteria=true
12:34:56.803 INF [test-user-login] 🎯 matching mock_selected mockIndex=1 specificity=5
12:34:56.810 DBG [test-user-login] 💾 state state_set setState={"userId":"user-123"} source="mock-level"
12:34:56.815 WRN [test-user-login] 🎯 matching mock_no_match url="https://api.example.com/unknown" method="GET" candidateCount=0
```
**Features:** * Timestamps with milliseconds * Level indicators: `ERR`, `WRN`, `INF`, `DBG`, `TRC` * Category icons for visual scanning * Colored test IDs (persistent per test) * Key-value data fields * JSON Format
```json
{"timestamp":1732650896789,"level":"info","category":"scenario","message":"scenario_switched","testId":"test-user-login","scenarioId":"premium-user","data":{}}
{"timestamp":1732650896801,"level":"info","category":"matching","message":"mock_selected","testId":"test-user-login","scenarioId":"premium-user","data":{"mockIndex":2,"specificity":5}}
{"timestamp":1732650896802,"level":"debug","category":"matching","message":"mock_match_evaluated","testId":"test-user-login","scenarioId":"premium-user","data":{"mockIndex":0,"matched":false,"hasCriteria":true}}
```
**Use for:** Log aggregation (Datadog, Splunk), structured queries, production debugging ### Parallel Test Support When running tests in parallel, each test gets a unique test ID. The pretty format assigns **persistent colors** to each test ID, making it easy to trace logs from concurrent tests:
```plaintext
12:34:56.789 INF [test-checkout-flow] 🎯 matching mock_selected mockIndex=0 specificity=0
12:34:56.790 INF [test-payment-process] 🎯 matching mock_selected mockIndex=1 specificity=0
12:34:56.791 INF [test-user-login] 🎯 matching mock_selected mockIndex=0 specificity=2
12:34:56.792 DBG [test-checkout-flow] 💾 state state_set setState={"step":"paid"} source="mock-level"
```
Each test ID gets a unique color that remains consistent throughout the test run. ## NoOpLogger The default logger - does nothing. Use for clean test output:
```typescript
import { noOpLogger, createNoOpLogger } from '@scenarist/express-adapter';
// Singleton instance (recommended)
const logger = noOpLogger;
// Or create new instance (functionally identical)
const logger = createNoOpLogger();
```
**When to use:** * Production-like test runs where you want clean output * CI/CD pipelines where logs would be noise * Any situation where you don’t need Scenarist debugging ## Environment Variable Pattern For easy toggling without code changes:
```typescript
import {
createScenarist,
createConsoleLogger,
noOpLogger,
type LogLevel,
type LogFormat,
} from '@scenarist/express-adapter';
// Type-safe environment variable parsing
const LOG_LEVELS: ReadonlyArray> = [
'error', 'warn', 'info', 'debug', 'trace',
];
const LOG_FORMATS: ReadonlyArray = ['pretty', 'json'];
const parseLogLevel = (value: string | undefined): Exclude =>
LOG_LEVELS.includes(value as Exclude)
? (value as Exclude)
: 'info';
const parseLogFormat = (value: string | undefined): LogFormat =>
LOG_FORMATS.includes(value as LogFormat) ? (value as LogFormat) : 'pretty';
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test',
scenarios,
// Enable logging via SCENARIST_LOG=1
logger: process.env.SCENARIST_LOG
? createConsoleLogger({
level: parseLogLevel(process.env.SCENARIST_LOG_LEVEL),
format: parseLogFormat(process.env.SCENARIST_LOG_FORMAT),
})
: noOpLogger,
});
```
These env vars are a convention, not automatic `SCENARIST_LOG`, `SCENARIST_LOG_LEVEL`, and `SCENARIST_LOG_FORMAT` are **conventions for your code**, not something Scenarist reads automatically. Setting `SCENARIST_LOG=1` alone does nothing—you must explicitly pass a `logger` to `createScenarist()` as shown above. This pattern gives you full control over when and how logging is enabled. Then run tests with logging:
```bash
# Enable info-level logging
SCENARIST_LOG=1 pnpm test
# Enable debug-level logging
SCENARIST_LOG=1 SCENARIST_LOG_LEVEL=debug pnpm test
# JSON format for CI
SCENARIST_LOG=1 SCENARIST_LOG_FORMAT=json pnpm test
```
## Vitest Configuration Required for Vitest By default, Vitest captures console output and only displays it for **failed** tests. To see Scenarist logging output for all tests, you must configure Vitest to disable console interception. Add `disableConsoleIntercept: true` to your `vitest.config.ts`: vitest.config.ts
```typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// REQUIRED: Show Scenarist logging output for all tests
// Without this, console output is only shown for failed tests
disableConsoleIntercept: true,
// ... your other config
},
});
```
Then run tests with logging enabled:
```bash
SCENARIST_LOG=1 pnpm test
```
You’ll see output like:
```plaintext
09:00:12.269 DBG [unknown] 🎬 scenario scenario_registered scenarioId="default" mockCount=4
09:00:12.270 DBG [unknown] 🎬 scenario scenario_registered scenarioId="success" mockCount=3
09:00:12.308 INF [my-test-id] 🎬 scenario scenario_switched
```
## Debugging Common Issues ### “Why isn’t my mock being selected?” Enable debug logging for the `matching` category:
```typescript
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching'],
});
```
You’ll see: * `mock_candidates_found` - How many mocks could potentially match * `mock_match_evaluated` - Each mock’s evaluation result and specificity score * `mock_selected` - Which mock was chosen and why * `mock_no_match` - When no mock matched (with the URL that failed) ### “What scenario is active?” Enable info logging for the `scenario` category:
```typescript
const logger = createConsoleLogger({
level: 'info',
categories: ['scenario'],
});
```
You’ll see: * `scenario_switched` - When a test switches scenarios * `scenario_not_found` - When a requested scenario doesn’t exist ### “Is state being captured correctly?” Enable debug logging for the `state` category:
```typescript
const logger = createConsoleLogger({
level: 'debug',
categories: ['state'],
});
```
You’ll see: * `state_set` - When `afterResponse.setState` updates state * `state_response_resolved` - Which `stateResponse` condition (or default) was used ## Custom Loggers Implement the `Logger` interface to integrate with your logging library:
```typescript
import type { Logger, LogCategory, LogContext } from '@scenarist/core';
import pino from 'pino';
const pinoInstance = pino({ level: 'debug' });
const pinoLogger: Logger = {
error: (category, message, context, data) =>
pinoInstance.error({ category, ...context, ...data }, message),
warn: (category, message, context, data) =>
pinoInstance.warn({ category, ...context, ...data }, message),
info: (category, message, context, data) =>
pinoInstance.info({ category, ...context, ...data }, message),
debug: (category, message, context, data) =>
pinoInstance.debug({ category, ...context, ...data }, message),
trace: (category, message, context, data) =>
pinoInstance.trace({ category, ...context, ...data }, message),
isEnabled: (level) => pinoInstance.isLevelEnabled(level),
};
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: pinoLogger,
});
```
## API Reference ### `createConsoleLogger(config)` Create a console logger with the specified configuration.
```typescript
function createConsoleLogger(config: ConsoleLoggerConfig): Logger;
type ConsoleLoggerConfig = {
level: Exclude;
format?: 'pretty' | 'json';
categories?: ReadonlyArray;
};
```
### `noOpLogger` Singleton logger that does nothing.
```typescript
const noOpLogger: Logger;
```
### `createNoOpLogger()` Factory function to create a no-op logger.
```typescript
function createNoOpLogger(): Logger;
```
### `Logger` Interface The port interface all loggers implement:
```typescript
interface Logger {
error(category: LogCategory, message: string, context: LogContext, data?: Record): void;
warn(category: LogCategory, message: string, context: LogContext, data?: Record): void;
info(category: LogCategory, message: string, context: LogContext, data?: Record): void;
debug(category: LogCategory, message: string, context: LogContext, data?: Record): void;
trace(category: LogCategory, message: string, context: LogContext, data?: Record): void;
isEnabled(level: Exclude): boolean;
}
```
### Types
```typescript
type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
type LogCategory =
| 'lifecycle'
| 'scenario'
| 'matching'
| 'sequence'
| 'state'
| 'template'
| 'request';
type LogContext = {
readonly testId?: string;
readonly scenarioId?: string;
readonly requestUrl?: string;
readonly requestMethod?: string;
};
```
## Performance Considerations Scenarist’s logging is designed for minimal overhead: ### NoOpLogger: Zero Cost When logging is disabled (the default), `noOpLogger` is used. Its methods are empty functions that V8’s JIT compiler can inline and potentially eliminate entirely:
```typescript
// NoOpLogger implementation - effectively no-op
export const noOpLogger: Logger = {
error: () => {},
warn: () => {},
info: () => {},
debug: () => {},
trace: () => {},
isEnabled: () => false,
};
```
### Lazy Evaluation For high-frequency code paths, use `isEnabled()` to skip expensive string formatting when logging is disabled:
```typescript
// Check before formatting expensive context
if (logger.isEnabled('debug')) {
logger.debug('matching', 'Details', context, {
body: JSON.stringify(largeObject), // Only formatted if needed
headers: Object.fromEntries(request.headers),
});
}
```
This pattern ensures that expensive operations like: * JSON serialization * Object transformation * String concatenation …are only performed when the output will actually be used. ### V8 Inlining Simple function calls like `logger.debug(category, message, context, data)` are candidates for V8’s inlining optimization when hot. The straightforward signature and return type (`void`) help V8 make efficient inlining decisions. ## Next Steps * [Testing Best Practices](/testing/best-practices/) - Patterns for organizing tests * [Parallel Testing](/testing/parallel-testing/) - Run tests concurrently * [Architecture](/concepts/architecture/) - Understand the ports and adapters pattern
# Verification Guide
> How to verify Scenarist is working correctly in your project
When evaluating whether Scenarist is working correctly in your project, use this guide to verify core functionality, integration quality, and test coverage. ## Core Functionality Checks ### Header Propagation in Parallel Tests **Issue:** When tests fail in parallel but pass sequentially, the root cause is usually **test ID headers not being propagated** through server-side fetch calls. **How Test Isolation Works:** Each test gets a unique test ID (automatically generated). This test ID must be sent with EVERY request to ensure the test uses the correct scenario. If headers aren’t propagated through internal server-side fetches, those requests will use the default scenario instead of the test’s scenario. **Common Symptom:** * ✅ Tests pass when run individually (`--workers=1`) * ❌ Tests fail when run in parallel (`--workers=4`) * ❌ Flaky results that change between runs * ❌ Wrong data appearing in tests (from different scenario) **Root Cause: Missing Header Propagation** When your server-side code makes internal fetch calls, headers don’t automatically propagate. You must explicitly include them. #### Next.js: Header Propagation Helpers **Problem:**
```typescript
// ❌ BAD - Headers not propagated to internal fetch
export default async function Page() {
// This fetch doesn't include test ID header!
const response = await fetch('https://api.stripe.com/v1/products');
const data = await response.json();
return
{/* render */}
;
}
```
**Solution for Server Components** (use `getScenaristHeadersFromReadonlyHeaders`):
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Server Components
export default async function Page() {
const headersList = await headers(); // Get ReadonlyHeaders from Next.js
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList), // Include test ID header
},
cache: 'no-store',
});
const data = await response.json();
return
{/* render */}
;
}
```
**Solution for Route Handlers** (use `getScenaristHeaders`):
```typescript
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
// ✅ GOOD - Headers propagated correctly in Route Handlers
export async function GET(request: Request) {
const response = await fetch("https://api.stripe.com/v1/products", {
headers: {
...getScenaristHeaders(request), // Include test ID header
},
cache: "no-store",
});
const data = await response.json();
return Response.json(data);
}
```
**What these helpers do:** * Extract test ID from request/headers * Return `{ 'x-scenarist-test-id': 'generated-uuid' }` object * Safe to call in production builds, where no Scenarist instance exists (returns empty object) #### Express: Headers Already Tracked Express adapter uses AsyncLocalStorage to automatically track test IDs per request. No manual header propagation needed for middleware chains. **Internal fetch calls** still need headers:
```typescript
// ✅ GOOD - Include test ID in internal fetches
app.get("/api/dashboard", async (req, res) => {
const testId = req.get(SCENARIST_TEST_ID_HEADER);
const response = await fetch("http://localhost:3001/api/user", {
headers: {
[SCENARIST_TEST_ID_HEADER]: testId || "default-test",
},
});
const data = await response.json();
res.json(data);
});
```
#### Verification: Use Playwright Tests **Verify headers are propagating correctly:** tests/header-propagation.spec.ts
```typescript
test("headers propagate through server-side fetch", async ({
page,
switchScenario,
}) => {
await switchScenario(page, "premium-user");
await page.goto("/dashboard");
// If headers propagated correctly, should see premium content
await expect(page.getByText("Premium Features")).toBeVisible();
// If headers DIDN'T propagate, would see default content
// This would fail in parallel tests (wrong scenario)
});
```
**Debugging failed tests:** 1. Add logging to see which scenario is active 2. Check server logs for test ID headers 3. Verify header helpers are called before fetch 4. Confirm headers object includes test ID #### Red Flags **❌ Tests fail only in parallel:**
```bash
# Pass individually
pnpm exec playwright test --workers=1
# ✅ All tests pass
# Fail in parallel
pnpm exec playwright test --workers=4
# ❌ Some tests fail with wrong data
```
**Root cause:** Missing header propagation. Tests interfere because they’re all using the default scenario. **❌ Wrong data in tests:**
```typescript
// Test expects premium pricing
await expect(page.getByText("£99.99")).toBeVisible();
// ❌ Error: element not found
// But sees standard pricing instead
await expect(page.getByText("£149.99")).toBeVisible();
// ✅ This passes (wrong scenario!)
```
**Root cause:** Internal fetch didn’t include test ID header, used default scenario instead of premium scenario. **❌ Flaky test results:** * Sometimes premium pricing, sometimes standard * Different results on different runs * Race conditions between parallel tests **Root cause:** Tests sharing scenarios due to missing header propagation. #### Fix Checklist When parallel tests fail: 1. ✅ **Next.js:** Add header helpers before all internal fetch calls (use `getScenaristHeadersFromReadonlyHeaders` in Server Components, `getScenaristHeaders` in Route Handlers) 2. ✅ **Express:** Include test ID header in internal fetch calls 3. ✅ **Playwright:** Verify tests switch scenarios before navigation 4. ✅ **Logging:** Add debug logs to confirm headers are present 5. ✅ **Isolation:** Ensure each test calls `switchScenario()` independently **Quick fix for Next.js Server Components:**
```typescript
import { headers } from "next/headers";
import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app";
// Add this before EVERY external fetch in Server Components
const headersList = await headers();
fetch(url, {
headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList) },
cache: "no-store",
}); // Always include
```
**Quick fix for Next.js Route Handlers:**
```typescript
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
// In your route handler: export async function GET(request: Request)
fetch(url, {
headers: { ...getScenaristHeaders(request) },
cache: "no-store",
}); // Always include
```
### Runtime Scenario Switching **Verify:** Scenarios can be switched without server restarts
```typescript
test("scenario switching", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
await page.goto("/dashboard");
await expect(page.getByText("Premium Features")).toBeVisible();
await switchScenario(page, "free");
await page.goto("/dashboard");
await expect(page.getByText("Upgrade to Premium")).toBeVisible();
});
```
**Expected behavior:** * Scenario changes take effect immediately * No server restart required * Different responses from same endpoints * State is cleared when switching scenarios **Red flags:** * Need to restart server between scenario changes * Scenario switches not taking effect * Previous scenario behavior persisting ### Test ID Isolation **Verify:** Each test has isolated state via unique test ID
```typescript
test("test 1: add item to cart", async ({ page, switchScenario }) => {
await switchScenario(page, "cart");
await page.goto("/cart");
await page.click('[data-testid="add-product-1"]');
const items = await page.locator('[data-testid="cart-item"]').count();
expect(items).toBe(1);
});
test("test 2: empty cart", async ({ page, switchScenario }) => {
await switchScenario(page, "cart");
await page.goto("/cart");
const items = await page.locator('[data-testid="cart-item"]').count();
expect(items).toBe(0); // Should be empty, not affected by test 1
});
```
**Expected behavior:** * Each test starts with clean state * Test 2 doesn’t see items added in Test 1 * Tests can run in any order **Red flags:** * Tests depend on execution order * State leaking between tests * Need to manually clean up state ### Real Backend Execution **Verify:** Backend code executes with real middleware and routing
```typescript
test("middleware executes", async ({ page, switchScenario }) => {
await switchScenario(page, "logged-in");
// Should trigger auth middleware
const response = await page.goto("/protected");
// Middleware should have run and allowed access
expect(response?.status()).toBe(200);
await expect(page.getByText("Protected Content")).toBeVisible();
});
```
**Expected behavior:** * Middleware chains execute normally * Route handlers run with production logic * Business logic processes responses correctly * Only external API calls are mocked **Red flags:** * Middleware is being skipped or mocked * Business logic not executing as in production * Framework internals are mocked ## Integration Quality Checks ### External APIs Only **Verify:** Only external API calls are mocked, not framework internals **Check your test setup:**
```typescript
import type { ScenaristScenarios } from "@scenarist/express-adapter";
// ✅ GOOD - Only external APIs mocked
const scenarios = {
stripe: {
mocks: [
{
method: "POST",
url: "https://api.stripe.com/v1/charges", // External API
response: {
/* ... */
},
},
],
},
} as const satisfies ScenaristScenarios;
// ❌ BAD - Mocking framework internals
const badScenarios = {
nextjs: {
mocks: [
{
method: "GET",
url: "/api/my-endpoint", // Your own endpoint!
response: {
/* ... */
},
},
],
},
}; // Don't do this - mocking your own routes defeats the purpose
```
**What to mock:** * ✅ Stripe API calls * ✅ Auth0 API calls * ✅ SendGrid API calls * ✅ Any external HTTP service **What NOT to mock:** * ❌ Your own API routes * ❌ Framework request/response objects * ❌ Internal middleware ### Scenario Reusability **Verify:** Scenario definitions work across different test suites
```typescript
// scenarios.ts - Shared across all tests
export const premiumScenario = {
id: "premium",
mocks: [
/* ... */
],
};
// Used in multiple test files:
// tests/dashboard.spec.ts
test("dashboard shows premium features", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// ...
});
// tests/checkout.spec.ts
test("checkout with premium discount", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// ...
});
```
**Expected behavior:** * Same scenario definition works everywhere * No need to duplicate scenario logic * Changes to scenario affect all tests using it **Red flags:** * Duplicating scenario definitions across test files * Different scenarios for same behavior * Scenarios tightly coupled to specific tests ### Mock Accuracy **Verify:** Mock definitions accurately represent external API contracts
```typescript
// ✅ GOOD - Matches real Stripe response structure
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
id: 'ch_123',
object: 'charge',
amount: 5000,
currency: 'usd',
status: 'succeeded'
// Matches Stripe API documentation
}
}
}
// ❌ BAD - Doesn't match real API structure
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
success: true,
chargeId: '123'
// This is not how Stripe responds
}
}
}
```
**How to verify:** * Compare mock responses against API documentation * Use TypeScript types from API client libraries * Validate responses against API schemas if available * Test against real API in staging to verify mock accuracy ## Test Coverage Checks ### Edge Cases and Error States **Verify:** Can test edge cases without complex setup
```typescript
test("handles payment declined", async ({ page, switchScenario }) => {
await switchScenario(page, "payment-declined");
await page.goto("/checkout");
await page.click('[data-testid="submit-payment"]');
await expect(page.getByText("Payment declined")).toBeVisible();
});
test("handles auth timeout", async ({ page, switchScenario }) => {
await switchScenario(page, "auth-timeout");
const response = await page.goto("/dashboard");
expect(response?.status()).toBe(401);
});
```
**Expected behavior:** * Can test decline codes, timeouts, rate limits easily * No need to manipulate external APIs * Error states are deterministic and repeatable **Red flags:** * Can’t test specific error codes * Need complex setup to trigger errors * Error scenarios are flaky ### User Types and Tiers **Verify:** Multiple user types can be tested concurrently
```typescript
test.describe.parallel("User tiers", () => {
test("premium user experience", async ({ page, switchScenario }) => {
await switchScenario(page, "premium-user");
// ... test premium features
});
test("free user experience", async ({ page, switchScenario }) => {
await switchScenario(page, "free-user");
// ... test limited features
});
test("trial user experience", async ({ page, switchScenario }) => {
await switchScenario(page, "trial-user");
// ... test trial features
});
});
```
**Expected behavior:** * All tier tests run in parallel * Each test uses correct tier data * No interference between tiers ### API Retry Logic **Verify:** Retry logic and rate limiting can be tested
```typescript
{
mocks: [
{
method: "POST",
url: "https://api.example.com/action",
sequence: {
responses: [
{ status: 500, body: { error: "Server error" } },
{ status: 500, body: { error: "Server error" } },
{ status: 200, body: { success: true } },
],
repeat: "last",
},
},
];
}
test("retries on failure", async ({ page, switchScenario }) => {
await switchScenario(page, "retry-scenario");
// First two attempts fail, third succeeds
await page.click('[data-testid="submit"]');
// Should eventually show success after retries
await expect(page.getByText("Success")).toBeVisible({ timeout: 10000 });
});
```
**Expected behavior:** * Sequence advances through responses * Retry logic can be verified * Rate limiting scenarios testable ### Performance **Verify:** Tests remain fast for frequent execution **Benchmarks to aim for:** * Test setup (scenario switch): < 100ms * HTTP requests to your backend: Same speed as without Scenarist * Mock response time: < 10ms * Full test suite: Suitable for watch mode during development **Red flags:** * Tests are slower than unit tests * Scenario switching takes multiple seconds * Mock overhead is noticeable ## Production Tree-Shaking Verification When deploying Scenarist to production, it’s critical to verify that implementation code is NOT being delivered to production. The adapters use **conditional exports**: when your bundler resolves the `production` export condition, the adapter entry point resolves to a stub whose `createScenarist()` returns `undefined` and imports nothing. You should verify this is working correctly in your build. ### How Conditional Exports Work 1. The adapter’s `package.json` maps the `production` export condition to a `production.js` stub 2. Your bundler resolves that condition when building for production 3. The stub’s `createScenarist()` returns `undefined` and has no imports 4. **Result:** Neither the Scenarist implementation nor MSW is included in the build output ### Step 1: Build Your Application Build with the `production` condition enabled:
```bash
# esbuild (--conditions=production is required)
esbuild src/server.ts --bundle --platform=node --format=esm \
--outfile=dist/server.js \
--define:process.env.NODE_ENV='"production"' \
--conditions=production
# Next.js
NODE_ENV=production next build
```
For other bundlers, make sure `production` is included in the resolve conditions for production builds. ### Step 2: Verify Implementation is NOT in the Build Output
```bash
# Search for MSW implementation code in the build output
grep -rE '(setupWorker|HttpResponse\.json)' dist/
# Should output nothing (no matches) ✅
```
**Expected result:** No matches. If you see matches, the bundler resolved the default (non-production) entry point and bundled the implementation. ### Red Flags **❌ Implementation code in the build output:**
```bash
$ grep -rE '(setupWorker|HttpResponse\.json)' dist/
# Found matches ← BAD: Implementation bundled
```
**Fix:** Make sure the bundler resolves the `production` export condition: * esbuild: Add `--conditions=production` * Next.js: Build with `NODE_ENV=production` * Other bundlers: Add `production` to the resolve conditions ### Framework-Specific Verification #### Express
```bash
# Build (esbuild with --conditions=production)
pnpm build:production
# Verify implementation not in output
! grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/
# (no matches)
```
#### Next.js App Router and Pages Router
```bash
# Build
NODE_ENV=production next build
# Verify implementation not in output
! grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' .next/ --exclude-dir=cache
# (no matches)
```
### What This Proves ✅ **Zero delivery overhead** - Implementation code never reaches production runtime ✅ **Conditional exports work** - Bundler resolved the production stub ✅ **Production safety** - Test infrastructure code completely absent from production execution ### Next Steps If verification fails: 1. Check that your bundler resolves the `production` export condition 2. Verify `NODE_ENV=production` is set for the build 3. Review [Production Safety Guide](/concepts/production-safety/) for detailed configuration If verification succeeds: ✅ Your production deployment is safe - Scenarist implementation code is completely tree-shaken! ## Common Issues to Watch ### Issue: Tests Interfere With Each Other **Symptoms:** * Tests pass individually but fail in parallel * Flaky results * State leaking between tests **Check:** * Verify each test calls `switchScenario()` before actions * Ensure test IDs are unique (generated automatically by helper) * Check that state is isolated per test ID **Fix:**
```typescript
// ✅ GOOD - Each test switches scenario
test("test 1", async ({ page, switchScenario }) => {
await switchScenario(page, "scenario1"); // Isolates this test
// ...
});
// ❌ BAD - Shared scenario across tests
const scenarioId = "shared";
test("test 1", async ({ page, switchScenario }) => {
await switchScenario(page, scenarioId); // Don't share!
// ...
});
```
### Issue: Framework Internals Are Mocked **Symptoms:** * Middleware doesn’t execute * Business logic is bypassed * Tests don’t reflect production behavior **Check:** * Review mock URLs - should all be external APIs * Verify your route handlers execute normally * Check that middleware chains run **Fix:**
```typescript
// ❌ BAD - Mocking your own routes
{
url: 'http://localhost:3000/api/my-route', // Your app!
response: { /* ... */ }
}
// ✅ GOOD - Mocking external APIs only
{
url: 'https://api.stripe.com/v1/charges', // External!
response: { /* ... */ }
}
```
### Issue: Scenarios Can’t Switch at Runtime **Symptoms:** * Need to restart server to change scenarios * Scenario changes don’t take effect * Old scenario behavior persists **Check:** * Verify `/__scenario__` endpoint is registered * Check that `createScenarist()` returned an instance (it returns `undefined` in production builds) * Ensure test ID headers are being sent **Fix:** * Check adapter setup in your application * Verify Playwright helpers are configured correctly * Check for errors in scenario registration ### Issue: Tests Are Slow **Symptoms:** * Tests slower than expected * Long wait times for responses * Unsuitable for watch mode **Check:** * Look for unnecessary `delay` in mock responses * Check for browser interactions that could be HTTP-only * Verify parallel execution is enabled **Fix:**
```typescript
// Remove unnecessary delays
{
response: {
status: 200,
body: { /* ... */ },
// delay: 5000 ← Remove this
}
}
// Use parallel execution
test.describe.parallel('Fast tests', () => {
// Tests run concurrently
});
```
## Next Steps If verification reveals issues: 1. **Review framework guides** - Ensure adapter is set up correctly * [Next.js →](/frameworks/nextjs-app-router/getting-started/) * [Express →](/frameworks/express/getting-started/) 2. **Check scenario definitions** - Verify mocks match external APIs * [Writing Scenarios →](/scenarios/overview/) 3. **Examine test setup** - Ensure test isolation is working * [How it works: Test Isolation →](/concepts/how-it-works/#how-test-isolation-works-complete-request-flow) 4. **Consult architecture docs** - Understand how pieces fit together * [Architecture →](/concepts/architecture/)