This is the full 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 } ); }); ``` Next.js: set the state endpoint These fixtures read the debug state route, which the Playwright helpers look for at `/__scenarist__/state` (the Express path). Next.js apps serve it under `/api/`, so set `scenaristStateEndpoint: '/api/__scenarist__/state'` in the `use` block of `playwright.config.ts`. The [App Router guide](/frameworks/nextjs-app-router/getting-started/#3-create-scenario-control-endpoint) shows the route and config; the [Pages Router example app](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example) does the same with `pages/api/__scenarist__/state.ts`. **[→ 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. Ask for the framework you use Name your framework in the prompt, for example “Next.js App Router with Playwright”. Setup differs between adapters, and the index tells the assistant which guide covers each one. ## 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. Mock Type Priority This also applies when the default mock is a `sequence` or `stateResponse`: an active scenario’s simple `response` fallback still overrides it. Within one set of candidates, `sequence` and `stateResponse` fallbacks have higher priority (1) than simple `response` fallbacks (0). See [Mock Type Priority](/scenarios/request-matching/#mock-type-priority) for details. ## 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' ✗ ``` Native vs Serialized Regex * **Native RegExp** (`/pattern/`): Use in TypeScript code for better readability * **Serialized Regex** (`{ regex: { source, flags } }`): Use when scenarios must be JSON-serializable ## 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 State-Aware vs Stateful Mocks **[Stateful Mocks](/scenarios/stateful-mocks/)** capture data from requests and inject it into responses (data flow). **State-Aware Mocking** uses accumulated state to **change behavior** - selecting different responses or different mocks based on workflow state (control flow). ## 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 Stateful Mocks vs State-Aware Mocking **Stateful Mocks** (this page) capture data from requests and inject it into responses - state is a **data carrier**. **[State-Aware Mocking](/scenarios/state-aware-mocking/)** uses state to **change mock behavior** - selecting different responses or different mocks based on workflow state (control flow). ## 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 * apps/express-example/ * src/ * app.ts Express app with Scenarist * server.ts Entry point * scenarios.ts Scenario definitions * routes/ Express routes * … * tests/ * scenario-switching.test.ts * test-id-isolation.test.ts * dynamic-sequences.test.ts * dynamic-matching.test.ts * stateful-scenarios.test.ts * test-helpers.ts * package.json ## 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. See It Working **[View the complete Express example on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/express-example)** Clone it, run the tests, and explore working code for scenarios, Supertest patterns, and test isolation. ## 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 Recommended: Supertest for Express APIs We recommend using **Supertest** with **Vitest** for testing Express applications. Supertest is designed specifically for HTTP API testing and provides a clean, fluent interface for making requests and assertions. **Why Supertest?** * Direct HTTP requests to your Express app (no browser needed) * Fluent API for building requests and setting headers * Works perfectly with Express middleware and route handlers * Fast parallel test execution **Example app:** See [complete Express example](https://github.com/citypaul/scenarist/tree/main/apps/express-example) with comprehensive test suite using Supertest + Vitest. ## 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 * apps/nextjs-app-router-example/ * app/ * api/ * %5F%5Fscenario%5F%5F/route.ts Scenario endpoint * %5F%5Fscenarist%5F%5F/state/route.ts Debug state endpoint * products/ Server Components * … * polling/ Sequence example * … * cart-server/ Stateful mock example * … * lib/ * scenarist.ts Scenarist setup * scenarios.ts Scenario definitions * tests/ * playwright/ * products-server-components.spec.ts * polling-server-components.spec.ts * cart-server-components.spec.ts ## 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. See It Working **[View the complete Next.js App Router example on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example)** Clone it, run the tests, and explore working code for Server Components, sequences, and stateful mocks. ## Installation ```bash npm install @scenarist/nextjs-adapter msw npm install -D @playwright/test @scenarist/playwright-helpers ``` Testing Apps with Database Access? If your Next.js app uses **direct database access** (PostgreSQL, MongoDB, Prisma, etc.) instead of HTTP APIs, Scenarist cannot mock those database calls. Use **Testcontainers** for real database testing combined with Scenarist for external API mocking. **[→ Read the Database Testing Guide](/guides/testing-database-apps/)** to learn the recommended testing strategy for apps with database access. **[→ See what Scenarist can and cannot mock](/getting-started/why-scenarist/#what-cannot-be-intercepted)** ## 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(); } ``` Why enabled: true, not a NODE\_ENV check? Next.js replaces `process.env.NODE_ENV` in your server code with `'development'` under `next dev` and `'production'` under `next build`, even when you start it with `NODE_ENV=test`. A check such as `process.env.NODE_ENV === 'test'` is therefore never true in a Next.js app, so the example app uses `enabled: true`. When `enabled` is `false`, `createScenarist()` returns `undefined` ([details](/reference/ephemeral-endpoints/#the-enabled-flag)); what keeps Scenarist out of production builds regardless is the adapter’s `production` export condition: `next build` resolves it and `createScenarist()` returns `undefined`. See [Running your app for tests](#running-your-app-for-tests) and [Production Safety](/concepts/production-safety/). 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`. Why Scenarist Handles This For You The module duplication issue is a [well-known Next.js challenge](https://github.com/vercel/next.js/discussions/68572) that affects any library using singletons. Rather than forcing every application to implement the `globalThis` pattern correctly, Scenarist builds singleton protection directly into the adapter. You just use a simple `export const` and everything works. ## 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; ``` Why URL-encoded folder name? The folder is named `%5F%5Fscenario%5F%5F` (URL-encoded underscores) because Next.js treats folders starting with `_` as [private folders](https://nextjs.org/docs/app/getting-started/project-structure#private-folders) that are excluded from routing. To create a public route with underscores, you must URL-encode them. The route will be accessible at `/api/__scenario__` in your application, which is the default `scenaristEndpoint` used by `@scenarist/playwright-helpers`. If you place the route file elsewhere, set `scenaristEndpoint` in your Playwright config to match. 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(); }); ``` Recommended: Playwright Testing We recommend using **Playwright** for testing Next.js applications with Scenarist. The fixtures pattern shown above provides type-safe scenario switching with autocomplete. **Why Playwright?** * Test Server Components with real rendering * Type-safe scenario IDs with autocomplete * Parallel test execution with test ID isolation * No mocking of Next.js internals **[Learn more about Playwright testing →](/testing/playwright-integration/)** **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. Production Safety **These helper functions are production-safe:** * Return `{}` (empty object) when scenarist is undefined * Safe to spread in headers without guards * Zero runtime overhead (tree-shaken in production builds) You don’t need `if (scenarist)` checks - just spread the helper directly into your fetch headers. ## 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 Broader Context This testing gap applies to all modern server-side code. See [Why Scenarist?](/getting-started/why-scenarist/) for how Scenarist addresses middleware, session handling, and other server-side testing challenges, or explore the [Philosophy](/concepts/philosophy/) for the core beliefs that guide Scenarist’s design. ### 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 Speed + Confidence Test dozens of RSC data fetching scenarios in the time it takes to run a few traditional E2E tests. Scenario switching is instant - no server restarts needed. ## 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 ; } ``` Why Header Forwarding Matters Each Playwright test has a unique test ID (`x-scenarist-test-id`). This header tells MSW which scenario to use for each request. Without forwarding, your Server Component’s fetch calls won’t be associated with the correct test scenario. 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' }, ], }, }, }, ], }; ``` Request Matching The `match` criteria above route requests to different responses based on headers. Scenarist supports matching on headers, query params, body content, and regex patterns. See [Request Matching](/scenarios/request-matching/) for the complete reference. ### 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(); }); }); ``` State Isolation Each test gets its own isolated state via the test ID. Parallel tests won’t interfere with each other’s cart contents. *** ## 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 (
Welcome, {auth.user.name} {auth.user.email}
{children}
); } ``` ### Authentication Scenarios **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 authenticatedUserScenario: ScenaristScenario = { id: 'authenticatedUser', name: 'Authenticated User', description: 'User is authenticated with valid session', mocks: [ { method: 'GET', url: 'http://localhost:3001/auth/me', response: { status: 200, body: { id: 'user-123', email: 'test@example.com', name: 'Test User', }, }, }, ], }; export const unauthenticatedUserScenario: ScenaristScenario = { id: 'unauthenticatedUser', name: 'Unauthenticated User', description: 'User is not authenticated', mocks: [ { method: 'GET', url: 'http://localhost:3001/auth/me', response: { status: 401, body: { error: 'Unauthorized', message: 'Authentication required', }, }, }, ], }; ``` ### Test Implementation **Test:** [`tests/playwright/auth-flows.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/auth-flows.spec.ts) tests/playwright/auth-flows.spec.ts ```typescript import { test, expect } from './fixtures'; test.describe('Authentication Flow - Protected Routes', () => { test('should render protected content when authenticated', async ({ page, switchScenario, }) => { await switchScenario(page, 'authenticatedUser'); await page.goto('/protected'); // Verify protected content is visible await expect( page.getByRole('heading', { name: 'Protected Dashboard' }), ).toBeVisible(); // Verify user info is displayed await expect(page.getByText('test@example.com')).toBeVisible(); await expect(page.getByText('Welcome, Test User')).toBeVisible(); }); test('should redirect to login when not authenticated', async ({ page, switchScenario, }) => { await switchScenario(page, 'unauthenticatedUser'); await page.goto('/protected'); // Should be redirected to login page await expect(page).toHaveURL(/\/login\?from=\/protected/); // Verify login page content await expect( page.getByRole('heading', { name: 'Sign In' }), ).toBeVisible(); }); test('should switch between auth states at runtime', async ({ page, switchScenario, }) => { // Start as authenticated user await switchScenario(page, 'authenticatedUser'); await page.goto('/protected'); await expect( page.getByRole('heading', { name: 'Protected Dashboard' }), ).toBeVisible(); // Switch to unauthenticated (simulating session expiry) await switchScenario(page, 'unauthenticatedUser'); await page.reload(); // Now redirected to login await expect(page).toHaveURL(/\/login/); }); }); ``` Testing Auth Without Real Auth Scenarist lets you test authentication flows without setting up real auth providers. Switch scenarios to simulate different auth states - no tokens, cookies, or sessions required. *** ## Server Actions Server Actions are server-side functions that can be called directly from Client Components. They’re commonly used for form submissions, mutations, and any operation that requires server-side execution. Testing Server Actions with Scenarist requires the same header forwarding pattern used in Server Components. ### The Server Actions Testing Challenge Server Actions run on the server and make their own fetch requests. This creates a testing challenge: 1. **Isolated server execution** - Server Actions don’t inherit browser headers automatically 2. **Test ID routing** - Without the `x-scenarist-test-id` header, MSW can’t route to the correct scenario 3. **Concurrent test isolation** - Multiple tests running in parallel need separate mock responses **Solution:** Forward headers from the incoming request to outgoing fetch calls using `getScenaristHeadersFromReadonlyHeaders()`. ### Example: Contact Form with Server Action **Server Action:** [`app/actions/actions.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/actions/actions.ts) app/actions/actions.ts ```typescript "use server"; import { headers } from "next/headers"; import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app"; type FormState = { readonly success: boolean; readonly message: string; } | null; export async function submitContactForm( _prevState: FormState, formData: FormData ): Promise { const headersList = await headers(); const response = await fetch("http://localhost:3001/contact", { method: "POST", headers: { "Content-Type": "application/json", // Forward Scenarist headers for test isolation // Each test gets its own scenario based on x-scenarist-test-id ...getScenaristHeadersFromReadonlyHeaders(headersList), }, body: JSON.stringify({ name: formData.get("name"), email: formData.get("email"), message: formData.get("message"), }), cache: "no-store", }); const data: unknown = await response.json(); if (!response.ok) { const errorMessage = data !== null && typeof data === "object" && "error" in data && typeof data.error === "string" ? data.error : "Submission failed"; return { success: false, message: errorMessage }; } const successMessage = data !== null && typeof data === "object" && "message" in data && typeof data.message === "string" ? data.message : "Message sent!"; return { success: true, message: successMessage }; } ``` Header Forwarding is Critical The `getScenaristHeadersFromReadonlyHeaders(headersList)` call forwards the test ID from the browser request to the external API call. Without this, all concurrent tests would share the same mock responses, causing flaky tests. **Page Component:** [`app/actions/page.tsx`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/app/actions/page.tsx) app/actions/page.tsx ```typescript "use client"; import { useActionState } from "react"; import { submitContactForm } from "./actions"; export default function ActionsPage() { const [state, formAction, isPending] = useActionState(submitContactForm, null); return (

Server Actions Demo