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 (
);
}
```
Suspense Architecture
The page component is synchronous and renders immediately with the skeleton. The async `SlowProducts` component is wrapped in Suspense - React streams it when ready. This separation is key: fast shell, deferred content.
## Scenario Definition
lib/scenarios.ts
```typescript
import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app';
export const streamingScenario: ScenaristScenario = {
id: 'streaming',
name: 'Streaming Demo',
description: 'Demonstrates Suspense boundary with streaming RSC',
mocks: [
{
method: 'GET',
url: 'http://localhost:3001/products',
match: {
headers: { 'x-user-tier': 'standard' },
},
response: {
status: 200,
body: {
products: [
{ id: 1, name: 'Product A', price: 149.99, tier: 'standard' },
{ id: 2, name: 'Product B', price: 249.99, tier: 'standard' },
{ id: 3, name: 'Product C', price: 349.99, tier: 'standard' },
],
},
},
},
],
};
export const streamingPremiumUserScenario: ScenaristScenario = {
id: 'streamingPremiumUser',
name: 'Streaming Demo (Premium)',
description: 'Streaming with premium tier products',
mocks: [
{
method: 'GET',
url: 'http://localhost:3001/products',
match: {
headers: { 'x-user-tier': 'premium' },
},
response: {
status: 200,
body: {
products: [
{ id: 1, name: 'Product A', price: 99.99, tier: 'premium' },
{ id: 2, name: 'Product B', price: 199.99, tier: 'premium' },
{ id: 3, name: 'Product C', price: 299.99, tier: 'premium' },
],
},
},
},
],
};
```
## Test Implementation
**Test:** [`tests/playwright/streaming.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/streaming.spec.ts)
tests/playwright/streaming.spec.ts
```typescript
import { test, expect } from './fixtures';
test.describe('Streaming Page - Suspense Boundaries', () => {
test('should render products after Suspense boundary resolves', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'streaming');
await page.goto('/streaming');
// Wait for products to render (Suspense resolved)
await expect(page.getByRole('article')).toHaveCount(3);
// Verify product names are visible
await expect(page.getByRole('heading', { name: 'Product A' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Product B' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Product C' })).toBeVisible();
});
test('should render standard tier products with standard pricing', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'streaming');
await page.goto('/streaming?tier=standard');
// Standard price for Product A is £149.99
await expect(page.getByText('£149.99')).toBeVisible();
// Verify tier badge shows standard
const firstProduct = page.getByRole('article').first();
await expect(firstProduct.getByText('standard', { exact: false })).toBeVisible();
});
test('should render premium tier products with premium pricing', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'streamingPremiumUser');
await page.goto('/streaming?tier=premium');
// Premium price for Product A is £99.99 (lower than standard)
await expect(page.getByText('£99.99')).toBeVisible();
// Verify tier badge shows premium
const firstProduct = page.getByRole('article').first();
await expect(firstProduct.getByText('premium', { exact: false })).toBeVisible();
});
test('should show loading skeleton initially before products load', async ({
page,
switchScenario,
}) => {
await switchScenario(page, 'streaming');
await page.goto('/streaming', { waitUntil: 'domcontentloaded' });
const skeleton = page.getByLabel('Loading products');
// Handle race condition: skeleton may or may not be visible
// depending on how fast the response comes back
await Promise.race([
skeleton.waitFor({ state: 'visible', timeout: 1000 }).catch(() => {}),
page.getByRole('article').first().waitFor({ state: 'visible' }),
]);
// Eventually, products should appear
await expect(page.getByRole('article')).toHaveCount(3);
// Skeleton should no longer be visible
await expect(skeleton).not.toBeVisible();
});
});
```
Testing Fallback UI
Testing the loading skeleton requires handling a race condition - the fallback may only be visible briefly before the Suspense boundary resolves. The test above uses `Promise.race` to handle both cases: skeleton visible momentarily, or products appearing immediately. The key assertion is that products eventually render and the skeleton disappears.
## Key Points for Streaming Tests
| Aspect | Consideration |
| ----------------------- | ----------------------------------------------------------------------------------- |
| **Fallback visibility** | May be brief; use race conditions or `waitUntil: 'domcontentloaded'` |
| **Scenario isolation** | Different scenarios (standard/premium) verify correct data flows through |
| **Header forwarding** | Async component must forward test ID via `getScenaristHeadersFromReadonlyHeaders()` |
| **Aria labels** | Add `aria-label` to skeleton for reliable test selection |
***
## Next Steps
* **[User Interactions](/frameworks/nextjs-app-router/rsc/interactions/)** - Authentication, Server Actions, and error boundaries
* **[Troubleshooting](/frameworks/nextjs-app-router/rsc/troubleshooting/)** - Common pitfalls and debugging tips
* **[Data Fetching Patterns](/frameworks/nextjs-app-router/rsc/data-fetching/)** - Core patterns: fetching, stateful mocks, sequences
# Troubleshooting
> Common pitfalls and debugging tips for testing React Server Components with Scenarist
This page covers common issues you may encounter when testing React Server Components with Scenarist, along with debugging tips.
## Common Pitfalls
### Pitfall 1: Missing Header Forwarding
**Symptom:** Tests fail with “no mock matched” or default responses instead of scenario-specific ones.
**Cause:** Server Component fetches don’t include the test ID header.
**Fix:** Always forward headers:
```typescript
// ✅ Correct
const headersList = await headers();
const response = await fetch(url, {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList),
},
cache: 'no-store',
});
// ❌ Wrong - missing header forwarding
const response = await fetch(url);
```
### Pitfall 2: page.request Doesn’t Include Test ID
**Symptom:** API calls from `page.request.post()` don’t use the correct scenario.
**Cause:** `page.request` uses a separate HTTP context from the browser page.
**Fix:** Explicitly include the test ID header:
```typescript
test('adds item to cart', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'cartWithState'); // ✅ Capture testId
await page.request.post('http://localhost:3002/api/cart/add', {
headers: {
'Content-Type': 'application/json',
'x-scenarist-test-id': testId, // ✅ Explicitly include
},
data: { productId: 'prod-1' },
});
});
```
### Pitfall 3: Next.js Caching
**Symptom:** Same response returned despite scenario changes.
**Cause:** Next.js caches fetch responses by default.
**Fix:** Disable caching for testable fetches:
```typescript
const response = await fetch(url, {
headers: { ... },
cache: 'no-store', // ✅ Disable caching
});
```
### Pitfall 4: Sequence Not Advancing
**Symptom:** Same response returned on every request.
**Cause:** Requests going to a different mock or different test ID.
**Fix:** Ensure URL matches exactly and headers are forwarded:
```typescript
// Scenario mock
{ method: 'GET', url: 'http://localhost:3001/github/jobs/:id', sequence: {...} }
// Component fetch - must match URL pattern
await fetch(`http://localhost:3001/github/jobs/${jobId}`, { // ✅ Matches pattern
headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList) },
cache: 'no-store',
});
```
***
## Debugging Tips
Scenarist has built-in logging that shows exactly what’s happening with scenario switching, mock matching, and state management. This is far more useful than generic network debugging.
### 1. Enable Scenarist Logging
Add a console logger to your Scenarist setup:
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({
enabled: true,
scenarios,
// Enable logging at info level
logger: createConsoleLogger({ level: 'info' }),
});
```
This shows key events like scenario switches and mock selections:
```plaintext
12:34:56.789 INF 🎬 [test-abc-123] scenario | scenario_switched scenarioId=premiumUser
12:34:56.801 INF 🎯 [test-abc-123] matching | mock_selected mockIndex=2 specificity=5
```
### 2. Debug Mock Matching Issues
When a mock isn’t being selected, enable debug logging for the `matching` category:
```typescript
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching'],
});
```
You’ll see:
* `mock_candidates_found` - How many mocks could potentially match
* `mock_match_evaluated` - Each mock’s evaluation result
* `mock_selected` - Which mock was chosen and why
* `mock_no_match` - When no mock matched (with the URL that failed)
### 3. Environment Variable Pattern
For easy toggling without code changes:
```typescript
import {
createScenarist,
createConsoleLogger,
noOpLogger,
} from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({
enabled: true,
scenarios,
// Enable via SCENARIST_LOG=1
logger: process.env.SCENARIST_LOG
? createConsoleLogger({ level: 'debug' })
: noOpLogger,
});
```
Then run tests with logging:
```bash
SCENARIST_LOG=1 pnpm test
```
### 4. Check for URL Mismatches
Ensure your mock URLs exactly match what your components are fetching:
```typescript
// Mock definition
{
method: 'GET',
url: 'http://localhost:3001/products', // Must match exactly
// ...
}
// Component fetch - common mistakes:
await fetch('http://localhost:3001/products'); // ✅ Matches
await fetch('http://localhost:3001/products/'); // ❌ Trailing slash
await fetch('http://localhost:3001/Products'); // ❌ Case mismatch
await fetch('/products'); // ❌ Relative URL
```
### 5. Verify Header Forwarding in Server Components
Add temporary logging to verify headers are being forwarded:
```typescript
export default async function MyPage() {
const headersList = await headers();
const scenaristHeaders = getScenaristHeadersFromReadonlyHeaders(headersList);
// Temporary debug logging
console.log('Scenarist headers:', scenaristHeaders);
const response = await fetch('http://localhost:3001/api/data', {
headers: {
...scenaristHeaders,
},
cache: 'no-store',
});
// ...
}
```
### 6. Debug State in Playwright Tests
The `@scenarist/playwright-helpers` package provides fixtures to inspect test state directly from your Playwright tests. They read from the state endpoint configured by `scenaristStateEndpoint` (default `/__scenarist__/state`). In the App Router, create `app/api/%5F%5Fscenarist%5F%5F/state/route.ts` exporting `GET = scenarist?.createStateEndpoint()` and set `scenaristStateEndpoint: '/api/__scenarist__/state'` in your Playwright config, as the example app does:
```typescript
// Import from your configured fixtures file
import { test, expect } from './fixtures';
test('checkout flow updates cart state', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'cartWithState');
// Check initial state
const initialState = await debugState(page);
console.log('Initial state:', initialState);
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
// Verify state after action
const updatedState = await debugState(page);
console.log('Updated state:', updatedState);
expect(updatedState.checkoutStarted).toBe(true);
});
```
For async flows where state changes after a delay, use `waitForDebugState`:
```typescript
test('polling updates job status', async ({ page, switchScenario, waitForDebugState }) => {
await switchScenario(page, 'jobPolling');
await page.goto('/jobs/123');
// Wait for state to indicate job completion
const state = await waitForDebugState(
page,
(s) => s.jobStatus === 'completed',
{ timeout: 10000 } // Wait up to 10 seconds
);
expect(state.jobStatus).toBe('completed');
});
```
More Debugging Resources
* **[Debugging with Logs](/reference/logging/)** - Complete details on log levels, categories, and custom loggers
* **[Playwright Integration](/testing/playwright-integration/)** - Full API for `debugState`, `waitForDebugState`, and other fixtures
***
## Quick Reference: Common Issues
| Issue | Likely Cause | Solution |
| ----------------------- | ------------------------------ | ---------------------------------------------- |
| “No mock matched” | Missing header forwarding | Add `getScenaristHeadersFromReadonlyHeaders()` |
| Wrong scenario response | `page.request` missing test ID | Explicitly pass `x-scenarist-test-id` header |
| Stale responses | Next.js caching | Add `cache: 'no-store'` to fetch |
| Sequence stuck | URL mismatch or wrong test ID | Verify URL pattern and header forwarding |
| Intermittent failures | Parallel test isolation | Ensure each test uses unique test ID |
***
## Next Steps
* **[Testing RSC Overview](/frameworks/nextjs-app-router/rsc/)** - Return to the main RSC testing guide
* **[Data Fetching Patterns](/frameworks/nextjs-app-router/rsc/data-fetching/)** - Core patterns for RSC testing
* **[Debugging with Logs](/reference/logging/)** - Detailed logging configuration
# Next.js Pages Router
> Using Scenarist with Next.js Pages Router (API Routes, getServerSideProps, getStaticProps)
## Testing Next.js Pages Router with Scenarist
The Pages Router is Next.js’s traditional routing system using the pages directory. Scenarist enables testing API routes and server-side rendering without complex mocking, allowing you to test your application code with real HTTP requests.
### The Challenge
Testing Pages Router applications traditionally requires choosing between:
* **Unit testing API routes** in isolation (misses integration with middleware and framework features)
* **Mocking framework features** (getServerSideProps, request/response objects) which creates distance from production
* **Testing each API route separately** with mocked dependencies (creates test maintenance burden)
**Specific Pages Router challenges:**
* API routes need testing with different external API scenarios
* getServerSideProps executes server-side and requires mocking fetch or external APIs
* getStaticProps needs testing with various data states
* Framework internals must be mocked for unit tests
### How Scenarist Helps
Scenarist enables HTTP-level testing for Pages Router applications:
* **Test API routes** with real HTTP requests and different external API scenarios
* **Test getServerSideProps** without mocking Next.js internals
* **Test getStaticProps** against your default scenario (it has no request to carry a test ID)
* **Run parallel tests** without interference
* **No restarts** - each test switches scenario at runtime against one running server
* **Automatic singleton protection** - Handles Next.js module duplication for you (no `globalThis` boilerplate needed)
## Pages Router Features Supported
### API Routes
Test API routes with different scenarios:
pages/api/checkout.ts
```typescript
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/pages";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const response = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: getScenaristHeaders(req), // Forward the test ID
body: JSON.stringify(req.body),
});
const data = await response.json();
res.status(200).json(data);
}
// Test with different payment scenarios
test("processes successful payment", async ({ page, switchScenario }) => {
const testId = await switchScenario(page, "paymentSuccess");
const response = await page.request.post("/api/checkout", {
headers: { "x-scenarist-test-id": testId }, // page.request needs the test ID explicitly
data: { amount: 5000, token: "tok_test" },
});
expect(response.ok()).toBe(true);
});
```
### getServerSideProps
Test server-side rendering with different external API responses:
pages/products.tsx
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export async function getServerSideProps(context) {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: getScenaristHeaders(context.req), // Forward the test ID
});
const { data: products } = await response.json();
return { props: { products } };
}
export default function ProductsPage({ products }) {
return ;
}
// Test without mocking Next.js internals
test('renders products from external API', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await expect(page.getByText('Premium Product')).toBeVisible();
});
```
### getStaticProps
`getStaticProps` receives no request object, so it cannot forward the test ID header. Its external calls resolve against the default test ID (`default-test`) and use your `default` scenario rather than a per-test scenario. In production builds it runs at build time:
pages/index.tsx
```typescript
export async function getStaticProps() {
const response = await fetch('https://api.products.example.com/featured');
const { data: featured } = await response.json();
return {
props: { featured },
revalidate: 60 // ISR
};
}
export default function HomePage({ featured }) {
return ;
}
// Test against the default scenario's featured products
test('renders featured products', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Featured')).toBeVisible();
});
```
## Getting Started
Ready to integrate Scenarist into your Next.js Pages Router application?
[**Get started with Pages Router →**](/frameworks/nextjs-pages-router/getting-started/)
## Key Benefits
**API Routes Execute Normally** - Your validation, error handling, and business logic all run as they would in production.
**Server-Side Rendering Works** - Your getServerSideProps and getStaticProps fetch data from mocked external APIs.
**Test Isolation** - Each test gets isolated scenario state. Run tests in parallel with zero interference.
**No App Restart** - Switch scenarios instantly during test execution.
**Real HTTP Requests** - Tests make actual HTTP requests to your Next.js app, exercising middleware and routing.
## Next Steps
* [Pages Router Getting Started →](/frameworks/nextjs-pages-router/getting-started/) - Set up Scenarist in your Pages Router app
* [Next.js General Guide →](/frameworks/nextjs/) - Learn about Next.js testing with Scenarist
# Next.js Pages Router Example App
> Working example demonstrating Scenarist with Next.js Pages Router
## Overview
The Next.js Pages Router example demonstrates HTTP-level testing for API routes, getServerSideProps, and client components using Scenarist.
**GitHub:** [apps/nextjs-pages-router-example](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example)
## What It Demonstrates
This example app showcases all major Scenarist features with Pages Router:
### Core Features
* **API Routes** - Test Next.js API routes with different external API responses
* **getServerSideProps** - Test server-side rendering with mocked external APIs
* **Client Components** - Test client-side hydration with backend scenarios
* **Runtime Scenario Switching** - Multiple scenarios running concurrently
### Dynamic Response Features
* **Request Matching** - Different responses based on request content (tier-based pricing)
* **Sequences** - Polling scenarios (pending → processing → complete)
* **Stateful Mocks** - Shopping cart with state capture and injection
* **Feature Composition** - Checkout flow combining matching and stateful mocks
## Installation
### Prerequisites
* Node.js 22+
* pnpm 11+
### Clone and Install
```bash
# Clone the repository
git clone https://github.com/citypaul/scenarist.git
cd scenarist
# Install dependencies
pnpm install
# Navigate to Pages Router example
cd apps/nextjs-pages-router-example
```
## Running the Example
### Development Mode
```bash
# Start the Next.js dev server
pnpm dev
```
Visit to see the app.
### Run Tests
```bash
# Run all tests
pnpm test
# Run tests in UI mode
pnpm test:e2e:ui
# Run specific test file
pnpm test products-server-side
```
## Key Files
### Scenarist Setup
**`lib/scenarist.ts`** - Scenarist configuration
```typescript
import { createScenarist } from '@scenarist/nextjs-adapter/pages';
import { scenarios } from './scenarios';
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Auto-start MSW server for server-side interception (scenarist is undefined in production builds)
if (typeof window === 'undefined' && scenarist) {
scenarist.start();
}
```
**`pages/api/__scenario__.ts`** - Scenario control endpoint
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { scenarist } from '../../lib/scenarist';
// scenarist is undefined in production builds or when enabled is false - fall back to a 405 handler
export default scenarist?.createScenarioEndpoint() ??
((_req: NextApiRequest, res: NextApiResponse) => {
res.status(405).end();
});
```
This creates the `/api/__scenario__` endpoint used by tests to switch scenarios. `pages/api/__scenarist__/state.ts` exports `scenarist?.createStateEndpoint()` for the `/api/__scenarist__/state` debug endpoint.
### Scenario Definitions
**`lib/scenarios.ts`** - All scenario definitions ([view on GitHub](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-pages-router-example/lib/scenarios.ts))
Key scenarios:
**`default`** - Default baseline behavior
**`premiumUser`** - Premium tier with request matching
```typescript
premiumUser: {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier pricing (£99.99)',
mocks: [{
method: 'GET',
url: 'http://localhost:3001/products',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { products: buildProducts('premium') } }
}]
}
```
**`githubPolling`** - Polling sequence (pending → processing → complete)
```typescript
githubPolling: {
id: 'githubPolling',
name: 'GitHub Job Polling',
description: "Async job polling sequence (repeat: 'last')",
mocks: [{
method: 'GET',
url: 'http://localhost:3001/github/jobs/:id',
sequence: {
responses: [
{ status: 200, body: { jobId: '123', status: 'pending', progress: 0 } },
{ status: 200, body: { jobId: '123', status: 'processing', progress: 50 } },
{ status: 200, body: { jobId: '123', status: 'complete', progress: 100 } }
],
repeat: 'last'
}
}]
}
```
**`cartWithState`** - Stateful shopping cart
```typescript
cartWithState: {
id: 'cartWithState',
name: 'Shopping Cart with State',
description: 'Stateful shopping cart that captures and injects cart items',
mocks: [
// (also mocks GET /products so the add-to-cart buttons render)
{
method: 'GET',
url: 'http://localhost:3001/cart',
response: {
status: 200,
body: { items: '{{state.cartItems}}' }
}
},
{
method: 'PATCH',
url: 'http://localhost:3001/cart',
captureState: {
cartItems: 'body.items'
},
response: { status: 200, body: { items: '{{state.cartItems}}' } }
}
]
}
```
**`checkout`** - Feature composition (matching + state)
```typescript
checkout: {
id: 'checkout',
name: 'Checkout with Shipping',
description: 'Demonstrates matching + stateful composition',
mocks: [
// UK free shipping (US and FR variants follow the same shape)
{
method: 'POST',
url: 'http://localhost:3001/checkout/shipping',
match: { body: { country: 'UK' } },
captureState: { country: 'body.country', address: 'body.address' },
response: { status: 200, body: { country: 'UK', shippingCost: 0 } }
},
// Order with captured address
{
method: 'POST',
url: 'http://localhost:3001/checkout/order',
response: {
status: 200,
body: {
shippingAddress: {
country: '{{state.country}}',
address: '{{state.address}}'
}
}
}
}
]
}
```
### API Route Examples
**`pages/api/products.ts`** - Products API route with tier-based pricing
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const tier = req.headers['x-user-tier'] || 'standard';
// This fetch is mocked by Scenarist
const response = await fetch('http://localhost:3001/products', {
headers: {
...getScenaristHeaders(req), // Forward the test ID
'x-user-tier': tier,
},
});
const data = await response.json();
res.status(200).json(data);
}
```
**`pages/api/cart/add.ts`** - Add-to-cart API route (state captured)
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// GET current cart - state injected by Scenarist
const current = await fetch('http://localhost:3001/cart', {
headers: getScenaristHeaders(req),
}).then((r) => r.json());
// PATCH full items array - state captured by Scenarist
const response = await fetch('http://localhost:3001/cart', {
method: 'PATCH',
headers: { ...getScenaristHeaders(req), 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [...(current.items || []), req.body.productId], // raw productIds
}),
});
const data = await response.json();
res.status(200).json({ success: true, items: data.items });
}
```
`pages/api/cart.ts` serves `GET /api/cart` the same way, forwarding `getScenaristHeaders(req)` to `GET http://localhost:3001/cart`.
### Test Examples
**`tests/playwright/products-server-side.spec.ts`** - getServerSideProps with request matching
```typescript
test('should render premium products server-side', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/?tier=premium');
// getServerSideProps fetches http://localhost:3001/products with x-user-tier header
// Scenarist returns mock matching { headers: { 'x-user-tier': 'premium' } }
const firstProduct = page.getByRole('article').first();
await expect(firstProduct.getByText('£99.99')).toBeVisible();
});
```
**`tests/playwright/sequences.spec.ts`** - Polling with sequences
```typescript
test('polling updates status through sequence', async ({ page, switchScenario }) => {
await switchScenario(page, 'githubPolling');
await page.goto('/sequences');
const checkStatus = page.getByRole('button', { name: 'Check Job Status' });
const jobStatus = page.getByRole('status').first();
// First request: pending
await checkStatus.click();
await expect(jobStatus).toContainText('Status: pending');
// Second request: processing
await checkStatus.click();
await expect(jobStatus).toContainText('Status: processing');
// Third request: complete
await checkStatus.click();
await expect(jobStatus).toContainText('Status: complete');
});
```
**`tests/playwright/cart-server-side.spec.ts`** - Stateful mocks with API routes
```typescript
test('should render cart items server-side after adding products', async ({ page, switchScenario }) => {
await switchScenario(page, 'cartWithState');
// Add products via the UI - POST /api/cart/add, state captured
await page.goto('/');
const addButtons = page.getByRole('button', { name: /Add .* to cart/ });
const cartCount = page.getByLabel('Cart item count');
await addButtons.nth(0).click();
await expect(cartCount).toHaveText('1');
await addButtons.nth(1).click();
await expect(cartCount).toHaveText('2');
// Cart page is server-rendered - state injected
await page.goto('/cart');
const cartList = page.getByRole('list', { name: 'Shopping cart items' });
await expect(cartList.getByRole('listitem')).toHaveCount(2);
});
```
**`tests/playwright/checkout.spec.ts`** - Feature composition
```typescript
test('should provide free shipping for UK and capture address', async ({ page, switchScenario }) => {
await switchScenario(page, 'checkout');
await page.goto('/checkout');
// Calculate shipping - matches UK, captures address
await page.getByLabel('Country').selectOption('UK');
await page.getByLabel('Address').fill('123 Test Street');
await page.getByLabel('City').fill('London');
await page.getByLabel('Postcode').fill('SW1A 1AA');
await page.getByRole('button', { name: 'Calculate Shipping' }).click();
const shippingCost = page.getByRole('status').filter({ hasText: 'Shipping' });
await expect(shippingCost).toContainText('£0.00');
// Place order - injects captured address
await page.getByRole('button', { name: 'Place Order' }).click();
const confirmation = page.getByRole('region', { name: 'Order confirmation' });
await expect(confirmation).toContainText('123 Test Street');
await expect(confirmation).toContainText('UK');
});
```
## Architecture
### How It Works
1. **Setup** - Pages Router app includes Scenarist API endpoint
2. **Test starts** - Calls `switchScenario()` to set active scenario
3. **HTTP request** - Test makes request to Next.js page or API route
4. **Route execution** - API routes and getServerSideProps execute normally
5. **External API call** - Intercepted by MSW with scenario-defined response
6. **Test assertion** - Verifies API response or rendered output
### Test Isolation
Each test gets a unique test ID:
* Scenario switching: `POST /api/__scenario__` with `x-scenarist-test-id` header
* All requests include `x-scenarist-test-id` header automatically (Playwright helper)
* Server routes requests to correct scenario based on test ID
* Parallel tests don’t interfere with each other
### File Structure
* apps/nextjs-pages-router-example/
* pages/
* index.tsx Products page (getServerSideProps)
* cart.tsx Cart page
* sequences.tsx Sequences (polling) example
* checkout.tsx Checkout page
* api/
* **scenario** .ts Scenarist endpoint
* **scenarist** /state.ts Debug state endpoint
* products.ts Products API route
* cart.ts Cart API route
* cart/add.ts Add-to-cart API route
* checkout/ Checkout API routes
* …
* lib/
* scenarist.ts Scenarist setup
* scenarios.ts Scenario definitions
* tests/
* playwright/
* products-server-side.spec.ts
* sequences.spec.ts
* cart-server-side.spec.ts
* checkout.spec.ts
## Common Patterns
### Testing API Routes
```typescript
// API route with external API call
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await fetch('https://api.external.com/data', {
headers: getScenaristHeaders(req), // Forward the test ID
});
const data = await response.json();
res.json(data);
}
// Test with different scenarios
test('standard data', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'default');
const response = await page.request.get('/api/data', {
headers: { 'x-scenarist-test-id': testId }
});
expect(await response.json()).toMatchObject({ tier: 'standard' });
});
test('premium data', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'premiumUser');
const response = await page.request.get('/api/data', {
headers: { 'x-scenarist-test-id': testId }
});
expect(await response.json()).toMatchObject({ tier: 'premium' });
});
```
### Testing getServerSideProps
```typescript
// Page with getServerSideProps
export async function getServerSideProps({ req }) {
const response = await fetch('https://api.external.com/products', {
headers: getScenaristHeaders(req), // Forward the test ID
});
const { products } = await response.json();
return { props: { products } };
}
// Test with different data scenarios
test('renders products from getServerSideProps', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await expect(page.getByText('Premium Product')).toBeVisible();
});
```
### Testing Request Matching
Use request content to determine response:
```typescript
// Scenario with tier-based pricing
mocks: [{
method: 'GET',
url: 'http://localhost:3001/products',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { products: buildProducts('premium') } }
}, {
method: 'GET',
url: 'http://localhost:3001/products',
// No match criteria - fallback for standard tier
response: { status: 200, body: { products: buildProducts('standard') } }
}]
```
### Testing Polling Scenarios
Use sequences to simulate async operations:
```typescript
// Scenario with polling sequence
mocks: [{
method: 'GET',
url: 'http://localhost:3001/github/jobs/:id',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete' } }
],
repeat: 'last' // After sequence exhausts, repeat last response
}
}]
```
## Next Steps
* [Pages Router Getting Started →](/frameworks/nextjs-pages-router/getting-started/) - Integrate Scenarist into your Pages Router app
* [Request Matching →](/scenarios/request-matching/) - Learn about request content matching
* [Sequences →](/scenarios/response-sequences/) - Learn about response sequences
* [Stateful Mocks →](/scenarios/stateful-mocks/) - Learn about state capture and injection
# Next.js Pages Router - Getting Started
> Set up Scenarist with Next.js Pages Router in 5 minutes
Test your Next.js Pages Router application with API routes, getServerSideProps, and getStaticProps all executing. No mocking of Next.js internals required.
See It Working
**[View the complete Next.js Pages Router example on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example)**
Clone it, run the tests, and explore working code for API routes, getServerSideProps, and test isolation.
## Installation
```bash
npm install @scenarist/nextjs-adapter msw
npm install -D @playwright/test @scenarist/playwright-helpers
```
`msw` (v2) is a required peer dependency of `@scenarist/nextjs-adapter`, which supports Next.js 14, 15 and 16.
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/pages';
// ✅ RECOMMENDED - Default scenario with complete happy path
const defaultScenario: ScenaristScenario = {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed with valid responses',
mocks: [
// Stripe: Successful payment
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_123', status: 'succeeded', amount: 5000 },
},
},
// Auth0: Authenticated standard user
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_123', email: 'john@example.com', tier: 'standard' },
},
},
// SendGrid: Email sent successfully
{
method: 'POST',
url: 'https://api.sendgrid.com/v3/mail/send',
response: {
status: 202,
body: { message_id: 'msg_123' },
},
},
],
};
// Specialized scenario: Override ONLY Auth0 for premium user
const premiumUserScenario: ScenaristScenario = {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user, everything else succeeds',
mocks: [
// Override: Auth0 returns premium tier
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_456', email: 'premium@example.com', tier: 'premium' },
},
},
// Stripe and SendGrid automatically fall back to default (happy path)
],
};
export const scenarios = {
default: defaultScenario,
premiumUser: premiumUserScenario,
} as const satisfies ScenaristScenarios;
```
## 2. Set Up Scenarist
lib/scenarist.ts
```typescript
import { createScenarist } from '@scenarist/nextjs-adapter/pages';
import { scenarios } from './scenarios';
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Start MSW in the Next.js server process (scenarist is undefined in production builds)
if (typeof window === 'undefined' && scenarist) {
scenarist.start();
}
```
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 [Production Safety](/concepts/production-safety/).
Run your scenario tests against `next dev`, not `next build && next start`. [Running your app for tests](/frameworks/nextjs-app-router/getting-started/#running-your-app-for-tests) in the App Router guide shows the Playwright `webServer` config; it applies to both routers.
Create One Instance
Use the `export const scenarist` pattern shown above, and import `scenarist` wherever you need it. Call `createScenarist()` in one module only.
**Why this matters:** Scenarist handles a Next.js-specific issue for you.
It has a [well-documented singleton problem](https://github.com/vercel/next.js/discussions/68572) where webpack bundles the same module multiple times, breaking classic singleton patterns. This is compounded by [MSW's challenges with Next.js's process model](https://github.com/mswjs/msw/issues/1644)—Next.js keeps multiple Node.js processes that make global module patches difficult to maintain.
**Scenarist solves this automatically.** The Next.js adapter includes built-in `globalThis` singleton guards that ensure only one MSW instance exists, regardless of how Next.js loads your modules. You don't need to understand Next.js internals or implement manual workarounds—just use `export const scenarist = createScenarist(...)` and Scenarist handles the complexity.
Without protection, this causes:
* `[MSW] Multiple handlers with the same URL` warnings
* Intermittent 500 errors from MSW
* Different tests getting wrong scenarios
* Scenarios not switching properly
**How Scenarist solves this:** The first `createScenarist()` call stores its instance in `global.__scenarist_instance_pages`. Every later call returns that same instance, even when Next.js loads your module more than once, so only one instance and one MSW server ever exist. Later calls ignore the options you pass them, so a second `createScenarist()` call with different scenarios has no effect. The exception is `enabled: false`, which always returns `undefined`.
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
pages/api/\_\_scenario\_\_.ts
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { scenarist } from '@/lib/scenarist';
// scenarist is undefined in production builds or when enabled is false, and
// Next.js requires a default export function, so fall back to a handler that answers 405
export default scenarist?.createScenarioEndpoint() ??
((_req: NextApiRequest, res: NextApiResponse) => {
res.status(405).end();
});
```
Endpoint path
In the Pages Router the endpoint path comes from the file location: `pages/api/__scenario__.ts` serves `/api/__scenario__`, which is the Playwright helpers’ default `scenaristEndpoint`. If you place the file elsewhere, set `scenaristEndpoint` in your Playwright config `use` block to match.
## 4. Set Up Playwright Fixtures
tests/fixtures.ts
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };
```
## 5. Write Tests
### Testing Server-Side Rendering
tests/products.spec.ts
```typescript
import { test, expect } from './fixtures'; // ✅ Import from fixtures
test('premium users see premium pricing', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // ✅ Type-safe! Autocomplete works
await page.goto('/products');
// Your getServerSideProps runs, Auth0 returns premium tier
await expect(page.getByText('Premium Plan')).toBeVisible();
await expect(page.getByText('$50.00')).toBeVisible();
});
test('standard users see standard pricing', async ({ page, switchScenario }) => {
await switchScenario(page, 'default'); // Default scenario (happy path)
await page.goto('/products');
await expect(page.getByText('Standard Plan')).toBeVisible();
await expect(page.getByText('$25.00')).toBeVisible();
});
```
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 getServerSideProps with real execution
* 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 Page with getServerSideProps:**
pages/products.tsx
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export async function getServerSideProps({ req }) {
// This fetch is mocked by Scenarist (the forwarded test ID selects the scenario)
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(req),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
});
const { data: products } = await response.json();
return { props: { products } };
}
export default function ProductsPage({ products }) {
return (
{products.map(product => (
{product.name}
${(product.price / 100).toFixed(2)}
))}
);
}
```
### Testing API Routes
tests/checkout.spec.ts
```typescript
test('processes payment via API route', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'default');
// Your API route validation runs (page.request needs the test ID explicitly)
const response = await page.request.post('/api/checkout', {
headers: { 'x-scenarist-test-id': testId },
data: { amount: 5000, token: 'tok_test' },
});
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.status).toBe('succeeded');
});
```
**Example API Route:**
pages/api/checkout.ts
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Validation runs normally
if (!req.body.amount || !req.body.token) {
return res.status(400).json({ error: 'Missing required fields' });
}
// External API call is mocked by Scenarist
const response = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: {
...getScenaristHeaders(req),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
});
const data = await response.json();
res.status(200).json(data);
}
```
## Forwarding Headers to External APIs
**Why header forwarding matters:** When your API routes or getServerSideProps call external APIs (that you’re mocking with Scenarist), you must forward the test ID header so MSW knows which scenario to use.
### API Routes
Use the `getScenaristHeaders` helper to safely extract Scenarist headers:
pages/api/products.ts
```typescript
import type { NextApiRequest, NextApiResponse } from 'next';
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Forward Scenarist headers to external API
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(req),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
});
const data = await response.json();
res.status(200).json(data);
}
```
### getServerSideProps
The same helper works in getServerSideProps:
pages/products.tsx
```typescript
import type { GetServerSideProps } from 'next';
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/pages';
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(req),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
});
const { data: products } = await response.json();
return { props: { products } };
};
export default function ProductsPage({ products }) {
return (
{products.map(product => (
{product.name}
${(product.price / 100).toFixed(2)}
))}
);
}
```
The `getScenaristHeaders` helper extracts the test ID header (`x-scenarist-test-id`) for forwarding to external APIs.
Production Safety
**The helper function is production-safe:**
* Returns `{}` (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 Pages Router Setup Special
**API Routes Execute Normally** - Your validation, error handling, and business logic all run as they would in production.
**Server-Side Rendering Works** - Your getServerSideProps and getStaticProps fetch data from mocked external APIs.
**Test Isolation** - Each test gets isolated scenario state. Run tests in parallel with zero interference.
**No App Restart** - Switch scenarios instantly during test execution.
## Next Steps
* **[Example App on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example)** - Clone and run the complete working example
* **[Testing Database Apps →](/guides/testing-database-apps/)** - Learn how to test Next.js apps that use both databases and external APIs
* **[Architecture →](/concepts/architecture/)** - Learn how Scenarist works under the hood
# Testing Best Practices
> Practical patterns for organizing scenarios, structuring tests, and avoiding common pitfalls
This guide covers practical patterns that make Scenarist tests maintainable as your test suite grows. For the underlying philosophy, see [Testing Philosophy](/concepts/philosophy/).
## Scenario Organization
### Group by Business Domain, Not by API
Organize scenarios around **what users experience**, not which APIs you’re mocking:
```plaintext
lib/scenarios/
├── checkout/
│ ├── success.ts # Complete checkout flow succeeds
│ ├── payment-declined.ts # Card declined at payment step
│ └── inventory-error.ts # Item goes out of stock
├── user-tiers/
│ ├── free-user.ts # Limited features
│ ├── premium-user.ts # All features unlocked
│ └── enterprise-user.ts # Admin + team features
└── errors/
├── api-timeout.ts # External APIs are slow
├── rate-limited.ts # API returns 429
└── server-error.ts # Generic 500 responses
```
**Why this matters:**
* Scenarios describe **user journeys**, not technical details
* Easy to find the right scenario when writing a test
* New team members understand the test intent immediately
### The Default Scenario Pattern
Your `default` scenario should represent the **happy path baseline**—everything works:
lib/scenarios/default.ts
```typescript
import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/core';
const defaultScenario: ScenaristScenario = {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed with valid responses',
mocks: [
// Stripe: Payment succeeds
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_default', status: 'succeeded', amount: 5000 },
},
},
// Auth0: Standard authenticated user
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_default', email: 'user@example.com', tier: 'standard' },
},
},
// SendGrid: Email sent successfully
{
method: 'POST',
url: 'https://api.sendgrid.com/v3/mail/send',
response: { status: 202, body: { message_id: 'msg_default' } },
},
],
};
export const scenarios = {
default: defaultScenario,
// Other scenarios inherit from default automatically
} as const satisfies ScenaristScenarios;
```
**Key insight:** Specialized scenarios only need to define **what changes**. Unmocked endpoints automatically fall back to the default scenario.
### Minimal Override Scenarios
Don’t duplicate mocks. Override only what changes:
```typescript
// ✅ GOOD - Override only Auth0, others fall back to default
const premiumUserScenario: ScenaristScenario = {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user with all features',
mocks: [
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_premium', email: 'premium@example.com', tier: 'premium' },
},
},
// Stripe and SendGrid automatically use default scenario
],
};
// ❌ BAD - Duplicates all mocks even when unchanged
const premiumUserScenarioBad: ScenaristScenario = {
id: 'premiumUser',
mocks: [
{ method: 'GET', url: 'https://api.auth0.com/userinfo', response: { /* ... */ } },
{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { /* same as default */ } },
{ method: 'POST', url: 'https://api.sendgrid.com/v3/mail/send', response: { /* same as default */ } },
],
};
```
**Benefits of minimal overrides:**
* Less code to maintain
* Clear about what the scenario actually tests
* Default scenario changes propagate automatically
## Feature Selection Guide
### When to Use Request Matching
Use **match criteria** when response depends on request content:
```typescript
// Different prices based on user tier header
{
method: 'GET',
url: '/api/pricing',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { price: 79.99 } },
}
// Filter products by query parameter
{
method: 'GET',
url: '/api/products',
match: { query: { category: 'electronics' } },
response: { status: 200, body: { products: [/* electronics */] } },
}
```
Specificity-Based Selection
When multiple mocks match, the **most specific** wins. You don’t need to carefully order mocks—Scenarist picks the best match automatically.
### When to Use Sequences
Use **sequences** for APIs that return different responses on subsequent calls:
```typescript
// Polling API: pending → processing → complete
{
method: 'GET',
url: '/api/job/:id/status',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete', result: { /* ... */ } } },
],
repeat: 'last', // Stay at "complete" for all subsequent calls
},
}
```
**Common sequence patterns:**
| Pattern | `repeat` mode | Use case |
| ------------------ | ------------- | ----------------------------------- |
| Progress indicator | `'last'` | Show final state forever |
| Retry logic | `'cycle'` | Keep failing then succeeding |
| Rate limiting | `'none'` | First N requests succeed, then fail |
### When to Use Stateful Mocks
Use **state capture** when responses depend on previous requests. State is isolated per test ID, so parallel tests each maintain their own state without conflicts:
```typescript
// Capture item added to cart
{
method: 'POST',
url: '/api/cart/items',
captureState: { 'cartItems[]': 'body.item' },
response: { status: 200, body: { success: true } },
}
// Return captured items in cart
{
method: 'GET',
url: '/api/cart',
response: {
status: 200,
body: {
items: '{{state.cartItems}}',
itemCount: '{{state.cartItems.length}}',
},
},
}
```
State Resets on Scenario Switch
All captured state is cleared when switching scenarios. This ensures tests are isolated—each test starts fresh.
## Test Structure Patterns
### Factory Functions for Test Data
Use factory functions instead of `let` and `beforeEach`:
* ✅ Good - Factory Function
tests/fixtures.ts
```typescript
const createTestContext = () => ({
testId: `test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
baseURL: 'http://localhost:3000',
});
// tests/checkout.spec.ts
test('processes payment successfully', async ({ page, switchScenario }) => {
const ctx = createTestContext();
await switchScenario(page, 'default');
await page.goto(`${ctx.baseURL}/checkout`);
// ... test code
});
```
* ❌ Bad - Shared State
```typescript
// Shared mutable state leads to test pollution
let testId: string;
beforeEach(() => {
testId = `test-${Date.now()}`;
});
test('processes payment', async ({ page }) => {
// testId could be modified by parallel tests
});
```
### One Scenario Switch Per Test
Switch scenarios at the beginning of each test, not multiple times:
```typescript
// ✅ GOOD - Single scenario for entire test journey
test('premium checkout flow', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await page.click('[data-testid="add-to-cart"]');
await page.goto('/checkout');
await page.click('[data-testid="submit-payment"]');
await expect(page.getByText('Order Confirmed')).toBeVisible();
});
// ❌ AVOID - Multiple scenario switches mid-test
test('confusing flow', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await switchScenario(page, 'paymentError'); // Why switch here?
await page.goto('/checkout');
// What is this test actually verifying?
});
```
**Exception:** Testing scenario transitions explicitly (e.g., “user upgrades mid-session”) may warrant multiple switches, but make the intent clear.
### Descriptive Test Names
Test names should describe the **user experience**, not the scenario:
```typescript
// ✅ GOOD - Describes what user sees
test('premium users see 20% discount on checkout', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
// ...
});
test('declined card shows clear error message', async ({ page, switchScenario }) => {
await switchScenario(page, 'paymentDeclined');
// ...
});
// ❌ BAD - Describes implementation
test('premiumUser scenario works', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
// ...
});
```
## Anti-Patterns to Avoid
### Over-Mocking
Don’t mock every API in every scenario:
```typescript
// ❌ BAD - Mocking APIs not relevant to this test
const checkoutErrorScenario = {
id: 'checkoutError',
mocks: [
{ method: 'POST', url: '/api/charges', response: { status: 402 } }, // Relevant
{ method: 'GET', url: '/api/user', response: { /* ... */ } }, // Not needed
{ method: 'GET', url: '/api/products', response: { /* ... */ } }, // Not needed
{ method: 'POST', url: '/api/analytics', response: { /* ... */ } }, // Not needed
],
};
// ✅ GOOD - Only mock what the test cares about
const checkoutErrorScenario = {
id: 'checkoutError',
mocks: [
{ method: 'POST', url: '/api/charges', response: { status: 402 } },
// Other APIs use default scenario
],
};
```
### Testing Implementation Details
Don’t verify internal behavior—verify user outcomes:
```typescript
// ❌ BAD - Testing that Stripe was called
test('calls Stripe API', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/checkout');
await page.click('button[type="submit"]');
// How would you even verify this without coupling to implementation?
});
// ✅ GOOD - Testing what user sees
test('displays success message after payment', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/checkout');
await page.click('button[type="submit"]');
await expect(page.getByText('Payment successful')).toBeVisible();
});
```
### Scenarios with Hidden Logic
Avoid creating scenarios that require understanding hidden conditional logic:
```typescript
// ❌ BAD - What does this scenario do? Need to read source.
const scenario = {
id: 'complex',
mocks: [{
method: 'GET',
url: '/api/data',
match: {
headers: { 'x-flag-a': 'true' },
query: { mode: { contains: 'special' } },
body: { nested: { field: { startsWith: 'prefix' } } },
},
response: { status: 200, body: { /* ... */ } },
}],
};
// ✅ GOOD - Name explains what it tests
const scenario = {
id: 'specialModeWithFeatureFlagA',
name: 'Special Mode with Feature Flag A Enabled',
description: 'Tests the special mode behavior when feature flag A is active',
mocks: [{
method: 'GET',
url: '/api/data',
match: {
headers: { 'x-feature-flag': 'flagA' },
query: { mode: 'special' },
},
response: { status: 200, body: { /* ... */ } },
}],
};
```
## Scaling Your Test Suite
### Scenario Composition
For complex tests, compose smaller scenarios:
```typescript
// Base scenarios
const authMocks = {
authenticated: { method: 'GET', url: '/api/user', response: { status: 200, body: { /* ... */ } } },
unauthenticated: { method: 'GET', url: '/api/user', response: { status: 401 } },
};
const paymentMocks = {
success: { method: 'POST', url: '/api/charges', response: { status: 200, body: { /* ... */ } } },
declined: { method: 'POST', url: '/api/charges', response: { status: 402 } },
};
// Compose into full scenarios
const scenarios = {
default: {
id: 'default',
mocks: [authMocks.authenticated, paymentMocks.success],
},
paymentDeclined: {
id: 'paymentDeclined',
mocks: [authMocks.authenticated, paymentMocks.declined],
},
unauthenticatedCheckout: {
id: 'unauthenticatedCheckout',
mocks: [authMocks.unauthenticated, paymentMocks.success],
},
} as const satisfies ScenaristScenarios;
```
### Documentation
Document non-obvious scenarios:
```typescript
const rateLimitedScenario: ScenaristScenario = {
id: 'rateLimited',
name: 'API Rate Limited',
description: `
Simulates hitting API rate limits after 3 successful requests.
Use for testing retry logic and rate limit handling UI.
Sequence: 200 → 200 → 200 → 429 (repeats 429)
`,
mocks: [{
method: 'POST',
url: '/api/data',
sequence: {
responses: [
{ status: 200, body: { success: true } },
{ status: 200, body: { success: true } },
{ status: 200, body: { success: true } },
{ status: 429, body: { error: 'Rate limited', retryAfter: 60 } },
],
repeat: 'last',
},
}],
};
```
## Debugging with Logging
When tests fail unexpectedly, enable Scenarist’s logging to see what’s happening:
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
}),
});
```
**Common debugging scenarios:**
| Problem | What to Check | Log Category |
| ---------------------- | ------------------------- | ------------------------ |
| Wrong mock selected | Specificity scores | `matching` (debug level) |
| Mock not matching | Match criteria evaluation | `matching` (debug level) |
| State not captured | State capture events | `state` (debug level) |
| Scenario not switching | Scenario switch events | `scenario` (info level) |
Environment Variable Pattern
Add logging without code changes:
```bash
SCENARIST_LOG=1 SCENARIST_LOG_LEVEL=debug pnpm test -- --grep "failing test"
```
See [Logging Reference](/reference/logging/#environment-variable-pattern) for setup.
## Next Steps
* [Logging Reference](/reference/logging/) - Full logging configuration and API
* [Parallel Testing](/testing/parallel-testing/) - Run tests concurrently with isolated scenarios
* [Playwright Integration](/testing/playwright-integration/) - Set up type-safe fixtures
* [Testing Philosophy](/concepts/philosophy/) - Core principles behind Scenarist
# Parallel Testing
> Run concurrent tests with different backend states using test ID isolation
Scenarist enables **parallel test execution** where multiple tests run simultaneously, each with their own scenario. This is achieved through **test ID isolation** - every test gets a unique identifier that determines which scenario’s responses it receives.
## How Test Isolation Works
Each test gets a unique test ID (automatically generated by the Playwright fixture). This test ID is sent with every request, allowing Scenarist to route each test to its own scenario.
```typescript
// Two tests running in parallel, each with different scenarios
test('premium features', async ({ page, switchScenario }) => {
await switchScenario(page, 'premium'); // test-id: abc-123 → premium scenario
await page.goto('/dashboard');
await expect(page.getByText('Advanced Analytics')).toBeVisible();
});
test('free features', async ({ page, switchScenario }) => {
await switchScenario(page, 'free'); // test-id: xyz-789 → free scenario
await page.goto('/dashboard');
await expect(page.getByText('Upgrade to Premium')).toBeVisible();
});
// Both tests run simultaneously without interference
```
### Complete Request Flow
Here’s how two tests run in parallel with different scenarios:
**The isolation mechanism:**
1. **Each test gets a unique ID** - Generated automatically by the fixture
2. **Test switches scenario once** - `POST /__scenario__` with test ID
3. **All subsequent requests** include the test ID header
4. **Scenarist routes by test ID** - Same URL, different responses per test
5. **Scenario persists** for the entire test journey
This enables:
* ✅ **Unlimited scenarios** - Premium, free, error, edge cases all in parallel
* ✅ **No interference** - Each test isolated by unique test ID
* ✅ **One backend server** - All tests share same server instance
* ✅ **Fast execution** - No expensive external API calls
## Header Propagation (Critical for Parallel Tests)
Most Common Parallel Test Issue
When tests fail in parallel but pass sequentially, the root cause is almost always **test ID headers not being propagated** through server-side fetch calls.
### The Problem
When your server-side code makes internal fetch calls (e.g., Server Components fetching from API routes), headers don’t automatically propagate:
```typescript
// ❌ BAD - Headers not propagated to internal fetch
export async function Page() {
// This fetch doesn't include the test ID header!
const response = await fetch('http://localhost:3001/api/products');
const data = await response.json();
return
{/* render */}
;
}
```
Without the test ID header, the internal fetch uses the **default scenario** instead of the test’s scenario. In parallel tests, this causes interference.
### Next.js Solution: Header Propagation Helpers
**For Server Components** (use `getScenaristHeadersFromReadonlyHeaders`):
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Server Components
export default async function Page() {
const headersList = await headers(); // Get ReadonlyHeaders from Next.js
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList), // Include test ID header
},
cache: 'no-store',
});
const data = await response.json();
return
{/* render */}
;
}
```
**For Route Handlers** (use `getScenaristHeaders`):
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Route Handlers
export async function GET(request: Request) {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(request), // Include test ID header
},
cache: 'no-store',
});
const data = await response.json();
return Response.json(data);
}
```
**What these helpers do:**
* Extract test ID from request/headers
* Return `{ 'x-scenarist-test-id': 'generated-uuid' }` object
* Safe to call in production builds, where no Scenarist instance exists (returns empty object)
### Express Solution: Manual Header Forwarding
Express adapter uses AsyncLocalStorage to automatically track test IDs per request. For internal fetch calls, include the header manually:
```typescript
import { SCENARIST_TEST_ID_HEADER } from '@scenarist/express-adapter';
app.get('/api/dashboard', async (req, res) => {
const testId = req.get(SCENARIST_TEST_ID_HEADER);
const response = await fetch('http://localhost:3001/api/user', {
headers: {
[SCENARIST_TEST_ID_HEADER]: testId || '',
},
});
const data = await response.json();
res.json(data);
});
```
## Diagnosing Parallel Test Failures
### Symptoms of Missing Header Propagation
**Tests pass individually, fail in parallel:**
```bash
# Pass individually
pnpm exec playwright test --workers=1
# ✅ All tests pass
# Fail in parallel
pnpm exec playwright test --workers=4
# ❌ Some tests fail with wrong data
```
**Wrong data appearing in tests:**
```typescript
// Test expects premium pricing
await expect(page.getByText('£99.99')).toBeVisible();
// ❌ Error: element not found
// But sees standard pricing instead (from default scenario)
await expect(page.getByText('£149.99')).toBeVisible();
// ✅ This passes - wrong scenario!
```
**Flaky results:**
* Sometimes premium pricing, sometimes standard
* Different results on different runs
* Race conditions between parallel tests
### Debugging Steps
1. **Add logging** to see which scenario is active:
```typescript
const testId = req.get('x-scenarist-test-id') ?? 'default-test';
console.log('Test ID:', testId);
console.log('Active scenario:', scenarist.getActiveScenario(testId));
```
2. **Check server logs** for test ID headers on internal fetches
3. **Verify header helpers** are called before every internal fetch
4. **Confirm headers object** includes the test ID
### Fix Checklist
When parallel tests fail:
1. ✅ **Next.js:** Add header helpers before all internal fetch calls (use `getScenaristHeadersFromReadonlyHeaders` in Server Components, `getScenaristHeaders` in Route Handlers)
2. ✅ **Express:** Include test ID header in internal fetch calls
3. ✅ **Playwright:** Verify tests call `switchScenario()` before navigation
4. ✅ **Isolation:** Ensure each test switches scenarios independently
5. ✅ **Logging:** Add debug logs to confirm headers are present
## Parallel Test Patterns
### Pattern 1: Independent Scenario Tests
Each test operates with its own scenario, no shared state:
```typescript
import { test, expect } from './fixtures';
test.describe('User tiers', () => {
test('premium users see analytics', async ({ page, switchScenario }) => {
await switchScenario(page, 'premium');
await page.goto('/dashboard');
await expect(page.getByText('Analytics')).toBeVisible();
});
test('free users see upgrade prompt', async ({ page, switchScenario }) => {
await switchScenario(page, 'free');
await page.goto('/dashboard');
await expect(page.getByText('Upgrade')).toBeVisible();
});
test('enterprise users see admin panel', async ({ page, switchScenario }) => {
await switchScenario(page, 'enterprise');
await page.goto('/dashboard');
await expect(page.getByText('Admin Panel')).toBeVisible();
});
});
// All three tests run in parallel
```
### Pattern 2: Error Scenario Matrix
Test multiple error conditions in parallel:
```typescript
test.describe('Payment errors', () => {
test('handles card declined', async ({ page, switchScenario }) => {
await switchScenario(page, 'cardDeclined');
await page.goto('/checkout');
await page.click('button[type="submit"]');
await expect(page.getByText('Card was declined')).toBeVisible();
});
test('handles insufficient funds', async ({ page, switchScenario }) => {
await switchScenario(page, 'insufficientFunds');
await page.goto('/checkout');
await page.click('button[type="submit"]');
await expect(page.getByText('Insufficient funds')).toBeVisible();
});
test('handles network timeout', async ({ page, switchScenario }) => {
await switchScenario(page, 'stripeTimeout');
await page.goto('/checkout');
await page.click('button[type="submit"]');
await expect(page.getByText('Please try again')).toBeVisible();
});
});
```
### Pattern 3: Multi-Step Journeys
Each test covers a complete user journey with its scenario:
```typescript
test('complete checkout flow - premium user', async ({ page, switchScenario }) => {
await switchScenario(page, 'premium');
// Step 1: Browse products
await page.goto('/products');
await expect(page.getByText('Premium Discount: 20%')).toBeVisible();
// Step 2: Add to cart
await page.click('button:has-text("Add to Cart")');
await expect(page.getByText('Cart: 1 item')).toBeVisible();
// Step 3: Checkout
await page.goto('/checkout');
await expect(page.getByText('Total: £79.99')).toBeVisible(); // Discounted
// Step 4: Payment
await page.click('button:has-text("Pay Now")');
await expect(page.getByText('Order Confirmed')).toBeVisible();
});
test('complete checkout flow - standard user', async ({ page, switchScenario }) => {
await switchScenario(page, 'standard');
// Same journey, different scenario - runs in parallel
await page.goto('/products');
await expect(page.getByText('Premium Discount: 20%')).not.toBeVisible();
await page.click('button:has-text("Add to Cart")');
await page.goto('/checkout');
await expect(page.getByText('Total: £99.99')).toBeVisible(); // Full price
await page.click('button:has-text("Pay Now")');
await expect(page.getByText('Order Confirmed')).toBeVisible();
});
```
## Playwright Configuration
For optimal parallel test execution:
playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig({
// Run tests in parallel
fullyParallel: true,
// Number of parallel workers
workers: process.env.CI ? 4 : undefined, // 4 in CI, auto-detect locally
// Scenarist configuration
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__',
},
});
```
## Next Steps
* [Playwright Integration](/testing/playwright-integration/) - Fixture setup and configuration
* [Testing Best Practices](/testing/best-practices/) - Scenario organization patterns
* [Verification Guide](/reference/verification/) - Troubleshooting test issues
# Playwright Integration
> Set up type-safe Playwright tests with Scenarist fixtures and helpers
Scenarist provides Playwright helpers that enable type-safe scenario switching with autocomplete, automatic test ID management, and clean test organization.
## Quick Start
### 1. Install Playwright Helpers
```bash
npm install --save-dev @scenarist/playwright-helpers
# or
pnpm add -D @scenarist/playwright-helpers
```
### 2. Create Fixtures File
Create a `tests/fixtures.ts` file that exports a typed `test` object:
tests/fixtures.ts
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios'; // Your scenario definitions
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };
```
### 3. Use in Tests
**Import from fixtures:**
tests/my-feature.spec.ts
```typescript
import { test, expect } from './fixtures'; // Import from your fixtures file
test('premium users see advanced features', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // Type-safe! Autocomplete works
await page.goto('/dashboard');
await expect(page.getByText('Advanced Analytics')).toBeVisible();
});
```
**Do not import directly from @playwright/test:**
```typescript
// DON'T DO THIS
import { test, expect } from '@playwright/test'; // No Scenarist fixtures!
test('my test', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // Error: switchScenario doesn't exist
});
```
## Why Use Fixtures?
The fixtures pattern provides several benefits:
### Type-Safe Scenario IDs
TypeScript knows which scenario IDs exist and provides autocomplete:
```typescript
await switchScenario(page, 'premiumUser'); // Autocomplete suggests: 'default', 'premiumUser', 'error', etc.
await switchScenario(page, 'typo'); // TypeScript error: 'typo' not in scenarios
```
### Automatic Test ID Management
Each test gets a guaranteed unique test ID to prevent state collisions during parallel execution. You don’t need to manage test IDs yourself - the fixture handles it automatically.
For more details on parallel test execution and test isolation, see [Parallel Testing](/testing/parallel-testing/).
### Centralized Configuration
Configure the scenario endpoint once in your fixtures file or `playwright.config.ts`:
playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__', // Default, can be customized
},
});
```
The default matches the Next.js scenario routes. Express serves the endpoint at `/__scenario__`, so Express projects set `scenaristEndpoint: '/__scenario__'`.
Cross-Origin API Servers
When your API server runs on a different host or port than your frontend, use an absolute URL:
```typescript
export default defineConfig({
use: {
baseURL: 'http://localhost:3000', // Frontend (for page.goto)
scenaristEndpoint: 'http://localhost:9090/__scenario__', // API server
},
});
```
Absolute URLs (starting with `http://` or `https://`) are used directly without prepending `baseURL`.
### Clean Test Organization
All tests import from the same fixtures file, ensuring consistency:
```plaintext
tests/
fixtures.ts # Single source of truth
auth.spec.ts # import { test, expect } from './fixtures';
checkout.spec.ts # import { test, expect } from './fixtures';
dashboard.spec.ts # import { test, expect } from './fixtures';
```
## Complete Example
**1. Define Scenarios:**
lib/scenarios.ts
```typescript
import type { ScenaristScenarios } from '@scenarist/express-adapter';
export const scenarios = {
// Default scenario with complete happy path
default: {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed with valid responses',
mocks: [
// Stripe: Successful payment
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_123', status: 'succeeded', amount: 5000 },
},
},
// Auth0: Authenticated standard user
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_123', email: 'john@example.com', tier: 'standard' },
},
},
],
},
// Specialized scenario: Override Auth0 for premium user
premiumUser: {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user, everything else succeeds',
mocks: [
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_456', email: 'premium@example.com', tier: 'premium' },
},
},
],
},
// Specialized scenario: Stripe payment failure
paymentFails: {
id: 'paymentFails',
name: 'Payment Declined',
description: 'Stripe declines payment, everything else succeeds',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 402,
body: { error: { code: 'card_declined', message: 'Card was declined' } },
},
},
],
},
} as const satisfies ScenaristScenarios;
```
**2. Create Fixtures:**
tests/fixtures.ts
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
export const test = withScenarios(scenarios);
export { expect };
```
**3. Write Tests:**
tests/auth.spec.ts
```typescript
import { test, expect } from './fixtures';
test('premium users access advanced features', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/dashboard');
await expect(page.getByText('Advanced Analytics')).toBeVisible();
});
test('standard users see upgrade prompt', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/dashboard');
await expect(page.getByText('Upgrade to Premium')).toBeVisible();
});
```
## Composing with Custom Fixtures
If you already have custom Playwright fixtures, extend the Scenarist test object:
tests/fixtures.ts
```typescript
import type { Page } from '@playwright/test';
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
type CustomFixtures = {
authenticatedPage: Page;
apiToken: string;
};
export const test = withScenarios(scenarios).extend({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await use(page);
},
apiToken: async ({}, use) => {
const token = await generateTestToken();
await use(token);
},
});
export { expect };
```
Then use custom fixtures in tests:
```typescript
import { test, expect } from './fixtures';
test('authenticated user sees dashboard', async ({ authenticatedPage, switchScenario }) => {
await switchScenario(authenticatedPage, 'premiumUser');
await expect(authenticatedPage.getByText('Welcome Back')).toBeVisible();
});
```
## Per-Test Configuration Overrides
Override endpoint or baseURL for specific tests:
```typescript
import { test, expect } from './fixtures';
test('staging environment test', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser', {
baseURL: 'https://staging.example.com',
endpoint: '/api/custom-endpoint',
});
await page.goto('/dashboard');
// Test runs against staging with custom endpoint
});
```
## Cross-Origin API Servers
When your API server runs on a different host or port than your frontend, use an **absolute URL** for `scenaristEndpoint`. This is common in architectures where:
* Frontend and API are separate services on different ports
* You’re testing against a staging or production API
* Your test infrastructure uses a dedicated mock server
### Configuration
playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
// Frontend: http://localhost:3000
// API Server: http://localhost:9090
export default defineConfig({
use: {
baseURL: 'http://localhost:3000', // For Playwright navigation (page.goto)
scenaristEndpoint: 'http://localhost:9090/__scenario__', // Absolute URL to API
},
});
```
### How It Works
| Endpoint Type | Example | Behavior |
| ------------- | ------------------------------------ | ------------------------------------------------------------------- |
| Relative path | `/api/__scenario__` | Prepended with `baseURL` → `http://localhost:3000/api/__scenario__` |
| Absolute URL | `http://localhost:9090/__scenario__` | Used directly (ignores `baseURL`) |
### Per-Test Cross-Origin Override
You can also override for specific tests:
```typescript
import { test, expect } from './fixtures';
test('test against separate API server', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser', {
endpoint: 'http://api.staging.example.com/__scenario__', // Absolute URL
});
await page.goto('/dashboard');
});
```
## Debugging State
When tests fail, you often need to inspect the current test state to understand what went wrong. Scenarist provides debug fixtures for this.
### `debugState(page)`
Fetch the current test state from the debug endpoint:
```typescript
import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'checkout');
await page.goto('/cart');
// Add item to cart
await page.click('#add-item');
// Debug: Check what state was set
const state = await debugState(page);
console.log('After add item:', state);
// → { 'cart.items': 1, 'cart.total': 29.99 }
expect(state['cart.items']).toBe(1);
});
```
### `waitForDebugState(page, condition, options?)`
Wait for state to meet a condition (useful for async workflows):
```typescript
import { test, expect } from './fixtures';
test('async approval flow', async ({ page, switchScenario, waitForDebugState }) => {
await switchScenario(page, 'approvalFlow');
await page.goto('/dashboard');
// Trigger async approval
await page.click('#submit-for-approval');
// Wait for backend state to indicate approval completed
const state = await waitForDebugState(
page,
(s) => s['approval.status'] === 'approved',
{ timeout: 10000, interval: 100 }
);
expect(state['approval.status']).toBe('approved');
});
```
**Options:**
* `timeout?: number` - Maximum wait time in milliseconds (default: 5000)
* `interval?: number` - Polling interval in milliseconds (default: 100)
### Configuration
Configure the state endpoint in playwright.config.ts:
```typescript
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__',
scenaristStateEndpoint: '/__scenarist__/state', // Default value (Express)
},
});
```
Next.js apps serve the state route under `/api/`, so set `scenaristStateEndpoint: '/api/__scenarist__/state'` there, as both Next.js example apps do.
## API Reference
### `withScenarios(scenarios)`
Creates a typed Playwright test object with Scenarist fixtures.
**Parameters:**
* `scenarios` - Scenarios object (must satisfy `ScenaristScenarios` type)
**Returns:**
* Extended Playwright test object with `switchScenario` fixture
**Example:**
```typescript
const test = withScenarios(scenarios);
```
### `switchScenario(page, scenarioId, options?)`
Switch to a scenario for the current test.
**Parameters:**
* `page: Page` - Playwright Page object
* `scenarioId: string` - ID of scenario to activate (type-safe based on your scenarios)
* `options?: { endpoint?: string; baseURL?: string }` - Optional overrides
**Returns:**
* `Promise` - The test ID (for explicit `page.request` calls)
**Example:**
```typescript
test('my test', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'premiumUser');
// testId can be used for explicit page.request calls
});
```
### `expect`
Re-exported from `@playwright/test` for convenience. Use the same `expect` you’re familiar with.
```typescript
import { test, expect } from './fixtures';
test('my test', async ({ page }) => {
await expect(page.getByText('Hello')).toBeVisible();
});
```
## Next Steps
* [Parallel Testing](/testing/parallel-testing/) - Test isolation and concurrent test execution
* [Testing Best Practices](/testing/best-practices/) - Patterns for organizing tests and scenarios
* [Writing Scenarios](/scenarios/overview/) - Learn how to define scenarios
* [Endpoint APIs](/reference/api-endpoints/) - Complete endpoint reference including debug state
# Architecture
> How Scenarist's framework-agnostic core works internally
Scenarist uses **hexagonal architecture** (also called ports and adapters) to maintain complete framework independence. This means the core scenario management logic has zero framework dependencies, while thin adapters integrate with Express and Next.js (App Router and Pages Router).
## Why This Architecture Matters
**The Testing Gap Scenarist Fills:**
Unit tests CAN test server-side logic, but require extensive code-level mocking (request/response objects, session state, auth context, middleware chains, database connections). These mocks create distance from production—bugs can hide in the gap between mocked and real execution.
Browser tests give production-like execution, but testing multiple scenarios requires either complex per-scenario mocking, server restarts (impractical in CI), or hitting real external APIs (slow, flaky, expensive). Result: browser tests typically cover only the happy path.
**What developers actually need:** Test backend logic (API routes, validation, middleware, business rules, Server Components) through real HTTP requests with multiple scenarios—without external API dependencies and without server restarts.
**How Scenarist’s architecture solves this:**
* **Framework-agnostic core** handles scenario switching, test isolation, and response selection
* **Thin adapters** integrate with any framework (Express, Next.js, Remix, etc.)
* **Your entire backend executes normally** (middleware chains, validation, business logic, Server Components)
* **Only external APIs are mocked** (Stripe, SendGrid, Auth0, etc.)
This architecture ensures your code runs in production-like conditions (real middleware, real auth, real routing) while still enabling comprehensive scenario testing.
## The Hexagon: Framework-Agnostic Core
The internal core package contains all domain logic with **zero dependencies** on any web framework. Users interact with this through adapters - the core is not installed directly:
### Core Responsibilities
**1. Scenario Management**
* `ScenarioRegistry`: Stores scenario definitions (in-memory or external)
* `ScenarioStore`: Tracks active scenario per test ID
* `ScenarioManager`: Coordinates registration, switching, and retrieval
**2. Test ID Isolation**
* `StateManager`: Manages captured state per test ID
* `SequenceTracker`: Tracks sequence positions per test ID
* Each test gets isolated state via unique test ID header
**3. Dynamic Responses**
* `ResponseSelector`: Selects responses based on request content matching
* Sequence management: Advances position, handles repeat modes
* State capture/injection: Template replacement with `{{state.key}}` syntax
### Ports: Behavior Contracts
The core defines **interfaces** (ports) that adapters must implement:
```typescript
// Driving port (called by adapters, implemented by core)
interface ScenarioManager {
registerScenario(definition: ScenaristScenario): void;
switchScenario(testId: string, scenarioId: string): ScenaristResult;
getActiveScenario(testId: string): ActiveScenario | undefined;
listScenarios(): ReadonlyArray;
// ... clearScenario, getScenarioById, getState
}
// Driven ports (implemented by adapters)
interface RequestContext {
getTestId(): string;
getHeaders(): Record;
getHostname(): string;
}
// Driven port for observability
interface Logger {
info(category: LogCategory, message: string, context: LogContext, data?: Record): void;
debug(category: LogCategory, message: string, context: LogContext, data?: Record): void;
// ... error, warn, trace methods
}
interface ScenarioRegistry {
register(definition: ScenaristScenario): void;
get(scenarioId: string): ScenaristScenario | undefined;
list(): ReadonlyArray;
}
interface ScenarioStore {
set(testId: string, scenario: ActiveScenario): void;
get(testId: string): ActiveScenario | undefined;
delete(testId: string): void;
}
```
**Why interfaces for ports?**
* Signal implementation contracts clearly
* Better TypeScript errors when implementing
* Conventional in hexagonal architecture
* Class-friendly for adapter implementations
* (See “Why types for data?” below for complementary rationale)
### Data Types: Immutable Structures
All data structures use `type` (not `interface`) with `readonly`:
```typescript
type ScenaristScenario = {
readonly id: string;
readonly name: string;
readonly description: string;
readonly mocks: ReadonlyArray;
};
type ScenaristMock = {
readonly method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
readonly url: string | RegExp;
readonly response?: MockResponse;
readonly sequence?: ResponseSequence;
readonly match?: MatchCriteria;
readonly captureState?: Record;
};
```
**Why types for data?**
* Emphasizes immutability
* Better for unions, intersections, mapped types
* Functional programming alignment
* Prevents accidental mutations
## Framework Adapters: Thin Integration Layers
Adapters are small (\~100 lines) framework-specific integration layers. They translate between framework concepts and core ports.
### Adapter Responsibilities
Each adapter does **three things only**:
**1. Extract Request Context (Framework-Specific)**
```typescript
// Express
class ExpressRequestContext implements RequestContext {
constructor(private req: Request, private config: ScenaristConfig) {}
getTestId(): string {
const header = this.req.headers['x-scenarist-test-id'];
return typeof header === 'string' ? header : this.config.defaultTestId;
}
getHeaders(): Record {
return this.req.headers;
}
getHostname(): string {
return this.req.hostname;
}
}
// Next.js App Router
class AppRequestContext implements RequestContext {
constructor(private request: Request, private config: ScenaristConfig) {}
getTestId(): string {
return this.request.headers.get('x-scenarist-test-id') || this.config.defaultTestId;
}
// ... getHeaders(), getHostname()
}
```
**2. Wire Up Scenario Endpoints (Framework-Specific)**
```typescript
// Express
app.post('/__scenario__', async (req, res) => {
const testId = extractTestId(req);
const { scenario } = req.body;
const result = manager.switchScenario(testId, scenario);
if (result.success) {
res.json({ success: true });
} else {
res.status(400).json({ error: result.error.message });
}
});
// Next.js App Router
export async function POST(request: Request) {
const testId = request.headers.get('x-scenarist-test-id') || 'default-test';
const { scenario } = await request.json();
const result = manager.switchScenario(testId, scenario);
return Response.json(
result.success
? { success: true }
: { error: result.error.message },
{ status: result.success ? 200 : 400 }
);
}
```
**3. Delegate Everything Else to Core**
All scenario management, response selection, state tracking, and sequence advancement happens in the framework-agnostic core. Adapters are **translation layers only**.
### Why Adapters Stay Thin
**Adapter anti-patterns (avoided):**
* ❌ Duplicating scenario management logic
* ❌ Implementing response selection
* ❌ Managing test ID isolation
* ❌ Handling state capture/injection
**Correct adapter pattern:**
* ✅ Extract request context (framework-specific)
* ✅ Create endpoint routes (framework-specific)
* ✅ Delegate to core for all logic (framework-agnostic)
## MSW Integration Layer
The MSW adapter (`@scenarist/msw-adapter`) converts framework-agnostic scenario definitions into MSW handlers:
### MSW Adapter Responsibilities
**1. Convert Mock Definitions to MSW Handlers**
```typescript
const mockDefinition: ScenaristMock = {
method: 'GET',
url: '/api/users/:id',
response: {
status: 200,
body: { name: 'John Doe' }
}
};
// MSW Adapter registers one catch-all handler that resolves mocks per request:
http.all('*', async ({ request }) => {
// Extract test ID, find matching mocks (URL + method), select response via core
return HttpResponse.json({ name: 'John Doe' }, { status: 200 });
});
```
**2. URL Pattern Matching**
* **Exact match**: `/api/users` (string literal)
* **RegExp patterns**: `/\/api\/users\//` (substring match, any origin)
* **Path parameters**: `/api/users/:id` (extracted and available in context)
**3. Dynamic Handler with Fallback**
For each HTTP request:
1. Extract test ID from request headers
2. Get active scenario for test ID from core
3. If scenario exists, use scenario mocks
4. If no scenario, fall back to default scenario
5. Delegate response selection to core’s `ResponseSelector`
### Why MSW Integration is Separate
The MSW adapter is **not part of core** because:
* MSW is a framework for mocking HTTP (runtime dependency)
* Core must remain dependency-free (pure TypeScript)
* MSW adapter can be swapped for alternatives (Mirage, Nock, etc.)
## Data Flow: Request to Response
Here’s how a request flows through Scenarist:
### Step-by-Step Breakdown
**1. Scenario Switching**
* Test calls `switchScenario(page, 'premiumUser')`
* Playwright helper sends POST to the scenario endpoint (`scenaristEndpoint`, default `/api/__scenario__`)
* Adapter extracts test ID, calls core’s `ScenarioManager`
* Core stores `{ testId: 'uuid-123', scenarioId: 'premiumUser' }`
**2. Page Navigation**
* Browser navigates to `/dashboard` with `x-scenarist-test-id: uuid-123` header
* Adapter extracts `RequestContext` from framework request
* **Your route handler/Server Component executes normally**
**3. External API Call**
* Your code calls `fetch('https://api.external.com/user')`
* MSW intercepts the external call
* MSW asks core for active scenario: `uuid-123` → `premiumUser`
* MSW delegates response selection to core’s `ResponseSelector`
**4. Response Selection**
* Core checks match criteria (body, headers, query params)
* Core advances sequences if applicable
* Core injects state via templates if applicable
* Core returns selected response to MSW
* MSW returns response to your code
**5. Your Code Continues**
* Your route handler receives mocked response
* Your business logic executes with mocked data
* Your Server Component renders with real logic
* HTML sent to browser
## Dependency Injection Pattern
The core uses **dependency injection** for all port implementations:
```typescript
// ❌ WRONG - Creating implementation internally
export const createScenarioManager = () => {
const registry = new Map(); // Hardcoded!
// ...
};
// ✅ CORRECT - Injecting ports
export const createScenarioManager = ({
registry, // Injected
store, // Injected
}: {
registry: ScenarioRegistry;
store: ScenarioStore;
}): ScenarioManager => {
return {
registerScenario(definition) {
registry.register(definition); // Delegate to injected port
},
// ...
};
};
```
**Why dependency injection matters:**
* ✅ Testable (inject mocks for unit testing)
* ✅ True hexagonal architecture
* ✅ Follows dependency inversion principle
* ✅ Clean separation between domain logic and infrastructure
### Default Implementations
The core provides default in-memory implementations. Note: This is internal implementation detail - users interact through adapters, not directly with core:
```typescript
// Internal implementation (adapters use this, users don't import directly)
import {
createScenarioManager,
InMemoryScenarioRegistry,
InMemoryScenarioStore,
InMemoryStateManager,
InMemorySequenceTracker,
noOpLogger, // Silent by default
} from '@scenarist/core'; // Internal package
const registry = new InMemoryScenarioRegistry();
const store = new InMemoryScenarioStore();
const stateManager = new InMemoryStateManager();
const sequenceTracker = new InMemorySequenceTracker();
const scenarioManager = createScenarioManager({
registry,
store,
stateManager,
sequenceTracker,
logger: noOpLogger, // Injected dependency
});
```
For debugging, you can inject a `ConsoleLogger` instead. See [Logging Reference](/reference/logging/) for details.
## Declarative Patterns: Why Scenarios Are Data, Not Functions
Scenarist enforces **declarative patterns** - scenarios describe WHAT to return, not HOW to decide. This is an intentional constraint that leads to better test scenarios.
```typescript
// ✅ DECLARATIVE - Pure data describing intent
type ScenaristMock = {
readonly method: 'GET' | 'POST';
readonly url: string | RegExp; // String or RegExp pattern
readonly match?: { // Explicit match criteria
body?: Record;
headers?: Record;
};
readonly response: {
readonly status: number;
readonly body?: unknown;
};
};
// ❌ IMPERATIVE - Hidden logic in functions
const handler = (req: Request) => {
if (req.headers.get('x-tier') === 'premium') {
return premiumResponse;
}
return standardResponse; // Logic hidden in function body
};
```
**Why declarative patterns matter:**
| Imperative (Functions) | Declarative (Data) |
| ------------------------------- | ------------------------------------- |
| Logic hidden in function bodies | Intent visible in match criteria |
| Can’t inspect without executing | Can validate statically |
| Manual if/else ordering | Automatic specificity-based selection |
| Closures capture external state | No hidden dependencies |
| Routing hacks possible | No routing hacks |
**The constraint in action:**
```typescript
// ❌ IMPERATIVE - What we prevent
const handler = (request) => {
const tier = request.headers.get('x-tier');
const referer = request.headers.get('referer');
if (referer?.includes('/premium')) {
return premiumResponse; // Hidden routing hack!
}
if (tier === 'premium') return premiumResponse;
return standardResponse;
};
// ✅ DECLARATIVE - What Scenarist enforces
const mocks = [
{
url: '/api/products',
match: { headers: { 'x-tier': 'premium' } },
response: premiumResponse
},
{
url: '/api/products',
response: standardResponse // Fallback (no match criteria)
}
];
// Intent is visible: "premium tier header → premium response"
```
This mirrors React’s design philosophy: React could allow imperative DOM manipulation, but enforces declarative JSX because it’s clearer, more composable, and easier to reason about. Scenarist makes the same choice for test scenarios.
**Runtime conversion:** A single MSW handler interprets the declarative definitions at request time:
```typescript
const buildResponse = async (response: ScenaristResponse): Promise => {
if (response.delay) await delay(response.delay);
return HttpResponse.json(response.body, {
status: response.status,
headers: response.headers,
});
};
// Registered once: http.all('*', ...) selects the matching mock's response, then calls buildResponse
```
## Benefits of Hexagonal Architecture
**1. Framework Independence**
* Write scenarios once, use with Express and Next.js (App Router and Pages Router)
* Switching frameworks doesn’t require rewriting tests
* Core improvements benefit all frameworks immediately
**2. Testability**
* Core has zero dependencies (easy to test)
* Adapters are thin (easy to test)
* Ports can be mocked for unit testing
**3. Extensibility**
* Add new frameworks by writing \~100 line adapter
* Add new storage backends by implementing ports
* Add new features in core, all adapters benefit
**4. Maintainability**
* Clear separation of concerns
* Framework-specific code isolated to adapters
* Domain logic centralized in core
## Next Steps
* [Scenario Format](/scenarios/basic-structure/) - How to define and structure scenarios
* [Dynamic Responses](/scenarios/overview/) - Request matching, sequences, and state
* [Framework Guides](/frameworks/express/getting-started/) - Integrating with your framework
# Production Safety
> How Scenarist ensures zero test code in production bundles
Scenarist is **safe to use in production** - your test mocking code will never reach production users.
## How It Works
When your production build resolves the `production` export condition, `createScenarist()` resolves to a stub that returns `undefined` without loading any test code, so no Scenarist or MSW code ends up in your production bundle. Next.js production builds resolve this condition automatically; Express builds must enable it (see [Express](#framework-specific-verification) below).
Independently of the condition, `createScenarist()` also returns `undefined` when `enabled` is `false`, and the Express adapter returns `undefined` when `NODE_ENV` is `production` (see [Runtime Guards](#runtime-guards)).
```typescript
import { createScenarist } from "@scenarist/express-adapter";
import { scenarios } from "./scenarios";
// In production builds (`production` export condition), or when enabled is false: returns undefined
// In development/test with enabled: true: returns working Scenarist instance
export const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
});
```
### The Production Wrapper Pattern
All Scenarist adapters use **conditional package.json exports** to swap in a production entry point that imports nothing:
```typescript
// production.js - resolved via the `production` export condition
export const createScenarist = (_options) => {
return undefined; // ← Returns immediately, no test code loaded
};
// Development/test (default condition): the full implementation is exported
```
This pattern ensures:
* ✅ **Zero runtime impact** - Production code paths never execute test logic
* ✅ **Nothing to tree-shake** - The production entry point has zero imports
* ✅ **Automatic in Next.js** - Next.js production builds resolve the `production` condition; Express builds need it enabled
* ✅ **Type-safe** - TypeScript enforces null checks via `| undefined` return type
### Runtime Guards
* **`enabled: false`**: every adapter returns `undefined` from `createScenarist()`: no endpoints, no middleware, no interception.
* **`NODE_ENV=production` (Express)**: the Express adapter also returns `undefined` when `NODE_ENV` is `production`, even with `enabled: true`. This protects unbundled Express apps started without `--conditions=production`.
A runtime guard makes Scenarist inert, but it does not remove code: without the `production` condition, the adapter and MSW are still loaded or bundled.
enabled is honoured
`enabled` decides whether Scenarist runs, so it must evaluate to `true` in the process that serves your tests. For Express, `process.env.NODE_ENV === "test"` works when your test runner sets it (Vitest and Jest do). **In Next.js, don’t gate on `NODE_ENV === "test"`:** Next.js replaces `process.env.NODE_ENV` in your code with `'development'` under `next dev` and `'production'` under `next build`, even when you start it with `NODE_ENV=test`, so that check is never true. Use `enabled: true`; production builds are excluded by the `production` export condition.
## Bundle Size Impact
Production bundles contain **zero Scenarist code** - the production wrapper pattern combined with tree-shaking completely eliminates all test code from your production builds.
### Verification
**Critical:** Always verify tree-shaking is working in your production builds. This section provides multiple methods to confirm Scenarist code is eliminated.
#### Quick Verification (30 seconds)
The fastest way to verify tree-shaking by searching for **MSW runtime functions** (not just the word “msw”):
```bash
# Build for production
NODE_ENV=production npm run build
# Search for MSW runtime code (setupWorker, HttpResponse, handler functions)
# and Scenarist's shared server global, which survives minification
OUT_DIR=dist # your build output directory, e.g. dist, .next or build
test -d "$OUT_DIR" && {
grep -rqE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' "$OUT_DIR"
test $? -eq 1
}
```
**Expected result:** exit code 0. `grep` exits 1 when nothing matches, so the command succeeds only when the output directory exists and contains no matches. A non-zero exit means tree-shaking failed or the directory is missing.
**Why this pattern:** Searching for the literal strings “scenarist” or “msw” gives false positives (your own variable names, Zod code, comments). This pattern searches for actual MSW runtime functions like `http.get()`, `HttpResponse.json()`, and worker setup - code that should NEVER appear in production bundles.
#### Visual Bundle Analysis
Visual analysis tools provide the most comprehensive verification. Choose the tool matching your bundler:
**Next.js (Webpack):**
```bash
# Install analyzer
npm install --save-dev @next/bundle-analyzer
# Add to next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// ... your config
});
# Build and analyze
ANALYZE=true NODE_ENV=production npm run build
```
Opens interactive visualization in browser. Search for “scenarist” or “msw” - should find nothing.
**Vite:**
```bash
# Install visualizer
npm install --save-dev rollup-plugin-visualizer
# Add to vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
],
});
# Build and analyze
NODE_ENV=production npm run build
```
Opens `stats.html` showing bundle composition. Scenarist/MSW should be absent.
**Webpack (standalone):**
```bash
# Install analyzer
npm install --save-dev webpack-bundle-analyzer
# Generate stats during build
webpack --mode production --profile --json > stats.json
# Analyze
npx webpack-bundle-analyzer stats.json
```
**Rollup:**
```bash
# Install visualizer
npm install --save-dev rollup-plugin-visualizer
# Add to rollup.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
filename: 'bundle-stats.html',
open: true,
}),
],
};
# Build
NODE_ENV=production npm run build
```
#### Source Map Analysis (Framework-Agnostic)
Works with any bundler:
```bash
# Install source-map-explorer
npm install --save-dev source-map-explorer
# Build with source maps
NODE_ENV=production npm run build
# Analyze (adjust paths to your build output)
npx source-map-explorer 'dist/**/*.js' 'dist/**/*.js.map'
```
Generates treemap showing code origins. Scenarist/MSW should not appear.
#### Size Comparison (Before/After)
Measure actual bundle size impact:
```bash
# Baseline: Build without Scenarist
# (Comment out scenarist imports temporarily)
NODE_ENV=production npm run build
du -sh dist/ .next/static/ build/
# Note the size
# With Scenarist: Build normally
# (Uncomment scenarist imports)
NODE_ENV=production npm run build
du -sh dist/ .next/static/ build/
# Compare sizes - should be identical or negligible difference
```
**Expected:** ≤ 1KB difference (rounding/metadata only)
#### Framework-Specific Verification
**Express (Requires the `production` Condition):**
Caution
The Express adapter’s production entry point is selected through the `production` export condition. Node.js and standalone bundlers such as esbuild do not enable this custom condition by default, so setting `NODE_ENV=production` alone does not remove Scenarist’s code. The runtime guard still returns `undefined`, so no endpoints are mounted, but the adapter and `msw` are loaded, so `msw` must be installed.
**Unbundled Deployments:**
Run Node with the `production` condition:
```bash
# Deploy directly
NODE_ENV=production node --conditions=production src/server.js
# Without the condition: Scenarist is inert (/__scenario__ returns 404),
# but its code and msw are still loaded
NODE_ENV=production node src/server.js
```
**How it works:**
* Node resolves `@scenarist/express-adapter` to its production entry point
* `createScenarist()` returns `undefined` at runtime
* MSW code **never loads into memory**
Verify Node resolves the production entry point, using the same Node flags as your start command (Node.js 20.6+, run from your app’s root):
```bash
# Succeeds only when @scenarist/express-adapter resolves to its zero-import stub
node --conditions=production --input-type=module \
-e "console.log(import.meta.resolve('@scenarist/express-adapter'))" \
| grep -q '/dist/setup/production.js$'
```
Checking open files with `lsof` does not work for this: Node closes module files after loading them, so MSW never shows up even when it is loaded.
**Bundled Deployments (esbuild, webpack, Vite, rollup):**
Scenarist uses **conditional package.json exports** to provide different entry points:
```json
{
"exports": {
".": {
"production": "./dist/setup/production.js", // Zero dependencies
"default": "./dist/index.js" // Full implementation
}
}
}
```
The `"production"` condition is **custom** (not a Node.js built-in). Bundlers must be configured to recognize it:
**esbuild:**
```bash
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js --define:process.env.NODE_ENV='"production"' --conditions=production
```
**webpack:**
webpack.config.js
```js
module.exports = {
mode: "production",
resolve: {
conditionNames: ["production", "import", "require"],
},
};
```
**Vite:**
vite.config.js
```js
export default {
resolve: {
conditions: ["production"],
},
};
```
**rollup:**
```js
import resolve from "@rollup/plugin-node-resolve";
export default {
plugins: [
resolve({
exportConditions: ["production"],
}),
],
};
```
Caution
**WARNING**: The `--conditions` flag is **GLOBAL** and affects ALL packages with conditional exports in your dependency tree, not just Scenarist. This could change behavior of other dependencies unexpectedly.
**Verify bundled Express app:**
```bash
# Build production bundle (with --conditions=production)
NODE_ENV=production npm run build
# Search for MSW code (succeeds only if dist/ exists and has no matches)
test -d dist && {
grep -rqE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/
test $? -eq 1
}
```
Minifiers rename MSW’s internal identifiers, so `setupWorker` or `HttpResponse.json` can miss MSW code in a minified bundle. `__scenarist_shared_msw_server` is a global property name that survives minification.
**Example verification script:**
```json
{
"scripts": {
"build:production": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js --external:express --define:process.env.NODE_ENV='\"production\"' --minify --conditions=production",
"verify:treeshaking": "npm run build:production && test -d dist && { grep -rqE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\\.(get|post|put|delete|patch)|HttpResponse\\.json)' dist/; test $? -eq 1; }"
}
}
```
Run: `npm run verify:treeshaking`
**For detailed bundler configuration examples**, see the [Express Adapter README - Production Tree-Shaking](https://github.com/citypaul/scenarist/tree/main/packages/express-adapter#production-tree-shaking).
**Next.js (Detailed):**
Next.js has separate client and server bundles. Verify both:
```bash
# Build
NODE_ENV=production npm run build
# Check client and server bundles for Scenarist/MSW runtime code.
# grep exits 1 when nothing matches, so this succeeds (exit 0) only when the
# bundles exist and contain no matches.
test -d .next/static && test -d .next/server && {
grep -rqE --include='*.js' '(__scenarist_shared_msw_server|setupWorker|HttpResponse\.json)' .next/static .next/server
test $? -eq 1
}
```
**Vercel Deployment:**
If deployed to Vercel, check production bundle size in deployment logs:
```plaintext
Build Output:
├ ƒ / 1.2 kB 123 B
├ ○ /404 2.1 kB 456 B
└ ƒ /api/products 450 B 78 B
```
Compare with pre-Scenarist deployment - sizes should be nearly identical.
#### Automated CI/CD Verification
Prevent tree-shaking regressions by adding checks to CI:
**GitHub Actions Example:**
```yaml
name: Verify Production Safety
on:
pull_request:
paths:
- "package.json"
- "src/**"
jobs:
verify-bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Build production bundle
run: NODE_ENV=production npm run build
env:
NODE_ENV: production
- name: Verify no MSW runtime code in bundle
run: |
OUT_DIR=dist # your build output directory, e.g. dist, .next or build
status=0
grep -rE '(__scenarist_shared_msw_server|setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' "$OUT_DIR" || status=$?
if [ "$status" -eq 0 ]; then
echo "❌ ERROR: MSW runtime code found in production bundle!"
echo "Tree-shaking failed. Check the production condition and bundler config."
exit 1
fi
if [ "$status" -ne 1 ]; then
echo "❌ ERROR: could not search $OUT_DIR"
exit 1
fi
echo "✅ Verified: No MSW runtime code in production bundle"
- name: Compare bundle sizes
run: |
# Store baseline size (first run) or compare
BUNDLE_SIZE=$(du -sb dist/ .next/ build/ 2>/dev/null | awk '{sum+=$1} END {print sum}')
echo "Bundle size: $BUNDLE_SIZE bytes"
# Add budget check if needed
```
**npm script:**
Add to `package.json` for manual verification:
```json
{
"scripts": {
"verify:production": "NODE_ENV=production npm run build && test -d dist && { grep -rqE '(__scenarist_shared_msw_server|setupWorker|HttpResponse\\.json)' dist/; test $? -eq 1; }"
}
}
```
Run: `npm run verify:production`
#### What Success Looks Like
**✅ Passing verification:**
* Bundle analyzer shows NO `scenarist` or `msw` packages
* `grep` search returns no matches (exit code 1)
* Bundle size same as before adding Scenarist (±1KB)
* Source maps show NO Scenarist source files
* Production deployment works normally
**❌ Failed verification (tree-shaking didn’t work):**
* Bundle contains `scenarist` or `msw` strings
* Bundle size significantly larger than expected
* `createScenarist` function visible in bundle
* MSW handlers appear in production code
#### Troubleshooting Failed Verification
If verification fails, check these common issues:
**1. `production` export condition not resolved:**
```bash
# ❌ Wrong - resolves the full implementation
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js
# ✅ Correct - resolves Scenarist's production entry point
esbuild src/server.ts --bundle --platform=node --outfile=dist/server.js --conditions=production
```
**2. Bundler not configured for tree-shaking:**
Most bundlers enable tree-shaking by default in production mode. Verify config:
**Webpack:**
webpack.config.js
```js
module.exports = {
mode: "production", // ← Required
optimization: {
usedExports: true, // ← Should be true (default in production)
sideEffects: true, // ← Should be true (default)
},
};
```
**Vite:**
```js
// vite.config.js - tree-shaking automatic in production
export default defineConfig({
build: {
minify: "terser", // Or 'esbuild' - both tree-shake
},
});
```
**3. Express bundle built without the `production` condition:**
Code splitting and `NODE_ENV=production` do not remove Scenarist from an Express bundle. The adapter imports its implementation statically, so only the `production` export condition swaps it for the zero-import stub:
```bash
# ❌ Adapter and MSW code stay in the bundle
esbuild src/server.ts --bundle --platform=node --format=esm --splitting --outdir=dist --define:process.env.NODE_ENV='"production"'
# ✅ Resolves the stub: no Scenarist or MSW code in the bundle
esbuild src/server.ts --bundle --platform=node --format=esm --splitting --outdir=dist --define:process.env.NODE_ENV='"production"' --conditions=production
```
See [Express bundled deployments](#framework-specific-verification) for webpack, Vite, and rollup configuration.
**4. package.json sideEffects field:**
Check if your bundler respects `sideEffects: false` in Scenarist’s package.json:
```bash
cat node_modules/@scenarist/*/package.json | grep sideEffects
# Should show: "sideEffects": false
```
If this is missing, [file an issue](https://github.com/citypaul/scenarist/issues) - this is a bug.
**5. TypeScript compilation settings:**
Ensure TypeScript preserves ES modules:
tsconfig.json
```json
{
"compilerOptions": {
"module": "ES2020", // ← Or "ESNext", not "CommonJS"
"moduleResolution": "bundler" // ← Or "node16"
}
}
```
CommonJS modules prevent tree-shaking in most bundlers.
#### Still Having Issues?
If tree-shaking still fails after checking the above:
1. **Check your bundler version** - Update to latest version
2. **Review bundler logs** - Look for warnings about side effects
3. **Minimal reproduction** - Test in fresh project to isolate issue
4. **Open an issue** - [Report it](https://github.com/citypaul/scenarist/issues) with:
* Bundler name and version
* Framework name and version
* Bundler config
* Output of `npm ls @scenarist/*`
For additional help, see:
* [Production Tree-Shaking Verification](/reference/verification/#production-tree-shaking-verification) - Verify the production entry point is used
* [Header Propagation in Parallel Tests](/reference/verification/#header-propagation-in-parallel-tests) - Troubleshoot test isolation issues
## Type Safety
The return type `ExpressScenarist | undefined` (or equivalent for your adapter) forces you to handle the production case:
```typescript
// TypeScript enforces null checks
if (!scenarist) {
// Production mode - scenarist is undefined
return;
}
// Development/test mode - scenarist is defined
scenarist.start();
```
This prevents accidentally calling test methods in production code.
## Framework-Specific Notes
### Express
```typescript
import { createScenarist } from "@scenarist/express-adapter";
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Type-safe null check required
if (scenarist) {
scenarist.start(); // Only runs in development/test
}
```
### Next.js (App Router & Pages Router)
Both Next.js adapters use the **synchronous pattern** with conditional exports for production safety:
lib/scenarist.ts
```typescript
import { createScenarist } from "@scenarist/nextjs-adapter/app";
// or: import { createScenarist } from '@scenarist/nextjs-adapter/pages';
// Synchronous - no async/await needed
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// MSW auto-starts in development/test (when scenarist is defined)
if (typeof window === "undefined" && scenarist) {
scenarist.start();
}
```
**Production behavior:**
* ✅ **Conditional exports** resolve to `production.js` which returns `undefined` immediately
* ✅ **Zero imports** in production.js guarantees tree-shaking
* ✅ **Zero configuration:** Works automatically based on `NODE_ENV`
**Safe Helper Functions:**
To avoid `scenarist?.method() ?? fallback` patterns everywhere, both adapters export safe helper functions:
```typescript
// App Router API Route
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
export async function GET(request: Request) {
const response = await fetch("http://localhost:3001/products", {
headers: {
...getScenaristHeaders(request), // Always safe, no guards needed
"x-user-tier": "premium",
},
cache: "no-store",
});
}
```
```typescript
// App Router Server Component
import { headers } from "next/headers";
import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app";
export default async function ProductsPage() {
const headersList = await headers();
const response = await fetch("http://localhost:3001/products", {
headers: getScenaristHeadersFromReadonlyHeaders(headersList),
cache: "no-store",
});
}
```
```typescript
// Pages Router API Route
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/pages";
export default async function handler(req, res) {
const response = await fetch("http://localhost:3001/products", {
headers: getScenaristHeaders(req),
});
}
```
**Available helpers:**
* **App Router:**
* `getScenaristHeaders(request)` - Extract Scenarist headers from Request
* `getScenaristHeadersFromReadonlyHeaders(headers)` - Extract Scenarist headers from ReadonlyHeaders
* `getScenaristTestId(request)` - Extract test ID string from Request
* `getScenaristTestIdFromReadonlyHeaders(headers)` - Extract test ID string from ReadonlyHeaders
* **Pages Router:**
* `getScenaristHeaders(req)` - Extract Scenarist headers from IncomingMessage
These helpers access global singletons and return safe defaults in production (empty objects for headers, `'default-test'` for test IDs), eliminating the need for manual undefined checks.
## Common Questions
### Does Scenarist run in production if I forget NODE\_ENV?
**Not if `enabled` is tied to your test environment.** In Express, gating `enabled` on `process.env.NODE_ENV === "test"` means an unset `NODE_ENV` makes `createScenarist()` return `undefined`: no endpoints, no interception. (In Next.js use `enabled: true`: Next.js inlines `NODE_ENV`, so a `"test"` check is never true, and production builds are excluded by the `production` export condition.) `enabled` is the switch, not `start()`: MSW only intercepts requests after `scenarist.start()`, but the scenario endpoints are active wherever you mount the middleware (Express) or export the route handlers (Next.js).
You should still set `NODE_ENV=production` in production for:
* The Express runtime guard (returns `undefined` even with `enabled: true`)
* Optimal performance
* Correct framework behavior
### What about CI/CD environments?
CI/CD environments should use:
* `NODE_ENV=test` for running tests
* `NODE_ENV=production` for building production bundles
Scenarist works in both modes:
* **Test mode:** Full functionality for scenario-based tests
* **Production builds (`production` export condition):** Returns undefined, so no test code is bundled
### Can I verify tree-shaking before deploying?
Yes! See the [Production Tree-Shaking Verification](/reference/verification/#production-tree-shaking-verification) section for:
* Step-by-step build verification
* Bundle size analysis
* Build artifact inspection
* Framework-specific verification commands
* Red flags and troubleshooting
### What if tree-shaking fails?
If your bundler doesn’t tree-shake Scenarist:
1. **Check the `production` condition:** Ensure your bundler (or `node --conditions=production`) resolves it
2. **Check bundler config:** Verify tree-shaking is enabled (it usually is by default)
3. **Check sideEffects:** Scenarist marks itself as side-effect-free in package.json
4. **File an issue:** If tree-shaking still fails, [open an issue](https://github.com/citypaul/scenarist/issues) with your bundler details
## Best Practices
1. **Always use `NODE_ENV=production` for production builds**
```bash
NODE_ENV=production npm run build
```
2. **Add null checks where you use Scenarist**
```typescript
if (scenarist) {
scenarist.start();
}
```
3. **Verify bundle size in CI/CD**
```yaml
- name: Check bundle size
run: npm run build && npm run analyze
```
4. **Monitor production bundles**
* Set up bundle size budgets
* Alert on unexpected size increases
* Review bundle composition regularly
## Supply Chain Security
Beyond production safety, Scenarist provides cryptographic verification of package authenticity:
* **npm Provenance**: Packages include [npm provenance](https://docs.npmjs.com/generating-provenance-statements) attestations
* **GitHub Attestations**: Build provenance attestations verifiable via GitHub CLI
* **SBOM**: Software Bill of Materials signed with Sigstore
### Verifying Packages
Verify any Scenarist package was built from this repository:
```bash
# Download and verify a package
npm pack @scenarist/core
gh attestation verify scenarist-core-*.tgz -R citypaul/scenarist
```
View all attestations at [github.com/citypaul/scenarist/attestations](https://github.com/citypaul/scenarist/attestations).
For complete security documentation, see [SECURITY.md](https://github.com/citypaul/scenarist/blob/main/SECURITY.md).
## Summary
Scenarist is designed for production safety:
| Aspect | Guarantee |
| ------------------ | --------------------------------------------------------- |
| Production runtime | No endpoints, no request interception |
| Bundle size | Zero Scenarist code after tree-shaking |
| Configuration | Next.js: none; Express: enable the `production` condition |
| Type safety | TypeScript enforces null checks |
| Performance | No runtime overhead in production |
**The bottom line:** the `production` export condition removes Scenarist entirely, and the runtime guards (`enabled: false`, and `NODE_ENV=production` in Express) keep it inert when the condition is not applied.
# Testing Apps with Database Access
> How to test applications that use direct database access alongside Scenarist
## The Pattern: Shared Identity for Parallel Test Isolation
When testing applications that access multiple data sources (HTTP APIs + databases), the key insight is simple:
**Use a single identifier to partition ALL data sources.**
```plaintext
┌─────────────────────────────────────────────────────────┐
│ Test Runner │
├─────────────────────────────────────────────────────────┤
│ Test A (x-scenarist-test-id: abc-123) │
│ │ │
│ ├─→ HTTP API ──→ Scenarist ──→ Mocks for "abc-123" │
│ │ │
│ └─→ Database ──→ Your Code ──→ Data for "abc-123" │
├─────────────────────────────────────────────────────────┤
│ Test B (x-scenarist-test-id: xyz-789) │
│ │ │
│ ├─→ HTTP API ──→ Scenarist ──→ Mocks for "xyz-789" │
│ │ │
│ └─→ Database ──→ Your Code ──→ Data for "xyz-789" │
└─────────────────────────────────────────────────────────┘
Both tests run in PARALLEL with completely isolated state.
```
This is the same pattern used in distributed systems for request tracing, multi-tenancy, and session management. The test ID is just another form of correlation ID.
***
## What Scenarist Provides vs. What You Implement
Clear Separation of Concerns
**Scenarist handles HTTP isolation** — it extracts the test ID from request headers and returns scenario-specific mock responses. This is built-in. You get it for free.
**You handle database isolation** — you implement the mechanism to extract the test ID and partition database queries accordingly. Scenarist doesn’t touch databases.
The **connection point** is the `x-scenarist-test-id` header. Both systems read the same header and use it as their partition key.
| Responsibility | Tool | What It Does |
| ------------------------ | ------------- | ------------------------------------------------------ |
| HTTP API mocking | **Scenarist** | Intercepts fetch calls, returns mocks based on test ID |
| Database state isolation | **Your code** | Partitions database queries/data based on test ID |
Scenarist scripts
* **Third-party HTTP APIs**: Stripe, Auth0, SendGrid
* **Other services your server calls** over HTTP
* **Any outgoing HTTP request** from your server code
Scenarist leaves alone
* **Database queries**: Prisma, Drizzle, raw SQL
* **Your own API routes and Server Actions**: they’re your code, so let them run for real
* **File system operations**: `fs.readFile` and similar
***
## Implementing Database Isolation
There are multiple valid approaches to partition database state by test ID. Each has different trade-offs:
### Approach A: Repository Pattern with In-Memory Store
Abstract database access behind interfaces, inject test implementations that partition data by test ID.
**Best for:** Fast parallel tests, clean architecture, any ORM
**Trade-offs:** Requires refactoring database access; doesn’t test real SQL
[Detailed Guide →](./repository-pattern)
### Approach B: Test Fixtures with Direct Seeding
Seed database directly in test setup, using test ID to isolate data.
```typescript
test('premium user sees discounts', async ({ page }) => {
const testId = generateTestId();
// Seed directly (if tests have DB access)
await db.users.create({
testId,
tier: 'premium',
// ...
});
// Or seed via HTTP endpoint
await page.request.post('/test/seed', {
headers: { 'x-scenarist-test-id': testId },
data: { scenarioId: 'premiumUser' }
});
await page.goto('/products');
// ...
});
```
**Best for:** When tests have direct database access (e.g., same process)
**Trade-offs:** Tests coupled to database schema; requires cleanup logic
### Approach C: Row-Level Security or Test ID Columns
Add `test_id` column to tables, filter all queries automatically.
```sql
-- PostgreSQL RLS policy
CREATE POLICY test_isolation ON users
USING (test_id = current_setting('app.test_id'));
```
**Best for:** When you can’t change application code
**Trade-offs:** Schema changes in production; RLS overhead
### Approach D: Database Snapshots/Migrations
Pre-seed database with known state, reset between tests.
**Best for:** Testing specific SQL behavior; integration tests
**Trade-offs:** Slower; harder to parallelize
***
## Our Recommendation: Repository Pattern
For teams that want **scalable parallel testing** of real server-side code, we recommend the repository pattern because:
1. **Same isolation model as Scenarist** — Test ID partitions both HTTP and database
2. **Fast execution** — In-memory stores are orders of magnitude faster than real databases
3. **Clean architecture** — Benefits extend beyond testing (infrastructure flexibility, SOLID principles)
4. **ORM agnostic** — Works with Prisma, Drizzle, TypeORM, raw SQL
[Full Repository Pattern Guide →](./repository-pattern)
The Pattern vs. The Implementation
The **pattern** is “use test ID to partition database state.”
The **repository pattern** is one way to implement that pattern. It’s the approach we show in our examples because it provides the cleanest architecture, but it’s not the only way.
If your constraints don’t allow refactoring to repositories, consider the other approaches above.
***
## Why Database Testing Is Different
Scenarist intercepts HTTP requests via MSW because every HTTP library eventually calls the same underlying APIs. **Databases have no equivalent universal interception point.**
Each ORM and driver is a different API surface:
* Prisma: `prisma.user.findMany()`
* Drizzle: `db.select().from(users)`
* TypeORM: `userRepository.find()`
* Raw SQL: `pg.query('SELECT * FROM users')`
```typescript
// ❌ Scenarist CANNOT mock this - no HTTP request
export async function fetchProducts() {
return await db.products.findMany(); // Direct database call
}
```
That’s why **you implement** database isolation, using whatever approach fits your architecture. The examples show one way; adapt it to your needs.
***
## Quick Decision Guide
| Your Situation | Approach |
| ------------------------------------------------- | --------------------------------------------------------------------------- |
| App only calls external HTTP APIs (no database) | Scenarist alone; no database strategy needed |
| Want scalable parallel tests + clean architecture | [Repository Pattern](./repository-pattern) |
| Tests have direct database access | Test fixtures with direct seeding |
| Can’t change application code | Row-level security or test ID columns |
| Need to test actual SQL queries, constraints | [Testcontainers](./testcontainers-hybrid) |
| Small test suite, prototyping | [Sequential Execution](./parallelism-options#option-1-sequential-execution) |
***
## Next Steps
* [Repository Pattern Guide](./repository-pattern) — Full implementation walkthrough
* [Testcontainers Hybrid](./testcontainers-hybrid) — Testing real database behavior
* [All Parallelism Options](./parallelism-options) — Detailed comparison of approaches
# Database Testing Parallelism Options
> Compare strategies for parallel database testing—trade-offs between code changes, schema changes, complexity, and speed
## The Core Problem
Databases have no equivalent to HTTP’s test ID header. Without isolation, parallel tests corrupt each other’s data.
[We recommend the repository pattern](/guides/testing-database-apps/) for most teams. This page compares all options.
## Options Comparison
| Approach | Parallelism | No Code Changes | No Schema Changes | ORM Agnostic | Complexity |
| ------------------------------ | ----------- | --------------- | ----------------- | ------------ | ---------- |
| Sequential execution | ❌ | ✅ | ✅ | ✅ | Low |
| ⭐ Repository pattern | ✅ | ❌ | ✅ | ✅ | Medium |
| Sharding (container per shard) | ✅ | ✅ | ✅ | ✅ | Medium |
| PostgreSQL RLS | ✅ | ✅ | ❌ | ❌ | High |
| Schema per test | ✅ | ✅ | ✅ | ✅ | Very High |
**No option gives you everything.** Choose based on your priorities.
## Option 1: Sequential Execution
**Accept the limitation.** Run database tests sequentially, HTTP tests in parallel.
playwright.config.ts
```typescript
export default defineConfig({
projects: [
{
name: 'database-tests',
testMatch: '**/db-*.spec.ts',
fullyParallel: false, // Sequential
},
{
name: 'api-tests',
testMatch: '**/api-*.spec.ts',
fullyParallel: true, // Parallel (Scenarist handles isolation)
},
],
});
```
**Best for:**
* Quick prototypes where test speed doesn’t matter yet
* Teams who know their test suite will remain small
* Temporary solution while planning migration to repository pattern
**Trade-offs:**
* ✅ No code changes
* ✅ No schema changes
* ✅ Works with any ORM
* ❌ Doesn’t scale—CI time grows linearly with test count
* ❌ Limits your ability to run comprehensive test suites
## Option 2: Repository Pattern (Recommended)
Abstract database access behind interfaces and inject test implementations.
```typescript
// Define repository interface
interface UserRepository {
findById(id: string): Promise;
create(user: User): Promise;
findByEmail(email: string): Promise;
}
// Production implementation
class PrismaUserRepository implements UserRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
async create(user: User) {
return this.prisma.user.create({ data: user });
}
async findByEmail(email: string) {
return this.prisma.user.findFirst({ where: { email } });
}
}
// Test implementation with test ID isolation
class InMemoryUserRepository implements UserRepository {
private store = new Map>();
constructor(private getTestId: () => string) {}
private getTestStore() {
const testId = this.getTestId();
if (!this.store.has(testId)) {
this.store.set(testId, new Map());
}
return this.store.get(testId)!;
}
async findById(id: string) {
return this.getTestStore().get(id) ?? null;
}
async create(user: User) {
this.getTestStore().set(user.id, user);
return user;
}
async findByEmail(email: string) {
for (const user of this.getTestStore().values()) {
if (user.email === email) return user;
}
return null;
}
}
```
```typescript
// Inject based on environment
const userRepository = process.env.NODE_ENV === 'test'
? new InMemoryUserRepository(() => getTestIdFromHeader())
: new PrismaUserRepository(prisma);
```
**Best for:**
* Teams wanting true test-level parallelism
* Growing codebases that need scalable testing
* Teams valuing clean architecture and infrastructure flexibility
**Trade-offs:**
* ✅ True parallelism with test ID isolation
* ✅ No schema changes
* ✅ ORM agnostic (swap implementations freely)
* ✅ In-memory tests are fast
* ✅ Infrastructure flexibility (change databases/ORMs later)
* Requires abstracting database access behind interfaces
* Separate tests needed for real database behavior
[**Learn more about the Repository Pattern →**](./repository-pattern)
## Option 3: Sharding (Container per Shard)
Run multiple PostgreSQL containers, one per Playwright shard. This provides parallelism at the shard level (not test level)—tests within each shard still run sequentially against their container.
playwright.config.ts
```typescript
export default defineConfig({
workers: 4, // 4 parallel workers
});
// globalSetup.ts
export default async function globalSetup() {
const workerId = process.env.TEST_WORKER_INDEX || '0';
// Each worker gets its own container
const container = await new PostgreSqlContainer()
.withDatabase(`test_${workerId}`)
.start();
process.env.DATABASE_URL = container.getConnectionUrl();
}
```
Run with sharding:
```bash
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
```
**Best for:**
* CI/CD with good parallelization support
* Teams with container orchestration experience
* Teams who want parallelism without code changes
**Trade-offs:**
* ✅ Shard-level parallelism
* ✅ No code or schema changes
* ❌ Not test-level parallelism (tests within shard still sequential)
* ❌ Resource-intensive (CPU, memory, disk)
* ❌ Complex CI/CD setup
* ❌ Slower container startup (multiplied by shard count)
## Option 4: PostgreSQL Row-Level Security (RLS)
Use PostgreSQL’s built-in RLS to isolate by test ID without application code changes.
```sql
-- Add test_id to tables
ALTER TABLE users ADD COLUMN test_id TEXT;
-- Create policy
CREATE POLICY test_isolation ON users
USING (test_id = current_setting('app.test_id', true));
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
```
```typescript
// Set session variable per request
app.use(async (req, res, next) => {
const testId = req.headers['x-scenarist-test-id'];
await prisma.$executeRaw`SELECT set_config('app.test_id', ${testId}, true)`;
next();
});
```
**Best for:**
* PostgreSQL users only
* Teams comfortable with database-level security
* Applications where RLS is already used
**Trade-offs:**
* ✅ True parallelism
* ✅ No application code changes (after setup)
* ✅ Database handles isolation
* ❌ PostgreSQL only (not portable)
* ❌ Schema changes required
* ❌ Complex to debug
* ❌ Performance overhead
## Option 5: Schema Per Test
Create a database schema for each test, complete isolation.
```typescript
// Before each test
const testId = generateTestId();
await prisma.$executeRaw`CREATE SCHEMA test_${testId}`;
await prisma.$executeRaw`SET search_path TO test_${testId}`;
// Run migrations in new schema
execSync('npx prisma migrate deploy');
// After test
await prisma.$executeRaw`DROP SCHEMA test_${testId} CASCADE`;
```
**Best for:**
* Complete isolation requirements
* Teams with existing schema management tooling
* Debugging complex test failures
**Trade-offs:**
* ✅ Complete isolation
* ✅ No code changes
* ✅ Works with any ORM
* ❌ Very slow (migrations per test)
* ❌ Complex connection management
* ❌ High resource usage
## Decision Guide
### Recommended: Repository Pattern
For most teams that want scalable, parallel testing of real server-side code through browser-based tests, the repository pattern is the best approach. It provides true test-level parallelism with the same test ID isolation model as Scenarist, and the architectural benefits (clean separation, infrastructure flexibility) extend well beyond testing.
### When to Choose Sequential Execution
If you’re building a quick prototype, or you’re certain your application and test suite will remain small. Be aware that this approach doesn’t scale—you’ll need to migrate later if your test suite grows.
### When to Choose Sharding
If CI/CD time is critical but you don’t want to change your application code. Requires good infrastructure and Docker orchestration. Note that tests within each shard still run sequentially.
### When to Choose PostgreSQL RLS
If you need row-level isolation (e.g., testing multi-tenant applications) and you’re already using PostgreSQL. Accept the schema changes and complexity.
### When to Choose Schema Per Test
Rarely. Only if you need complete isolation and can tolerate very slow test execution.
## Recommended Strategy
A scalable approach for teams building comprehensive test suites:
1. **Use Scenarist for HTTP mocking** - External APIs (Stripe, Auth0, SendGrid)
2. **Use the repository pattern for database access** - True test-level parallelism with test ID isolation
3. **Test repository implementations with Testcontainers** - Verify real database behavior (queries, constraints, transactions)
This gives you the best of both worlds: fast parallel tests for most scenarios, plus real database validation where it matters.
## Next Steps
* [Repository Pattern Guide](./repository-pattern) - Test-level parallelism with clean architecture
* [Testcontainers Hybrid Guide](./testcontainers-hybrid) - Test real database behavior
* [Testcontainers Documentation](https://testcontainers.com/) - Learn container management
* [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) - Understand RLS
# Repository Pattern for Parallel Database Testing
> Use the repository pattern to achieve true test parallelism with test ID isolation—no schema changes, any ORM, fast in-memory execution
This Is One Implementation Approach
This guide shows how to use the **repository pattern** to implement test ID isolation for databases. This is the approach we recommend and use in our examples, but it’s not the only way.
The **core pattern** is: “use the same test ID to partition all data sources.” The repository pattern is one architectural approach to achieve that. See the [overview page](./index) for alternative approaches.
## Two Concepts Working Together
It’s important to distinguish between two separate concepts:
### 1. The Repository Pattern (Architectural)
The [repository pattern](https://martinfowler.com/eaaCatalog/repository.html) is a well-established architectural practice that abstracts database access behind interfaces. It’s not specific to testing—it provides [significant benefits](#beyond-testing-why-the-repository-pattern-is-good-practice) including infrastructure flexibility, better separation of concerns, and alignment with SOLID principles.
### 2. Test ID Isolation (Testing Technique)
Test ID isolation partitions data by a unique identifier per test, enabling parallel execution without interference. This is the same technique Scenarist uses for HTTP mocking.
**This guide combines both:** We use the repository pattern as the mechanism to implement test ID isolation for databases.
***
## Why Use the Repository Pattern for Test Isolation?
The repository pattern is particularly well-suited for test ID isolation because:
1. **Interface injection** — Swap production (real DB) for test (in-memory with partitioning) implementations
2. **Clean boundaries** — Database access is already abstracted, making partitioning natural
3. **No schema changes** — Isolation happens in application code, not database
4. **Fast execution** — In-memory stores are orders of magnitude faster than real databases
## How It Works
The repository pattern separates *what* your code does from *how* data is persisted. Your business logic depends on an interface (the “what”), while concrete implementations handle the database details (the “how”).
**The key insight:** You can swap implementations based on environment:
* **Production:** Real implementation that executes actual database queries (Prisma, Drizzle, TypeORM)
* **Tests:** In-memory implementation that stores data in JavaScript Maps, partitioned by test ID
This gives you the same isolation model as Scenarist’s HTTP mocking:
| Scenarist (HTTP) | Repository Pattern (Database) |
| -------------------------------------------- | -------------------------------------------- |
| `x-scenarist-test-id` header identifies test | `x-scenarist-test-id` header identifies test |
| MSW returns scenario-specific responses | Repository returns test-specific data |
| Each test gets isolated mock state | Each test gets isolated data store |
**The flow:**
1. Test sends request with `x-scenarist-test-id: abc-123`
2. Middleware extracts test ID and stores it in `AsyncLocalStorage`
3. Repository retrieves test ID and uses it as partition key
4. Data operations only affect that test’s partition
5. Parallel tests have completely isolated data stores
Because the interface is the same, your business logic doesn’t know or care which implementation is running—it just calls `userRepository.findById()`. The test isolation happens transparently.
## Working Example: Next.js App Router
See It In Action
Our **Next.js App Router example app** includes a complete implementation of this pattern with:
* Repository interface with test ID isolation
* In-memory repository for tests
* Playwright fixture that seeds repository on scenario switch
* Integration with Scenarist’s HTTP mocking
Browse the code: [`apps/nextjs-app-router-example`](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example)
Key files:
* [`lib/repositories/`](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example/lib/repositories) - Repository interface and implementations
* [`lib/container.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/lib/container.ts) - DI container with AsyncLocalStorage
* [`lib/repository-data.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/lib/repository-data.ts) - Scenario-to-seed-data mapping
* [`tests/playwright/fixtures.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/fixtures.ts) - Custom fixture with repository seeding
* [`tests/playwright/products-repo.spec.ts`](https://github.com/citypaul/scenarist/blob/main/apps/nextjs-app-router-example/tests/playwright/products-repo.spec.ts) - Tests demonstrating the pattern
This implementation extends Scenarist’s `switchScenario` to also seed database state, keeping the same test ID isolation for both HTTP mocks and direct database queries.
## Example Implementation (Express)
Adapt for Your Framework
This example uses Express with AsyncLocalStorage to demonstrate the pattern. You’ll need to adapt this for your specific framework (Next.js, Fastify, etc.) and ORM (Prisma, Drizzle, etc.). The core concepts—interface abstraction, test ID extraction, and data partitioning—apply regardless of technology choices.
Here’s a complete working example showing how test ID flows from Playwright through Express to isolated in-memory repositories:
### 1. Repository Interface
src/repositories/user-repository.ts
```typescript
export type User = {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
};
export type CreateUserInput = {
email: string;
name: string;
};
export interface UserRepository {
findById(id: string): Promise;
findByEmail(email: string): Promise;
findAll(): Promise;
create(data: CreateUserInput): Promise;
}
```
### 2. Production Implementation (Prisma)
src/repositories/prisma-user-repository.ts
```typescript
import { PrismaClient } from '@prisma/client';
import type { UserRepository, User, CreateUserInput } from './user-repository';
export class PrismaUserRepository implements UserRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: string): Promise {
return this.prisma.user.findUnique({ where: { id } });
}
async findByEmail(email: string): Promise {
return this.prisma.user.findFirst({ where: { email } });
}
async findAll(): Promise {
return this.prisma.user.findMany();
}
async create(data: CreateUserInput): Promise {
return this.prisma.user.create({ data });
}
}
```
### 3. Test Implementation with Test ID Isolation
This is where the magic happens. The test implementation partitions data by test ID:
src/repositories/in-memory-user-repository.ts
```typescript
import type { UserRepository, User, CreateUserInput } from './user-repository';
export class InMemoryUserRepository implements UserRepository {
// Map>
private store = new Map>();
private idCounter = new Map();
constructor(private getTestId: () => string) {}
private getTestStore(): Map {
const testId = this.getTestId();
if (!this.store.has(testId)) {
this.store.set(testId, new Map());
this.idCounter.set(testId, 0);
}
return this.store.get(testId)!;
}
private generateId(): string {
const testId = this.getTestId();
const counter = (this.idCounter.get(testId) ?? 0) + 1;
this.idCounter.set(testId, counter);
return `user-${counter}`;
}
async findById(id: string): Promise {
return this.getTestStore().get(id) ?? null;
}
async findByEmail(email: string): Promise {
for (const user of this.getTestStore().values()) {
if (user.email === email) return user;
}
return null;
}
async findAll(): Promise {
return Array.from(this.getTestStore().values());
}
async create(data: CreateUserInput): Promise {
const user: User = {
id: this.generateId(),
...data,
createdAt: new Date(),
updatedAt: new Date(),
};
this.getTestStore().set(user.id, user);
return user;
}
}
```
### 4. Dependency Injection Container
src/container.ts
```typescript
import { AsyncLocalStorage } from 'node:async_hooks';
import { PrismaClient } from '@prisma/client';
import type { UserRepository } from './repositories/user-repository';
import { PrismaUserRepository } from './repositories/prisma-user-repository';
import { InMemoryUserRepository } from './repositories/in-memory-user-repository';
// AsyncLocalStorage carries test ID through async request lifecycle
const testIdStorage = new AsyncLocalStorage();
export const getTestId = (): string => {
return testIdStorage.getStore() ?? 'default-test';
};
export const runWithTestId = (testId: string, fn: () => T): T => {
return testIdStorage.run(testId, fn);
};
// Create repositories based on environment
const prisma = new PrismaClient();
export const createRepositories = (): { userRepository: UserRepository } => {
const isTest = process.env.NODE_ENV === 'test';
const userRepository: UserRepository = isTest
? new InMemoryUserRepository(getTestId)
: new PrismaUserRepository(prisma);
return { userRepository };
};
```
### 5. Express Middleware to Extract Test ID
src/middleware/test-id-middleware.ts
```typescript
import type { Request, Response, NextFunction } from 'express';
import { runWithTestId } from '../container';
export const testIdMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
const testId = (req.headers['x-scenarist-test-id'] as string) ?? 'default-test';
runWithTestId(testId, () => {
next();
});
};
```
### 6. Express App Setup
src/app.ts
```typescript
import express from 'express';
import { testIdMiddleware } from './middleware/test-id-middleware';
import { createRepositories } from './container';
const app = express();
const { userRepository } = createRepositories();
app.use(express.json());
app.use(testIdMiddleware);
app.get('/users', async (req, res) => {
const users = await userRepository.findAll();
res.json({ users });
});
app.post('/users', async (req, res) => {
const { email, name } = req.body;
const existing = await userRepository.findByEmail(email);
if (existing) {
return res.status(400).json({ error: 'Email already registered' });
}
const user = await userRepository.create({ email, name });
res.status(201).json({ user });
});
export { app };
```
### 7. Playwright Tests
tests/user-registration.spec.ts
```typescript
import { test, expect } from './fixtures'; // withScenarios(scenarios)
test.describe('User Registration', () => {
test('should register new user', async ({ page, switchScenario }) => {
// Scenarist mocks external email API
await switchScenario(page, 'emailSuccess');
// Repository pattern isolates database by test ID
// (test ID automatically flows from x-scenarist-test-id header set by Scenarist)
await page.goto('/register');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="name"]', 'Test User');
await page.click('button[type="submit"]');
await expect(page.getByText('Welcome, Test User')).toBeVisible();
});
test('should show error for duplicate email', async ({ page, switchScenario }) => {
await switchScenario(page, 'emailSuccess');
// First registration
await page.goto('/register');
await page.fill('[name="email"]', 'duplicate@example.com');
await page.fill('[name="name"]', 'First User');
await page.click('button[type="submit"]');
// Second registration with same email
await page.goto('/register');
await page.fill('[name="email"]', 'duplicate@example.com');
await page.fill('[name="name"]', 'Second User');
await page.click('button[type="submit"]');
await expect(page.getByText('Email already registered')).toBeVisible();
});
});
// These tests run in PARALLEL with full isolation:
// - Test A (x-scenarist-test-id: abc-123) → store['abc-123']
// - Test B (x-scenarist-test-id: def-456) → store['def-456']
// - Same email in different tests → no conflict
```
## How the Test ID Flows
1. **Playwright** sends request with `x-scenarist-test-id` header (set automatically by Scenarist)
2. **Express middleware** extracts the test ID and stores it in `AsyncLocalStorage`
3. **Repository** calls `getTestId()` to retrieve the test ID from `AsyncLocalStorage`
4. **Data partitioning** ensures each test ID maps to its own isolated data store
This is the same pattern Scenarist uses internally—`AsyncLocalStorage` carries context through the async request lifecycle.
## Why This Approach Excels
### True Parallelism with Full Isolation
Each test gets its own isolated data store, keyed by test ID. Tests run concurrently without interference—the same isolation model as Scenarist’s HTTP mocking.
### Fast Execution
In-memory repositories are orders of magnitude faster than real databases:
* No network round-trips
* No disk I/O
* No connection pool overhead
* No query parsing/planning
### Infrastructure Flexibility
The repository pattern decouples your application from specific persistence technologies. Today you’re using PostgreSQL with Prisma—tomorrow you might migrate to:
* A different database (MySQL, MongoDB)
* A different ORM (Drizzle, TypeORM)
* A different architecture (microservices, event sourcing)
Your business logic remains unchanged because it depends on interfaces, not implementations.
### ORM Agnostic
The interface is your contract. Swap between Prisma, Drizzle, TypeORM, Knex, or raw SQL without changing tests:
```typescript
// All these implement the same interface
const prismaRepo = new PrismaUserRepository(prisma);
const drizzleRepo = new DrizzleUserRepository(db);
const knexRepo = new KnexUserRepository(knex);
const testRepo = new InMemoryUserRepository(getTestId);
```
### No Schema Changes
Unlike PostgreSQL RLS or test ID columns, the repository pattern requires no database schema modifications. Your production database remains clean.
## Trade-offs to Consider
### Requires Abstracting All Database Access
Every database call must go through a repository interface. For existing codebases, this can be significant refactoring:
```typescript
// Before: Direct ORM calls scattered throughout code
const user = await prisma.user.findUnique({ where: { id } });
// After: All access through repositories
const user = await userRepository.findById(id);
```
### Two Implementations to Maintain
You must maintain both production and test implementations. When the interface changes, both must be updated:
```typescript
// Add new method to interface
interface UserRepository {
// ... existing methods
findByRole(role: string): Promise; // NEW
}
// Must implement in BOTH:
// - PrismaUserRepository
// - InMemoryUserRepository
```
### Doesn’t Test Real Database Behavior
The in-memory implementation doesn’t execute actual SQL. Potential issues you might miss:
* Query performance problems
* Database constraints (unique, foreign keys)
* Transaction isolation issues
* ORM-specific edge cases
Mitigation Strategy
**Test the repository implementation itself in isolation.** Create a separate test suite that runs your production repository (e.g., `PrismaUserRepository`) against a real database using [Testcontainers](./testcontainers-hybrid):
tests/repositories/prisma-user-repository.test.ts
```typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
import { PrismaUserRepository } from '../../src/repositories/prisma-user-repository';
const createTestContext = async () => {
const container = await new PostgreSqlContainer().start();
const prisma = new PrismaClient({
datasources: { db: { url: container.getConnectionUri() } }
});
await prisma.$executeRaw`CREATE TABLE users (...)`; // Run migrations
const repository = new PrismaUserRepository(prisma);
return {
container,
prisma,
repository,
cleanup: async () => {
await prisma.$disconnect();
await container.stop();
},
};
};
describe('PrismaUserRepository', () => {
it('should enforce unique email constraint', async () => {
const { repository, cleanup } = await createTestContext();
try {
await repository.create({ email: 'test@example.com', name: 'User 1' });
await expect(repository.create({ email: 'test@example.com', name: 'User 2' }))
.rejects.toThrow(/unique constraint/i);
} finally {
await cleanup();
}
});
});
```
This gives you:
* **Fast parallel tests** for business logic (in-memory repositories)
* **Confidence in SQL correctness** (repository implementation tests against real database)
* **Clear separation** between “does my business logic work?” and “does my SQL work?”
## When to Choose This Approach
**The repository pattern is worth adopting if you value:**
* **Test parallelism** - Run hundreds of database tests concurrently
* **Infrastructure flexibility** - Ability to change databases, ORMs, or architectures without rewriting business logic
* **Fast feedback loops** - In-memory tests complete in milliseconds
* **Clean architecture** - Better separation of concerns between domain logic and persistence
* **Long-term maintainability** - Codebase that’s easier to understand, test, and evolve
**The investment required:**
* **Existing codebases:** Refactoring direct ORM calls to use repositories takes time. Start with new features and gradually migrate existing code.
* **Two implementations:** You maintain production and test repositories. TypeScript ensures they stay in sync.
* **Learning curve:** If your team is new to dependency injection, there’s initial learning investment.
**Consider simpler alternatives if:**
* You have a small test suite where sequential execution is fast enough
* You need to test specific database behavior (query performance, constraints, transactions)
* The refactoring cost outweighs the parallelism benefit for your current project timeline
Incremental Adoption
You don’t have to refactor everything at once. Start by adding repositories for new features. As you see the benefits—faster tests, cleaner code, easier mocking—you can incrementally migrate existing code.
## Beyond Testing: Why the Repository Pattern is Good Practice
The repository pattern is widely adopted because it follows fundamental software design principles:
### Separation of Concerns
Your business logic focuses on *what* to do, not *how* to persist data. The `UserService` doesn’t know or care whether data comes from PostgreSQL, MongoDB, or an API—it just calls `userRepository.findById()`.
### Dependency Inversion
High-level business logic depends on abstractions (interfaces), not concrete implementations. This is the “D” in SOLID principles. Your domain code depends on `UserRepository` (interface), not `PrismaUserRepository` (implementation).
### Single Responsibility
Each class has one job:
* `PrismaUserRepository` → translates domain operations to Prisma queries
* `UserService` → implements business rules
* `UserController` → handles HTTP requests
### Open/Closed Principle
Add new persistence strategies without modifying existing code. Need to cache frequently accessed data? Create a `CachedUserRepository` that wraps the real one. Need to log all database access? Create a `LoggingUserRepository` decorator.
### Type Safety
TypeScript interfaces ensure your production and test implementations have matching signatures. If you add a method to the interface, both implementations must implement it.
## Further Reading
* [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) - Martin Fowler’s original definition
* [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) - Alistair Cockburn’s ports and adapters
* [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) - Uncle Bob’s architectural principles
* [Testing Without Mocks](https://www.jamesshore.com/v2/projects/testing-without-mocks/testing-without-mocks) - James Shore on “Nullable Infrastructure”
* [Domain-Driven Design Reference](https://www.domainlanguage.com/ddd/reference/) - Eric Evans on repositories in DDD
## Next Steps
* [Parallelism Options Overview](./parallelism-options) - Compare all approaches
* [Testcontainers Hybrid](./testcontainers-hybrid) - Test real database behavior when needed
* [Scenarist Getting Started](/getting-started/installation/) - Set up HTTP mocking
# Testcontainers + Scenarist Hybrid
> Test database-heavy Next.js apps without code changes using Testcontainers and Scenarist together
This guide shows how to test Next.js apps that use direct database access by combining [Testcontainers](https://testcontainers.com/) for real database testing with Scenarist for mocking external APIs. **No code changes required.**
## What is Testcontainers?
[Testcontainers](https://testcontainers.com/) is a library that provides lightweight, throwaway Docker containers for testing. It spins up real database instances (PostgreSQL, MySQL, MongoDB) as Docker containers during your tests, pre-configured with your schema and seed data. Each test suite gets a fresh database instance that’s automatically destroyed after tests complete. This enables testing against actual database queries, migrations, transactions, and constraints without maintaining a shared test database or complex cleanup scripts.
**Learn more:** [Testcontainers Documentation](https://testcontainers.com/getting-started/)
## Overview
The hybrid approach uses two complementary tools:
* **Testcontainers:** Spins up real Docker database containers with seeded scenarios
* **Scenarist:** Mocks external API dependencies (Stripe, Auth0, SendGrid)
Together, you get:
* ✅ Real database queries and migrations tested
* ✅ External APIs mocked for different scenarios
* ✅ No code changes to your application
* ✅ Realistic integration testing
**Best for:** Teams that cannot/won’t refactor to add API routes
## Architecture
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// Server Component (unchanged - no API routes needed!)
export default async function CheckoutPage() {
// Database call - testcontainer provides real PostgreSQL with seeded data
const user = await db.user.findUnique({ where: { id: userId } });
// External API - Scenarist intercepts and mocks
const headersList = await headers();
const response = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: getScenaristHeadersFromReadonlyHeaders(headersList),
body: JSON.stringify({ amount: user.cartTotal }),
});
if (!response.ok) {
return ;
}
const payment = await response.json();
return ;
}
```
**What each tool handles:**
* **Testcontainers:** User data, products, cart items (real PostgreSQL)
* **Scenarist:** Stripe payment responses, Auth0 tokens, SendGrid emails (mocked)
## Installation
Install Testcontainers for your database:
```bash
# For PostgreSQL
npm install -D @testcontainers/postgresql
# For MySQL
npm install -D @testcontainers/mysql
# For MongoDB
npm install -D @testcontainers/mongodb
```
Scenarist is already installed from [Getting Started](/getting-started/installation/).
## Step-by-Step Implementation
### Step 1: Create Database Seeding Functions
Create functions to seed test data into database containers:
tests/helpers/seed-database.ts
```typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
export async function seedPremiumUser(container: StartedPostgreSqlContainer) {
const connectionString = container.getConnectionUrl();
const prisma = new PrismaClient({
datasources: { db: { url: connectionString } },
});
await prisma.user.create({
data: {
id: 'user-premium-123',
email: 'premium@example.com',
tier: 'premium',
cartTotal: 500,
},
});
await prisma.$disconnect();
}
export async function seedStandardUser(container: StartedPostgreSqlContainer) {
const connectionString = container.getConnectionUrl();
const prisma = new PrismaClient({
datasources: { db: { url: connectionString } },
});
await prisma.user.create({
data: {
id: 'user-standard-456',
email: 'standard@example.com',
tier: 'standard',
cartTotal: 200,
},
});
await prisma.$disconnect();
}
```
### Step 2: Set Up Container in Tests
Create a container setup function and use it in tests:
tests/helpers/setup-container.ts
```typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { execSync } from 'child_process';
export async function createTestContainer(): Promise {
// Start PostgreSQL container
const container = await new PostgreSqlContainer('postgres:16').start();
// Set DATABASE_URL for Next.js app
process.env.DATABASE_URL = container.getConnectionUrl();
// Run migrations
execSync('npx prisma migrate deploy', { env: process.env });
return container;
}
// tests/checkout.spec.ts
import { test, expect } from './fixtures'; // withScenarios(scenarios)
import { createTestContainer } from './helpers/setup-container';
import { seedPremiumUser, seedStandardUser } from './helpers/seed-database';
test.describe('Checkout flow', () => {
const setup = test.beforeAll(async () => {
const container = await createTestContainer();
return { container };
});
test.afterAll(async () => {
const { container } = await setup;
await container.stop();
});
// Tests use container from setup
});
```
### Step 3: Write Tests with Database + API Mocking
Combine database seeding with Scenarist scenario switching:
```typescript
test.describe('Checkout flow', () => {
const setup = test.beforeAll(async () => {
const container = await createTestContainer();
return { container };
});
test.afterAll(async () => {
const { container } = await setup;
await container.stop();
});
test('premium user with successful payment', async ({ page, switchScenario }) => {
const { container } = await setup;
// Testcontainer: Seed premium user in real database
await seedPremiumUser(container);
// Scenarist: Mock Stripe success
await switchScenario(page, 'stripeSuccess');
await page.goto('/checkout');
// Test sees:
// - Premium user from real database (tier='premium', cartTotal=500)
// - Successful payment from Scenarist mock
await expect(page.getByText('Payment successful')).toBeVisible();
await expect(page.getByText('£500.00')).toBeVisible();
});
test('standard user with declined payment', async ({ page, switchScenario }) => {
const { container } = await setup;
// Testcontainer: Seed standard user in real database
await seedStandardUser(container);
// Scenarist: Mock Stripe decline
await switchScenario(page, 'stripeDeclined');
await page.goto('/checkout');
// Test sees:
// - Standard user from real database (tier='standard', cartTotal=200)
// - Declined payment from Scenarist mock
await expect(page.getByText('Payment declined')).toBeVisible();
await expect(page.getByText('£200.00')).toBeVisible();
});
});
```
### Step 4: Define Scenarist Scenarios
Mock external APIs (Stripe, Auth0, SendGrid):
lib/scenarios.ts
```typescript
import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app';
export const stripeSuccessScenario: ScenaristScenario = {
id: 'stripeSuccess',
name: 'Stripe Payment Success',
description: 'Stripe charges succeed',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
id: 'ch_123',
status: 'succeeded',
amount: 50000, // $500.00
},
},
},
],
};
export const stripeDeclinedScenario: ScenaristScenario = {
id: 'stripeDeclined',
name: 'Stripe Payment Declined',
description: 'Stripe declines the card',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 402,
body: {
error: {
code: 'card_declined',
message: 'Your card was declined',
},
},
},
},
],
};
```
## Complete Example
Here’s a full test showing database setup, seeding, and API mocking using functional patterns:
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
import { scenarios } from '../lib/scenarios';
// Test ID header is set automatically; scenaristEndpoint defaults to '/api/__scenario__'
const test = withScenarios(scenarios);
test.describe('Checkout flow with database', () => {
const setup = test.beforeAll(async () => {
// Start database container
const container = await new PostgreSqlContainer('postgres:16')
.withDatabase('testdb')
.withUsername('testuser')
.withPassword('testpass')
.start();
// Configure Next.js to use container database
process.env.DATABASE_URL = container.getConnectionUrl();
// Initialize Prisma client
const prisma = new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL } },
});
// Run migrations
execSync('npx prisma migrate deploy', { env: process.env });
return { container, prisma };
});
test.afterAll(async () => {
const { prisma, container } = await setup;
await prisma.$disconnect();
await container.stop();
});
test.beforeEach(async () => {
const { prisma } = await setup;
// Clean database before each test
await prisma.user.deleteMany();
await prisma.order.deleteMany();
});
test('premium user can checkout with valid payment', async ({ page, switchScenario }) => {
const { prisma } = await setup;
// Seed database: premium user
await prisma.user.create({
data: {
id: 'user-123',
email: 'premium@example.com',
tier: 'premium',
cart: {
create: {
items: [
{ productId: 'prod-1', quantity: 2, price: 150 },
{ productId: 'prod-2', quantity: 1, price: 200 },
],
},
},
},
});
// Mock external API: Stripe success
await switchScenario(page, 'stripeSuccess');
// Test flow
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
// Fill payment form
await page.getByLabel('Card Number').fill('4242424242424242');
await page.getByLabel('Expiry').fill('12/25');
await page.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Pay £500' }).click();
// Verify success
await expect(page.getByText('Payment successful')).toBeVisible();
await expect(page.getByText('Order #')).toBeVisible();
// Verify database updated
const order = await prisma.order.findFirst({
where: { userId: 'user-123' },
});
expect(order?.status).toBe('completed');
});
});
```
## When to Use This Approach
**✅ Use when:**
* You want to test actual database queries and migrations
* You have external API dependencies to mock
* You cannot/don’t want to add API routes
* Container startup overhead is acceptable for your workflow
* You need realistic integration testing
**❌ Don’t use when:**
* You don’t have Docker available (CI/CD constraint)
* Test speed is critical (hundreds of tests)
* Parallel test execution is important
* Your app only uses external HTTP APIs (use Scenarist directly)
## Trade-offs
### Advantages
**✅ No code changes required**
* Server Components call database directly (as designed)
* No API route layer needed
* Production code unchanged
**✅ Test real database behavior**
* Actual SQL queries executed
* Database constraints validated
* Migrations tested
* Transactions work correctly
**✅ Realistic integration testing**
* Database + external APIs together
* Closest to production environment
* Catches integration bugs
### Disadvantages
**⚠️ Slower tests**
* Container startup overhead
* Database seeding per test
* Best suited for focused test suites rather than hundreds of tests
**⚠️ Docker required**
* CI/CD must support Docker
* Developers need Docker installed
* More complex local setup
**⚠️ Database seeding complexity**
* Must maintain seed data scripts
* Schema changes break seeds
* Cleanup between tests required
**⚠️ Sequential test execution required**
* Database state is shared (unlike HTTP mocks which are stateless)
* Scenarist isolates via test ID in HTTP headers, but databases have no equivalent mechanism
* Parallel tests would corrupt each other’s data without application code changes
* For parallelism, you’d need multiple containers (resource-intensive) or database-level isolation (schemas per test)
Need Parallelism?
If sequential execution is a blocker for your team, see [Parallelism Options](./parallelism-options) for a detailed comparison of alternatives (multiple containers, PostgreSQL RLS, schema-per-test) and their trade-offs.
## Performance Optimization Tips
**Reuse containers across tests:**
```typescript
test.beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16').start();
});
test.beforeEach(async () => {
// Only clean data, don't restart container
await prisma.user.deleteMany();
});
test.afterAll(async () => {
// Stop container once at end
await container.stop();
});
```
**Use database transactions for cleanup:**
```typescript
test.beforeEach(async () => {
await prisma.$transaction([
prisma.order.deleteMany(),
prisma.user.deleteMany(),
]);
});
```
**Cache container image:**
```bash
# Pull image once before running tests
docker pull postgres:16
```
## Next Steps
* [Testcontainers Documentation](https://testcontainers.com/) - Learn more about Testcontainers
* [Next.js Testing Overview](../) - Back to database testing overview
* [Next.js App Router Getting Started](/frameworks/nextjs-app-router/getting-started/) - Set up Scenarist for App Router
# Endpoint APIs
> Complete reference for scenario switching, status, and debug state endpoints
Scenarist provides HTTP endpoints for scenario management and debugging:
* `POST /__scenario__` — Switch the active scenario for a test
* `GET /__scenario__` — Check which scenario is active
* `GET /__scenarist__/state` — Retrieve test state for debugging
These endpoints are framework-agnostic and work consistently across Express, Next.js, and all supported frameworks.
## POST /**scenario** - Switch Scenario
Switch the active scenario for a test ID.
### Request
**Method:** `POST`
**URL:** `/__scenario__` (configurable via `endpoints.setScenario`)
**Headers:**
```plaintext
Content-Type: application/json
x-scenarist-test-id:
```
**Body:**
```typescript
{
scenario: string; // Required: scenario ID to activate
}
```
### Responses
**Success (200):**
```typescript
{
success: true;
testId: string; // The test ID used for routing
scenarioId: string; // The activated scenario ID
}
```
**Example:**
```json
{
"success": true,
"testId": "test-abc123",
"scenarioId": "payment-error"
}
```
**Validation Error (400):**
```typescript
{
error: string; // Error message
details?: unknown; // Validation error details (Zod errors)
}
```
**Example:**
```json
{
"error": "Invalid request body",
"details": [
{
"expected": "string",
"code": "invalid_type",
"path": ["scenario"],
"message": "Invalid input: expected string, received undefined"
}
]
}
```
**Scenario Not Found (400):**
```typescript
{
error: string; // "Scenario 'xyz' not found. Did you forget to register it?"
}
```
**Internal Server Error (500):**
```typescript
{
error: string; // "Internal server error"
}
```
### Example Usage
**With [Playwright helpers](/testing/playwright-integration/):**
```typescript
import { test } from './fixtures'; // withScenarios(scenarios)
test('my test', async ({ page, switchScenario }) => {
// Helper handles POST request automatically
await switchScenario(page, 'payment-error');
// Sends POST to scenaristEndpoint (default '/api/__scenario__') with auto-generated test ID
});
```
**Manual (curl):**
```bash
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-H "x-scenarist-test-id: test-123" \
-d '{"scenario": "payment-error"}'
```
**Manual (fetch):**
```typescript
const response = await fetch('http://localhost:3000/__scenario__', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-scenarist-test-id': 'test-123',
},
body: JSON.stringify({ scenario: 'payment-error' }),
});
const result = await response.json();
// { success: true, testId: 'test-123', scenarioId: 'payment-error' }
```
**With supertest:**
```typescript
import request from 'supertest';
import app from './app';
const response = await request(app)
.post('/__scenario__')
.set('x-scenarist-test-id', 'test-123')
.send({ scenario: 'payment-error' });
expect(response.status).toBe(200);
expect(response.body.scenarioId).toBe('payment-error');
```
## GET /**scenario** - Get Active Scenario
Retrieve the currently active scenario for a test ID.
### Request
**Method:** `GET`
**URL:** `/__scenario__` (configurable via `endpoints.getScenario`)
**Headers:**
```plaintext
x-scenarist-test-id:
```
**No body required.**
### Responses
**Success (200):**
```typescript
{
testId: string; // The test ID
scenarioId: string; // The active scenario ID
scenarioName?: string; // The scenario's human-readable name (if found)
}
```
**Example:**
```json
{
"testId": "test-abc123",
"scenarioId": "payment-error",
"scenarioName": "Payment Error Scenarios"
}
```
**No Active Scenario (404):**
```typescript
{
error: string; // "No active scenario for this test ID"
testId: string; // The test ID that was queried
}
```
**Example:**
```json
{
"error": "No active scenario for this test ID",
"testId": "test-xyz789"
}
```
### Example Usage
**With fetch:**
```typescript
const response = await fetch('http://localhost:3000/__scenario__', {
headers: {
'x-scenarist-test-id': 'test-123',
},
});
const result = await response.json();
// { testId: 'test-123', scenarioId: 'payment-error', scenarioName: '...' }
```
**With curl:**
```bash
curl http://localhost:3000/__scenario__ \
-H "x-scenarist-test-id: test-123"
```
**With supertest:**
```typescript
const response = await request(app)
.get('/__scenario__')
.set('x-scenarist-test-id', 'test-123');
expect(response.status).toBe(200);
expect(response.body.scenarioId).toBe('payment-error');
```
## GET /**scenarist**/state - Debug State
Retrieve the current test state for debugging. This endpoint exposes state set by `afterResponse.setState()` in your mocks, useful for debugging async workflows and verifying state transitions.
### Request
**Method:** `GET`
**URL:** `/__scenarist__/state` (configurable via `endpoints.getState`)
**Headers:**
```plaintext
x-scenarist-test-id:
```
**No body required.**
### Response
**Success (200):**
```typescript
{
testId: string; // The test ID
state: Record; // Current state object (empty {} if no state)
}
```
**Example:**
```json
{
"testId": "test-abc123",
"state": {
"cart.items": 2,
"cart.total": 59.98,
"user.tier": "premium"
}
}
```
### Example Usage
**With [Playwright helpers](/testing/playwright-integration/#debugging-state) (recommended):**
```typescript
import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'checkout');
await page.goto('/cart');
await page.click('#add-item');
// Fetch current state
const state = await debugState(page);
console.log('Cart state:', state);
// → { 'cart.items': 1, 'cart.total': 29.99 }
expect(state['cart.items']).toBe(1);
});
```
**Manual (fetch):**
```typescript
const response = await fetch('http://localhost:3000/__scenarist__/state', {
headers: {
'x-scenarist-test-id': 'test-123',
},
});
const { state } = await response.json();
// { 'cart.items': 1, 'cart.total': 29.99 }
```
**With curl:**
```bash
curl http://localhost:3000/__scenarist__/state \
-H "x-scenarist-test-id: test-123"
```
### When to Use
The debug state endpoint is useful for:
* **Debugging test failures**: See what state was set by mocks during a test run
* **Async workflows**: Wait for state to reach a condition before asserting (use `waitForDebugState`)
* **State-aware mocking verification**: Confirm that `afterResponse.setState()` is capturing expected values
Playwright Helpers
For the best developer experience, use the `debugState` and `waitForDebugState` fixtures from `@scenarist/playwright-helpers`. They handle test ID management automatically.
```typescript
// Wait for async state
const state = await waitForDebugState(
page,
(s) => s['approval.status'] === 'approved',
{ timeout: 10000 }
);
```
**[→ Full Playwright debug helpers guide](/testing/playwright-integration/#debugging-state)**
## Test ID Extraction
All endpoints extract the test ID from the request header:
```typescript
// The header name is standardized to 'x-scenarist-test-id'
// Use SCENARIST_TEST_ID_HEADER constant from your adapter package
import { SCENARIST_TEST_ID_HEADER } from '@scenarist/express-adapter';
```
**Test ID behavior:**
* **Header present:** Uses value from header (e.g., `'test-abc123'`)
* **Header missing:** Uses default test ID (e.g., `'default-test'`)
* **Empty string:** App Router treats it as missing and uses the default; Express and Pages Router use the empty string as-is
**Example:**
```typescript
// Request with header
Headers: { 'x-scenarist-test-id': 'test-123' }
// Uses test ID: 'test-123'
// Request without header
// Uses test ID: 'default-test'
```
## Request Validation
The `POST /__scenario__` endpoint validates the request body using Zod:
```typescript
import { z } from 'zod';
const ScenarioRequestSchema = z.object({
scenario: z.string().min(1), // Required, non-empty string
});
```
**Valid requests:**
```json
{ "scenario": "payment-error" }
```
**Invalid requests:**
```json
{} // Missing 'scenario'
{ "scenario": "" } // Empty string
{ "scenario": 123 } // Wrong type
```
## Framework-Specific Implementations
While the API is consistent across frameworks, the implementation differs:
### Express
```typescript
// Express endpoint handler
export const handleSetScenario = (manager: ScenarioManager, config: ScenaristConfig) => {
return (req: Request, res: Response): void => {
const { scenario } = ScenarioRequestSchema.parse(req.body);
const context = new ExpressRequestContext(req, config);
const testId = context.getTestId();
const result = manager.switchScenario(testId, scenario);
if (!result.success) {
res.status(400).json({ error: result.error.message });
return;
}
res.status(200).json({
success: true,
testId,
scenarioId: scenario,
});
};
};
```
### Next.js App Router
```typescript
// Next.js App Router endpoint handler
export const POST = async (req: Request): Promise => {
const body = await req.json();
const context = new AppRequestContext(req, config);
const result = await handlePostLogic(body, context, manager);
if (!result.success) {
return Response.json(
{ error: result.error, details: result.details },
{ status: result.status }
);
}
return Response.json(
{
success: true,
testId: result.testId,
scenarioId: result.scenarioId,
},
{ status: 200 }
);
};
```
### Next.js Pages Router
```typescript
// Next.js Pages Router endpoint handler
export default async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const { scenario } = ScenarioRequestSchema.parse(req.body);
const context = new PagesRequestContext(req, config);
const testId = context.getTestId();
const result = manager.switchScenario(testId, scenario);
if (!result.success) {
return res.status(400).json({ error: result.error.message });
}
return res.status(200).json({
success: true,
testId,
scenarioId: scenario,
});
}
// GET handler...
};
```
## Configuration
Endpoint paths are configurable:
```typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
// Customize endpoint paths
endpoints: {
setScenario: '/__scenario__', // POST endpoint (default)
getScenario: '/__scenario__', // GET endpoint (default)
// Or customize:
// setScenario: '/__test/switch',
// getScenario: '/__test/status',
},
});
```
**Why customize endpoint paths?**
* Security: Make the endpoints harder to find if a production build ever includes them
* Compatibility: Avoid conflicts with existing routes
* Convention: Match your team’s naming standards
**Note:** The test ID header is standardized to `'x-scenarist-test-id'` and is not configurable. Use the `SCENARIST_TEST_ID_HEADER` constant from your adapter package.
## Scenario Manager Coordination
Both endpoints delegate to `ScenarioManager`:
```typescript
// POST /__scenario__ flow
const result = manager.switchScenario(testId, scenarioId);
// 1. Looks up scenario definition by ID
// 2. Returns error if not found
// 3. Stores active scenario for test ID
// 4. Resets sequences and state for test ID
// 5. Returns success
// GET /__scenario__ flow
const activeScenario = manager.getActiveScenario(testId);
// 1. Retrieves active scenario for test ID
// 2. Returns undefined if none set
const scenarioDefinition = manager.getScenarioById(activeScenario.scenarioId);
// 3. Looks up full definition for metadata
```
## State and Sequence Reset
When switching scenarios, state and sequences are reset:
```typescript
manager.switchScenario(testId, scenarioId);
// Internally calls:
// - sequenceTracker.reset(testId) // All sequence positions cleared
// - stateManager.reset(testId) // All captured state cleared
```
**Why reset?**
* Clean slate for new scenario
* Prevents state bleeding between scenarios
* Ensures idempotent test execution
**Example:**
```typescript
// Test 1
await switchScenario(page, 'shopping-cart');
// Add items, state captured: { cartItems: ['item-1', 'item-2'] }
// Switch to error scenario
await switchScenario(page, 'payment-error');
// State reset: { } (empty)
// Sequences reset: all positions → 0
```
## Security Considerations
**Production Safety:**
* When `enabled: false`, `createScenarist()` returns `undefined`, so no endpoints are registered (Express responds 404)
* When the `production` export condition is resolved, the adapter’s `createScenarist()` resolves to a stub that returns `undefined`. Next.js production builds resolve it automatically; Express needs a bundler option such as esbuild’s `--conditions=production` or `node --conditions=production`. See [Production Safety](/concepts/production-safety/)
* Express also returns `undefined` when `NODE_ENV` is `production`, even with `enabled: true`, so unbundled apps started without the condition do not mount the endpoints
* For defense in depth, also consider:
* Custom endpoint paths (non-standard)
* Network-level blocking (firewall)
* Environment-based enablement (never `true` in production)
**Test Isolation:**
* Each test ID is isolated
* No cross-test interference
* Test IDs should be unique (use UUIDs)
**Header Validation:**
* Test ID read from the `x-scenarist-test-id` header
* Missing header → uses default test ID
* Empty header → uses default test ID (App Router); used as-is (Express, Pages Router)
## Error Handling
All endpoints return structured errors:
```typescript
// Validation error
{
error: 'Invalid request body',
details: [/* Zod validation errors */]
}
// Scenario not found
{
error: "Scenario 'xyz' not found. Did you forget to register it?"
}
// No active scenario
{
error: 'No active scenario for this test ID',
testId: 'test-123'
}
// Internal error
{
error: 'Internal server error'
}
```
**Best practice:** Always check response status before parsing body.
## Complete Example
Full workflow with both endpoints:
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from './scenarios';
export const testWithScenarios = withScenarios(scenarios);
testWithScenarios('payment flow', async ({ page, switchScenario }) => {
// 1. Switch to payment success scenario
const testId = await switchScenario(page, 'payment-success');
// POST /__scenario__ called with:
// { scenario: 'payment-success' }
// Response: { success: true, testId: 'test-abc123', scenarioId: 'payment-success' }
// 2. Verify active scenario (optional)
const response = await page.request.get('http://localhost:3000/__scenario__', {
headers: { 'x-scenarist-test-id': testId },
});
const activeScenario = await response.json();
// { testId: 'test-abc123', scenarioId: 'payment-success', scenarioName: '...' }
// 3. Test with success scenario
await page.goto('/checkout');
await page.click('button:has-text("Pay")');
await expect(page.getByText('Payment successful')).toBeVisible();
// 4. Switch to error scenario
await switchScenario(page, 'payment-error');
// POST /__scenario__ called with:
// { scenario: 'payment-error' }
// Sequences and state reset
// 5. Test with error scenario
await page.goto('/checkout');
await page.click('button:has-text("Pay")');
await expect(page.getByText('Payment failed')).toBeVisible();
});
```
## Next Steps
* [Writing Scenarios →](/scenarios/overview/) - Learn the complete scenario structure
* [Default Scenarios →](/scenarios/default-scenarios/) - Understand override behavior
* [Ephemeral Endpoints →](/reference/ephemeral-endpoints/) - Understand test-only activation
# Ephemeral Endpoints
> Test-only activation, production safety, and test ID isolation in Scenarist
Scenarist creates **ephemeral endpoints** that only exist when Scenarist is enabled in development and test. When `enabled` is `false`, and in production, they are never registered, so they add zero overhead. This ensures scenario switching infrastructure never leaks into production.
## The `enabled` Flag
The `enabled` flag controls whether Scenarist’s testing infrastructure is active. When it is `false`, `createScenarist()` returns `undefined` in every adapter.
```typescript
import { createScenarist } from "@scenarist/express-adapter";
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Only in test environment
scenarios,
});
```
Production has its own guards that apply regardless of `enabled`: the adapter’s `production` export condition makes `createScenarist()` return `undefined` without loading any test code, and the Express adapter also returns `undefined` when `NODE_ENV` is `production`. See [Production Safety](/concepts/production-safety/).
Make sure enabled is true where your tests run
`enabled` is evaluated in the process that serves requests. For Express, `process.env.NODE_ENV === "test"` works when your test runner sets it (Vitest and Jest do). In Next.js, use `enabled: true`: Next.js replaces `process.env.NODE_ENV` with `'development'` or `'production'` at build time, so a `"test"` check is never true, and production builds are excluded by the `production` export condition.
### When `enabled: true` (Development and Test)
**Endpoints are active:**
* `POST /__scenario__` accepts scenario switch requests
* `GET /__scenario__` returns active scenario
* Both endpoints process requests normally
**Middleware extracts test IDs:**
* Reads `x-scenarist-test-id` header from requests
* Routes requests to correct scenario
* Maintains test isolation
**MSW is registered:**
* Handlers created from scenario definitions
* External API calls intercepted
* Responses returned based on active scenario
### When `enabled: false`, and in Production
`createScenarist()` returns `undefined`.
**Endpoints are not registered.** Requests to them get whatever your app returns for an unknown route:
```typescript
// Express: POST /__scenario__ → 404 Not Found (no middleware installed)
// Next.js: undefined handlers (App Router) or your fallback handler (Pages Router) → 405 Method Not Allowed
```
**No middleware is installed** (`createScenarist()` returned `undefined`, so there is no `scenarist.middleware`):
* Extracts no headers
* Adds no overhead
* Passes requests through unchanged
**MSW is not registered:**
* No handlers created
* No interception occurs
* External APIs called normally
**Result: Zero production overhead.** The testing infrastructure simply doesn’t exist.
## Production Safety Guarantees
Scenarist provides multiple layers of production safety:
### 1. Production Entry Point
The `production` export condition swaps in a stub whose `createScenarist()` returns `undefined` and imports nothing. Make sure your production build resolves it; the [Production Safety guide](/concepts/production-safety/) shows how to verify this for each framework.
The Express adapter also returns `undefined` when `NODE_ENV` is `production`, even with `enabled: true`. This protects unbundled Express apps started without `--conditions=production`; the adapter and `msw` are still loaded in that case, but nothing is mounted or intercepted.
### 2. Configuration Check
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Express: false in production; in Next.js use enabled: true
scenarios,
});
```
**Best practice:** Derive `enabled` from the environment so it is never `true` in production.
### 3. Endpoint Availability
As an extra precaution, the endpoints can be moved to non-standard paths:
```typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
endpoints: {
setScenario: "/__internal_test_scenario_switch__", // Obscure path
getScenario: "/__internal_test_scenario_status__",
},
});
```
### 4. Middleware Safety
The middleware only exists when `createScenarist()` returns an instance:
```typescript
// Express example
if (scenarist) {
app.use(scenarist.middleware);
}
// When enabled is false, or in production, scenarist is undefined
// - Middleware never installed
// - No header extraction
// - No performance impact
```
### 5. MSW Registration
MSW handlers are only registered when you call `scenarist.start()`, so guard the call:
```typescript
// Next.js example
if (scenarist) {
scenarist.start(); // Registers MSW handlers
}
// When enabled is false, or in production: handlers never registered
```
## Test ID Isolation
Each test gets a unique test ID that routes requests to the correct scenario:
### How Test IDs Work
```typescript
// Test 1
test("handles success", async ({ page, switchScenario }) => {
const testId = await switchScenario(page, "success");
// testId = 'test-abc123' (auto-generated UUID)
await page.goto("/api/payment");
// Request includes header: x-scenarist-test-id: test-abc123
// Scenarist routes to 'success' scenario for this test ID
});
// Test 2 (runs in parallel)
test("handles error", async ({ page, switchScenario }) => {
const testId = await switchScenario(page, "error");
// testId = 'test-def456' (different UUID)
await page.goto("/api/payment");
// Request includes header: x-scenarist-test-id: test-def456
// Scenarist routes to 'error' scenario for this test ID
});
```
**Tests run in parallel without interference** because each has its own test ID and scenario.
### Test ID Lifecycle
1. **Test starts**: No test ID or scenario set
2. **`switchScenario()` called**: Generates unique test ID (UUID)
3. **Scenario activated**: Test ID → Scenario mapping stored
4. **Request made**: Test ID header included automatically
5. **Scenario selected**: Based on test ID from header
6. **Response returned**: From active scenario for that test ID
7. **Test ends**: The test ID is never reused; its scenario mapping stays in memory until the server restarts
### Test ID Header
The test ID header is standardized to `'x-scenarist-test-id'`. Use the `SCENARIST_TEST_ID_HEADER` constant from your adapter package:
```typescript
import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";
// SCENARIST_TEST_ID_HEADER === 'x-scenarist-test-id'
```
**All requests must include this header** for scenario routing to work.
### Automatic Test ID Propagation
[Playwright helpers](/testing/playwright-integration/) automatically include the test ID header:
```typescript
// tests/fixtures.ts - Set up once
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "./scenarios";
export const test = withScenarios(scenarios);
export { expect };
// tests/my-test.spec.ts - Use in tests
import { test, expect } from "./fixtures"; // ✅ Import from fixtures
test("my test", async ({ page, switchScenario }) => {
await switchScenario(page, "success"); // ✅ Type-safe scenario IDs
// Helper automatically:
// 1. Generates unique test ID
// 2. Calls POST /__scenario__ with test ID header
// 3. Sets page.setExtraHTTPHeaders({ 'x-scenarist-test-id': testId })
await page.goto("/profile");
// All navigation requests include x-scenarist-test-id header
await page.request.post("/api/data", { data: { foo: "bar" } });
// API requests need explicit test ID (Playwright limitation):
const testId = await switchScenario(page, "success");
await page.request.post("/api/data", {
headers: { "x-scenarist-test-id": testId }, // Manual header for page.request
data: { foo: "bar" },
});
});
```
### Default Test ID
When no test ID header is present, Scenarist uses a default test ID:
```typescript
// Request without x-scenarist-test-id header
GET / api / user;
// No x-scenarist-test-id header
// Scenarist uses default test ID: 'default-test'
// Routes to 'default' scenario
```
**Use case:** Manual testing during development. Open browser, navigate to app, no test ID needed.
Change the fallback with `defaultTestId`. To treat a missing header as an error instead, set `defaultTestId: ''` and choose an [`onMissingTestId`](/reference/errors/#configurable-error-behaviors) behavior:
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true
scenarios,
defaultTestId: "", // No fallback
errorBehaviors: { onMissingTestId: "throw" }, // Respond 500 with MISSING_TEST_ID
});
```
## How Ephemeral Endpoints Enable Parallel Tests
Traditional E2E testing forces **sequential execution** because of shared global state:
```typescript
// ❌ Traditional approach - shared MSW handlers
beforeAll(() => {
server.use(http.get("/api/user", () => HttpResponse.json({ role: "admin" })));
});
test("test 1: admin view", () => {
// Uses global handler: admin
});
test("test 2: guest view", () => {
// PROBLEM: Still sees admin handler!
// Must run sequentially or reset handlers between tests
});
```
Scenarist enables **parallel execution** via test ID isolation:
```typescript
// ✅ Scenarist approach - isolated scenarios
test("test 1: admin view", async ({ page, switchScenario }) => {
await switchScenario(page, "admin-user");
// Test ID: test-1 → Scenario: admin-user
});
test("test 2: guest view", async ({ page, switchScenario }) => {
await switchScenario(page, "guest-user");
// Test ID: test-2 → Scenario: guest-user
// Runs in parallel with test 1, no interference
});
```
**10x faster test suites** because tests run in parallel instead of sequentially.
## Runtime Scenario Switching
Ephemeral endpoints enable **runtime scenario switching** without restarting your application:
### Traditional Approach (Slow)
```bash
# Test success case
NODE_ENV=test SCENARIO=success npm start
# Run test
# Kill server
# Test error case
NODE_ENV=test SCENARIO=error npm start
# Run test
# Kill server
# Test edge case
NODE_ENV=test SCENARIO=edge npm start
# Run test
# Kill server
```
**Problem:** Each scenario requires full server restart. Slow and painful.
### Scenarist Approach (Fast)
```typescript
// Start server once
// NODE_ENV=test npm start
// Switch scenarios at runtime
test("success case", async ({ page, switchScenario }) => {
await switchScenario(page, "success");
// Instant scenario switch, no restart
});
test("error case", async ({ page, switchScenario }) => {
await switchScenario(page, "error");
// Instant scenario switch, no restart
});
test("edge case", async ({ page, switchScenario }) => {
await switchScenario(page, "edge");
// Instant scenario switch, no restart
});
```
**Benefit:** Server runs continuously. Tests switch scenarios instantly via HTTP requests to `/__scenario__` endpoint.
## Manual Testing with Scenarios
Ephemeral endpoints also enable **manual testing** with different scenarios:
```bash
# Start your app in test mode
NODE_ENV=test npm start
# Switch to error scenario via curl
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-H "x-scenarist-test-id: manual-testing" \
-d '{"scenario": "payment-error"}'
# Open browser and manually test error scenario
# All requests with x-scenarist-test-id: manual-testing see error scenario
# Switch to success scenario
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-H "x-scenarist-test-id: manual-testing" \
-d '{"scenario": "payment-success"}'
# Manually test success scenario
```
**Use cases:**
* Demo different scenarios to stakeholders
* Debug edge cases locally
* Validate UI behavior across scenarios
## Architecture: How It Works
### Request Flow
```plaintext
1. Test calls switchScenario('payment-error')
↓
2. Helper generates test ID: 'test-abc123'
↓
3. Helper calls: POST /__scenario__
Headers: { x-scenarist-test-id: 'test-abc123' }
Body: { scenario: 'payment-error' }
↓
4. Endpoint stores: test-abc123 → payment-error
↓
5. Test makes request: GET /api/payment
Headers: { x-scenarist-test-id: 'test-abc123' }
↓
6. Middleware extracts test ID from header
↓
7. ScenarioManager looks up active scenario for test ID
↓
8. MSW handler finds matching mock in scenario
↓
9. Response returned from mock definition
```
### Scenario Storage
Scenarios are stored in-memory (current implementation):
```typescript
// Simplified internal structure
const scenarioStore = new Map();
// After switchScenario('test-abc123', 'payment-error')
scenarioStore.set("test-abc123", {
scenarioId: "payment-error",
});
// On request with x-scenarist-test-id: test-abc123
const activeScenario = scenarioStore.get("test-abc123");
// { scenarioId: 'payment-error' }
```
**Why in-memory?** This is the right choice for scenario-based testing - scenarios only need to persist for the duration of a test run. The test ID isolation ensures parallel tests don’t interfere with each other.
### State and Sequence Isolation
State capture and sequence positions are also isolated by test ID:
```typescript
// Internal structure (simplified)
const stateStore = new Map>();
const sequenceStore = new Map();
// Test ID: test-abc123
stateStore.set("test-abc123", { cartItems: ["item-1", "item-2"] });
sequenceStore.set("test-abc123:scenario:mockIndex", { position: 2 });
// Test ID: test-def456
stateStore.set("test-def456", { cartItems: ["item-3"] });
sequenceStore.set("test-def456:scenario:mockIndex", { position: 0 });
// Tests have independent state and sequence positions
```
## Configuration Reference
Complete configuration options related to ephemeral endpoints:
```typescript
const scenarist = createScenarist({
// createScenarist() returns undefined when false
enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true
// Scenario definitions
scenarios: {
default: defaultScenario,
// ... other scenarios
},
// Endpoint paths (configurable)
endpoints: {
setScenario: "/__scenario__", // POST: switch scenario
getScenario: "/__scenario__", // GET: get active scenario
},
// Note: Test ID header is standardized to 'x-scenarist-test-id'
// Use SCENARIST_TEST_ID_HEADER constant from your adapter package
});
```
## Next Steps
* [Writing Scenarios →](/scenarios/overview/) - Learn the complete scenario structure
* [Default Scenarios →](/scenarios/default-scenarios/) - Understand override behavior
* [Endpoint APIs →](/reference/api-endpoints/) - Reference for GET/POST /**scenario**
# Error Handling
> Error types, error codes, and configurable error behaviors in Scenarist
Scenarist provides comprehensive error handling with rich context, actionable hints, and configurable behaviors. All errors extend `ScenaristError` and include machine-readable codes for programmatic handling.
## Error Codes
| Code | Description | When Thrown |
| -------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `SCENARIO_NOT_FOUND` | Attempted to switch to a scenario that hasn’t been registered | `switchScenario()` with unknown ID (returned as `result.error`, not thrown) |
| `DUPLICATE_SCENARIO` | Attempted to register a scenario with an ID that already exists | `registerScenario()` with duplicate ID |
| `NO_MOCK_FOUND` | No mock matched the incoming request | Unmatched request with `errorBehaviors.onNoMockFound: 'throw'` |
| `SEQUENCE_EXHAUSTED` | Sequence with `repeat: 'none'` has no more responses and no fallback mock | Request to exhausted sequence with `errorBehaviors.onSequenceExhausted: 'throw'` |
| `MISSING_TEST_ID` | No test ID could be resolved for the request | Request without a test ID header, with `defaultTestId: ''` and `errorBehaviors.onMissingTestId: 'throw'` |
| `VALIDATION_ERROR` | Scenario definition failed schema validation | `registerScenario()` with invalid definition |
## ScenaristError
All Scenarist errors extend `ScenaristError`, which provides:
```typescript
class ScenaristError extends Error {
readonly code: string; // Machine-readable error code
readonly context: ErrorContext; // Rich context information
}
type ErrorContext = {
readonly testId?: string; // Test that triggered the error
readonly scenarioId?: string; // Scenario involved
readonly requestInfo?: { // Request details
method: string;
url: string;
};
readonly hint?: string; // Actionable guidance
};
```
### Example Error
`switchScenario()` returns a result instead of throwing. The error is typed as `Error`; at runtime it is a `ScenaristError`:
```typescript
import { ScenaristError } from '@scenarist/core';
const result = scenarist.switchScenario('test-123', scenarioId);
if (!result.success && result.error instanceof ScenaristError) {
console.log(result.error.code); // 'SCENARIO_NOT_FOUND'
console.log(result.error.message); // "Scenario '...' not found..."
console.log(result.error.context);
// {
// testId: 'test-123',
// scenarioId: '...',
// hint: 'Make sure to register the scenario before switching...'
// }
}
```
The Express adapter type-checks scenario IDs against your scenarios object; the Next.js adapters accept any string. Over HTTP, `POST /__scenario__` with an unknown scenario responds `400` with the error message.
## Configurable Error Behaviors
Control how Scenarist handles request-time error conditions using `errorBehaviors`:
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test',
scenarios,
logger: createConsoleLogger({ level: 'warn' }),
errorBehaviors: {
onNoMockFound: 'warn', // Log a warning, then strictMode decides
onSequenceExhausted: 'throw', // Respond 500 with SEQUENCE_EXHAUSTED
},
});
```
### Behavior Options
ignore
**Default.** Continue silently. `strictMode` decides what happens next.
warn
Log a warning through the configured `logger`, then let `strictMode` decide. Without a `logger`, this behaves like `ignore`.
throw
Respond with HTTP 500 and a JSON body containing the error `code` and `message`. The request your application made receives that response.
A `throw` response looks like this:
```json
{
"error": "Internal mock server error",
"message": "No mock matched for GET https://api.example.com/users",
"code": "NO_MOCK_FOUND"
}
```
### Behavior Defaults
All behaviors default to `ignore`, so an unmatched request behaves exactly as `strictMode` says:
```typescript
const DEFAULT_ERROR_BEHAVIORS = {
onNoMockFound: 'ignore',
onSequenceExhausted: 'ignore',
onMissingTestId: 'ignore',
};
```
Relationship with strictMode
`errorBehaviors` run first. `throw` responds 500 immediately. `warn` and `ignore` hand the request to `strictMode`:
* `strictMode: true` → Return 501 for unmatched requests
* `strictMode: false` → Pass through to the real endpoint
In-process tests see your own requests too
MSW intercepts every outgoing request in the process, including requests your test makes to your own app with supertest. With `strictMode: true` or `onNoMockFound: 'throw'`, those requests are also treated as unmatched. Use these options when your app runs in a separate process (for example, Playwright against a running server), or mock the app’s own URL.
### When onMissingTestId Applies
Adapters fall back to `defaultTestId` (`'default-test'`) when a request has no `x-scenarist-test-id` header, so by default a test ID is always resolved and `onMissingTestId` never fires. To make a missing header an error, disable the fallback:
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test', // Express; in Next.js use enabled: true
scenarios,
defaultTestId: '',
errorBehaviors: { onMissingTestId: 'throw' },
});
```
### Multiple Scenarist Instances
When several instances are started in one process (for example, App Router and Pages Router adapters in the same Next.js app), each request is offered to every instance. A `throw` from one instance is used only if no other instance has a mock for the request.
## Validation Errors
Scenario definitions are validated at registration time using Zod schemas. Validation errors include the path to the invalid field:
```typescript
try {
manager.registerScenario({
id: 'test',
name: '', // Empty name - validation error
description: 'Test scenario',
mocks: [
{
method: 'GET',
url: '', // Empty URL - validation error
response: { status: 600 }, // Invalid status - validation error
},
],
});
} catch (error) {
console.log(error.code); // 'VALIDATION_ERROR'
console.log(error.message);
// 'Invalid scenario definition for 'test': name: Too small: expected string to have >=1 characters, mocks.0.url: Too small: expected string to have >=1 characters, mocks.0.response.status: Too big: expected number to be <=599'
}
```
### Validation Rules
| Field | Rule |
| --------------------------------- | -------------------------------- |
| `id` | Non-empty string |
| `name` | Non-empty string |
| `url` | Non-empty string or valid RegExp |
| `status` | Integer between 100-599 |
| `sequence.responses` | At least one response |
| `stateResponse.conditions[].when` | At least one key |
| `afterResponse.setState` | At least one key |
## Error Handling Patterns
### Test Setup Validation
Validate your test setup by catching registration errors:
```typescript
describe('API tests', () => {
const scenarios = {
default: createDefaultScenario(),
error: createErrorScenario(),
};
// Validate all scenarios at test suite startup
beforeAll(() => {
for (const [id, scenario] of Object.entries(scenarios)) {
try {
manager.registerScenario(scenario);
} catch (error) {
if (error instanceof ScenaristError) {
throw new Error(
`Invalid scenario '${id}': ${error.message}\nHint: ${error.context.hint}`
);
}
throw error;
}
}
});
});
```
### Graceful Degradation
Use `warn` locally to surface issues without blocking, and `throw` in CI:
```typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: createConsoleLogger({ level: 'warn' }),
errorBehaviors: {
onNoMockFound: process.env.CI ? 'throw' : 'warn',
onSequenceExhausted: process.env.CI ? 'throw' : 'warn',
},
});
```
### Asserting on Request-Time Errors
Request-time errors (`NO_MOCK_FOUND`, `SEQUENCE_EXHAUSTED`, `MISSING_TEST_ID`) are not thrown into your application code. With `throw`, the external request your app made receives a 500 response whose body carries the `code`, so your app’s own error handling runs:
```typescript
import { ErrorCodes } from '@scenarist/core';
// Errors thrown while handling a mocked request do not propagate to your code.
// The request receives a 500 JSON response: { error, message, code }
const response = await fetch('https://api.example.com/users');
if (response.status === 500) {
const body = await response.json();
switch (body.code) {
case ErrorCodes.NO_MOCK_FOUND:
console.log('No mock matched:', body.message);
break;
case ErrorCodes.SEQUENCE_EXHAUSTED:
console.log('Sequence exhausted for mock');
break;
default:
console.log('Scenarist error:', body.message);
}
}
```
Switching errors are returned, not thrown: check `result.success` (see [Example Error](#example-error)).
Registration errors (`DUPLICATE_SCENARIO`, `VALIDATION_ERROR`) are thrown as `ScenaristError` when scenarios are registered, which adapters do inside `createScenarist()`. See [Validation Errors](#validation-errors).
## API Reference
### ErrorCodes
```typescript
export const ErrorCodes = {
SCENARIO_NOT_FOUND: 'SCENARIO_NOT_FOUND',
DUPLICATE_SCENARIO: 'DUPLICATE_SCENARIO',
NO_MOCK_FOUND: 'NO_MOCK_FOUND',
SEQUENCE_EXHAUSTED: 'SEQUENCE_EXHAUSTED',
MISSING_TEST_ID: 'MISSING_TEST_ID',
VALIDATION_ERROR: 'VALIDATION_ERROR',
} as const;
```
### ErrorBehaviors
```typescript
type ErrorBehavior = 'throw' | 'warn' | 'ignore';
type ErrorBehaviors = {
readonly onNoMockFound: ErrorBehavior;
readonly onSequenceExhausted: ErrorBehavior;
readonly onMissingTestId: ErrorBehavior;
};
```
### ScenaristError
```typescript
class ScenaristError extends Error {
constructor(
message: string,
options: {
code: string;
context: ErrorContext;
}
);
readonly code: string;
readonly context: ErrorContext;
}
```
## Next Steps
* [Debugging with Logs](/reference/logging/) - Configure logging for error debugging
* [Testing Best Practices](/testing/best-practices/) - Patterns for robust tests
* [API Endpoints](/reference/api-endpoints/) - Understand the mock server API
# Debugging with Logs
> Configure and use Scenarist's logging infrastructure for debugging scenario matching and observability
Scenarist provides a flexible logging infrastructure for debugging scenario matching, state management, and request handling. Logging is **disabled by default** (silent) and can be enabled when needed.
## Quick Start
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
// Or: import { createScenarist, createConsoleLogger } from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({
enabled: true,
scenarios,
// Enable logging with pretty format at info level
logger: createConsoleLogger({ level: 'info' }),
});
```
## Logger Types
Scenarist includes two logger implementations:
NoOpLogger
**Default.** Silent - no output. Zero overhead for production-like tests.
ConsoleLogger
Human-readable or JSON output. Great for debugging scenario matching issues.
## ConsoleLogger
The `ConsoleLogger` provides human-readable or JSON output to the console.
### Basic Usage
```typescript
import { createConsoleLogger } from '@scenarist/express-adapter';
// Pretty format (default) - colored, human-readable
const logger = createConsoleLogger({ level: 'info' });
// JSON format - for log aggregation tools
const logger = createConsoleLogger({ level: 'info', format: 'json' });
// Debug level with category filtering
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
});
```
### Configuration
```typescript
type ConsoleLoggerConfig = {
/** Minimum log level to output */
level: 'error' | 'warn' | 'info' | 'debug' | 'trace';
/** Output format (default: 'pretty') */
format?: 'pretty' | 'json';
/** Filter to specific categories (default: all categories) */
categories?: LogCategory[];
};
```
### Log Levels
Levels are hierarchical - each level includes all less verbose levels:
| Level | Description | Use Case |
| ------- | ----------------- | --------------------------------------------- |
| `error` | Critical failures | Scenario not found, invalid config |
| `warn` | Potential issues | No mock matched, sequence exhausted |
| `info` | Key events | Scenario switched, mock selected |
| `debug` | Decision logic | Match criteria evaluation, specificity scores |
| `trace` | Verbose details | Request/response bodies, template replacement |
Start with 'info'
For most debugging, `level: 'info'` shows what matters: scenario switches and mock selections. Move to `debug` when you need to understand *why* a particular mock was or wasn’t selected.
### Log Categories
Filter logs to specific areas of concern:
| Category | Description | Example Messages |
| ----------- | -------------------- | ------------------------------------------------------------------------------------ |
| `lifecycle` | Startup, shutdown | None emitted yet |
| `scenario` | Scenario switching | `scenario_switched`, `scenario_not_found`, `scenario_registered`, `scenario_cleared` |
| `matching` | Mock selection | `mock_selected`, `mock_no_match`, `mock_candidates_found`, `mock_match_evaluated` |
| `sequence` | Sequence state | `sequence_exhausted` |
| `state` | State updates | `state_set`, `state_response_resolved` |
| `template` | Template replacement | None emitted yet |
| `request` | Request lifecycle | None emitted yet |
```typescript
// Only show matching and scenario logs
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
});
```
### Output Formats
* Pretty Format
```plaintext
12:34:56.789 INF [test-user-login] 🎬 scenario scenario_switched
12:34:56.801 DBG [test-user-login] 🎯 matching mock_match_evaluated mockIndex=0 matched=false hasCriteria=true
12:34:56.802 DBG [test-user-login] 🎯 matching mock_match_evaluated mockIndex=1 matched=true hasCriteria=true
12:34:56.803 INF [test-user-login] 🎯 matching mock_selected mockIndex=1 specificity=5
12:34:56.810 DBG [test-user-login] 💾 state state_set setState={"userId":"user-123"} source="mock-level"
12:34:56.815 WRN [test-user-login] 🎯 matching mock_no_match url="https://api.example.com/unknown" method="GET" candidateCount=0
```
**Features:**
* Timestamps with milliseconds
* Level indicators: `ERR`, `WRN`, `INF`, `DBG`, `TRC`
* Category icons for visual scanning
* Colored test IDs (persistent per test)
* Key-value data fields
* JSON Format
```json
{"timestamp":1732650896789,"level":"info","category":"scenario","message":"scenario_switched","testId":"test-user-login","scenarioId":"premium-user","data":{}}
{"timestamp":1732650896801,"level":"info","category":"matching","message":"mock_selected","testId":"test-user-login","scenarioId":"premium-user","data":{"mockIndex":2,"specificity":5}}
{"timestamp":1732650896802,"level":"debug","category":"matching","message":"mock_match_evaluated","testId":"test-user-login","scenarioId":"premium-user","data":{"mockIndex":0,"matched":false,"hasCriteria":true}}
```
**Use for:** Log aggregation (Datadog, Splunk), structured queries, production debugging
### Parallel Test Support
When running tests in parallel, each test gets a unique test ID. The pretty format assigns **persistent colors** to each test ID, making it easy to trace logs from concurrent tests:
```plaintext
12:34:56.789 INF [test-checkout-flow] 🎯 matching mock_selected mockIndex=0 specificity=0
12:34:56.790 INF [test-payment-process] 🎯 matching mock_selected mockIndex=1 specificity=0
12:34:56.791 INF [test-user-login] 🎯 matching mock_selected mockIndex=0 specificity=2
12:34:56.792 DBG [test-checkout-flow] 💾 state state_set setState={"step":"paid"} source="mock-level"
```
Each test ID gets a unique color that remains consistent throughout the test run.
## NoOpLogger
The default logger - does nothing. Use for clean test output:
```typescript
import { noOpLogger, createNoOpLogger } from '@scenarist/express-adapter';
// Singleton instance (recommended)
const logger = noOpLogger;
// Or create new instance (functionally identical)
const logger = createNoOpLogger();
```
**When to use:**
* Production-like test runs where you want clean output
* CI/CD pipelines where logs would be noise
* Any situation where you don’t need Scenarist debugging
Default Behavior
When no logger is specified, Scenarist uses `noOpLogger` automatically. You only need to import it if you’re explicitly passing loggers around.
## Environment Variable Pattern
For easy toggling without code changes:
```typescript
import {
createScenarist,
createConsoleLogger,
noOpLogger,
type LogLevel,
type LogFormat,
} from '@scenarist/express-adapter';
// Type-safe environment variable parsing
const LOG_LEVELS: ReadonlyArray> = [
'error', 'warn', 'info', 'debug', 'trace',
];
const LOG_FORMATS: ReadonlyArray = ['pretty', 'json'];
const parseLogLevel = (value: string | undefined): Exclude =>
LOG_LEVELS.includes(value as Exclude)
? (value as Exclude)
: 'info';
const parseLogFormat = (value: string | undefined): LogFormat =>
LOG_FORMATS.includes(value as LogFormat) ? (value as LogFormat) : 'pretty';
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test',
scenarios,
// Enable logging via SCENARIST_LOG=1
logger: process.env.SCENARIST_LOG
? createConsoleLogger({
level: parseLogLevel(process.env.SCENARIST_LOG_LEVEL),
format: parseLogFormat(process.env.SCENARIST_LOG_FORMAT),
})
: noOpLogger,
});
```
These env vars are a convention, not automatic
`SCENARIST_LOG`, `SCENARIST_LOG_LEVEL`, and `SCENARIST_LOG_FORMAT` are **conventions for your code**, not something Scenarist reads automatically. Setting `SCENARIST_LOG=1` alone does nothing—you must explicitly pass a `logger` to `createScenarist()` as shown above. This pattern gives you full control over when and how logging is enabled.
Then run tests with logging:
```bash
# Enable info-level logging
SCENARIST_LOG=1 pnpm test
# Enable debug-level logging
SCENARIST_LOG=1 SCENARIST_LOG_LEVEL=debug pnpm test
# JSON format for CI
SCENARIST_LOG=1 SCENARIST_LOG_FORMAT=json pnpm test
```
## Vitest Configuration
Required for Vitest
By default, Vitest captures console output and only displays it for **failed** tests. To see Scenarist logging output for all tests, you must configure Vitest to disable console interception.
Add `disableConsoleIntercept: true` to your `vitest.config.ts`:
vitest.config.ts
```typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// REQUIRED: Show Scenarist logging output for all tests
// Without this, console output is only shown for failed tests
disableConsoleIntercept: true,
// ... your other config
},
});
```
Then run tests with logging enabled:
```bash
SCENARIST_LOG=1 pnpm test
```
You’ll see output like:
```plaintext
09:00:12.269 DBG [unknown] 🎬 scenario scenario_registered scenarioId="default" mockCount=4
09:00:12.270 DBG [unknown] 🎬 scenario scenario_registered scenarioId="success" mockCount=3
09:00:12.308 INF [my-test-id] 🎬 scenario scenario_switched
```
## Debugging Common Issues
### “Why isn’t my mock being selected?”
Enable debug logging for the `matching` category:
```typescript
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching'],
});
```
You’ll see:
* `mock_candidates_found` - How many mocks could potentially match
* `mock_match_evaluated` - Each mock’s evaluation result and specificity score
* `mock_selected` - Which mock was chosen and why
* `mock_no_match` - When no mock matched (with the URL that failed)
### “What scenario is active?”
Enable info logging for the `scenario` category:
```typescript
const logger = createConsoleLogger({
level: 'info',
categories: ['scenario'],
});
```
You’ll see:
* `scenario_switched` - When a test switches scenarios
* `scenario_not_found` - When a requested scenario doesn’t exist
### “Is state being captured correctly?”
Enable debug logging for the `state` category:
```typescript
const logger = createConsoleLogger({
level: 'debug',
categories: ['state'],
});
```
You’ll see:
* `state_set` - When `afterResponse.setState` updates state
* `state_response_resolved` - Which `stateResponse` condition (or default) was used
## Custom Loggers
Implement the `Logger` interface to integrate with your logging library:
```typescript
import type { Logger, LogCategory, LogContext } from '@scenarist/core';
import pino from 'pino';
const pinoInstance = pino({ level: 'debug' });
const pinoLogger: Logger = {
error: (category, message, context, data) =>
pinoInstance.error({ category, ...context, ...data }, message),
warn: (category, message, context, data) =>
pinoInstance.warn({ category, ...context, ...data }, message),
info: (category, message, context, data) =>
pinoInstance.info({ category, ...context, ...data }, message),
debug: (category, message, context, data) =>
pinoInstance.debug({ category, ...context, ...data }, message),
trace: (category, message, context, data) =>
pinoInstance.trace({ category, ...context, ...data }, message),
isEnabled: (level) => pinoInstance.isLevelEnabled(level),
};
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: pinoLogger,
});
```
## API Reference
### `createConsoleLogger(config)`
Create a console logger with the specified configuration.
```typescript
function createConsoleLogger(config: ConsoleLoggerConfig): Logger;
type ConsoleLoggerConfig = {
level: Exclude;
format?: 'pretty' | 'json';
categories?: ReadonlyArray;
};
```
### `noOpLogger`
Singleton logger that does nothing.
```typescript
const noOpLogger: Logger;
```
### `createNoOpLogger()`
Factory function to create a no-op logger.
```typescript
function createNoOpLogger(): Logger;
```
### `Logger` Interface
The port interface all loggers implement:
```typescript
interface Logger {
error(category: LogCategory, message: string, context: LogContext, data?: Record): void;
warn(category: LogCategory, message: string, context: LogContext, data?: Record): void;
info(category: LogCategory, message: string, context: LogContext, data?: Record): void;
debug(category: LogCategory, message: string, context: LogContext, data?: Record): void;
trace(category: LogCategory, message: string, context: LogContext, data?: Record): void;
isEnabled(level: Exclude): boolean;
}
```
### Types
```typescript
type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
type LogCategory =
| 'lifecycle'
| 'scenario'
| 'matching'
| 'sequence'
| 'state'
| 'template'
| 'request';
type LogContext = {
readonly testId?: string;
readonly scenarioId?: string;
readonly requestUrl?: string;
readonly requestMethod?: string;
};
```
## Performance Considerations
Scenarist’s logging is designed for minimal overhead:
### NoOpLogger: Zero Cost
When logging is disabled (the default), `noOpLogger` is used. Its methods are empty functions that V8’s JIT compiler can inline and potentially eliminate entirely:
```typescript
// NoOpLogger implementation - effectively no-op
export const noOpLogger: Logger = {
error: () => {},
warn: () => {},
info: () => {},
debug: () => {},
trace: () => {},
isEnabled: () => false,
};
```
Production Builds
In production builds, Scenarist is completely tree-shaken away (0KB). The logging infrastructure only exists in test bundles where performance is less critical.
### Lazy Evaluation
For high-frequency code paths, use `isEnabled()` to skip expensive string formatting when logging is disabled:
```typescript
// Check before formatting expensive context
if (logger.isEnabled('debug')) {
logger.debug('matching', 'Details', context, {
body: JSON.stringify(largeObject), // Only formatted if needed
headers: Object.fromEntries(request.headers),
});
}
```
This pattern ensures that expensive operations like:
* JSON serialization
* Object transformation
* String concatenation
…are only performed when the output will actually be used.
### V8 Inlining
Simple function calls like `logger.debug(category, message, context, data)` are candidates for V8’s inlining optimization when hot. The straightforward signature and return type (`void`) help V8 make efficient inlining decisions.
## Next Steps
* [Testing Best Practices](/testing/best-practices/) - Patterns for organizing tests
* [Parallel Testing](/testing/parallel-testing/) - Run tests concurrently
* [Architecture](/concepts/architecture/) - Understand the ports and adapters pattern
# Verification Guide
> How to verify Scenarist is working correctly in your project
When evaluating whether Scenarist is working correctly in your project, use this guide to verify core functionality, integration quality, and test coverage.
## Core Functionality Checks
### Header Propagation in Parallel Tests
**Issue:** When tests fail in parallel but pass sequentially, the root cause is usually **test ID headers not being propagated** through server-side fetch calls.
**How Test Isolation Works:**
Each test gets a unique test ID (automatically generated). This test ID must be sent with EVERY request to ensure the test uses the correct scenario. If headers aren’t propagated through internal server-side fetches, those requests will use the default scenario instead of the test’s scenario.
**Common Symptom:**
* ✅ Tests pass when run individually (`--workers=1`)
* ❌ Tests fail when run in parallel (`--workers=4`)
* ❌ Flaky results that change between runs
* ❌ Wrong data appearing in tests (from different scenario)
**Root Cause: Missing Header Propagation**
When your server-side code makes internal fetch calls, headers don’t automatically propagate. You must explicitly include them.
#### Next.js: Header Propagation Helpers
**Problem:**
```typescript
// ❌ BAD - Headers not propagated to internal fetch
export default async function Page() {
// This fetch doesn't include test ID header!
const response = await fetch('https://api.stripe.com/v1/products');
const data = await response.json();
return
{/* render */}
;
}
```
**Solution for Server Components** (use `getScenaristHeadersFromReadonlyHeaders`):
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Server Components
export default async function Page() {
const headersList = await headers(); // Get ReadonlyHeaders from Next.js
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeadersFromReadonlyHeaders(headersList), // Include test ID header
},
cache: 'no-store',
});
const data = await response.json();
return
{/* render */}
;
}
```
**Solution for Route Handlers** (use `getScenaristHeaders`):
```typescript
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
// ✅ GOOD - Headers propagated correctly in Route Handlers
export async function GET(request: Request) {
const response = await fetch("https://api.stripe.com/v1/products", {
headers: {
...getScenaristHeaders(request), // Include test ID header
},
cache: "no-store",
});
const data = await response.json();
return Response.json(data);
}
```
**What these helpers do:**
* Extract test ID from request/headers
* Return `{ 'x-scenarist-test-id': 'generated-uuid' }` object
* Safe to call in production builds, where no Scenarist instance exists (returns empty object)
#### Express: Headers Already Tracked
Express adapter uses AsyncLocalStorage to automatically track test IDs per request. No manual header propagation needed for middleware chains.
**Internal fetch calls** still need headers:
```typescript
// ✅ GOOD - Include test ID in internal fetches
app.get("/api/dashboard", async (req, res) => {
const testId = req.get(SCENARIST_TEST_ID_HEADER);
const response = await fetch("http://localhost:3001/api/user", {
headers: {
[SCENARIST_TEST_ID_HEADER]: testId || "default-test",
},
});
const data = await response.json();
res.json(data);
});
```
#### Verification: Use Playwright Tests
**Verify headers are propagating correctly:**
tests/header-propagation.spec.ts
```typescript
test("headers propagate through server-side fetch", async ({
page,
switchScenario,
}) => {
await switchScenario(page, "premium-user");
await page.goto("/dashboard");
// If headers propagated correctly, should see premium content
await expect(page.getByText("Premium Features")).toBeVisible();
// If headers DIDN'T propagate, would see default content
// This would fail in parallel tests (wrong scenario)
});
```
**Debugging failed tests:**
1. Add logging to see which scenario is active
2. Check server logs for test ID headers
3. Verify header helpers are called before fetch
4. Confirm headers object includes test ID
#### Red Flags
**❌ Tests fail only in parallel:**
```bash
# Pass individually
pnpm exec playwright test --workers=1
# ✅ All tests pass
# Fail in parallel
pnpm exec playwright test --workers=4
# ❌ Some tests fail with wrong data
```
**Root cause:** Missing header propagation. Tests interfere because they’re all using the default scenario.
**❌ Wrong data in tests:**
```typescript
// Test expects premium pricing
await expect(page.getByText("£99.99")).toBeVisible();
// ❌ Error: element not found
// But sees standard pricing instead
await expect(page.getByText("£149.99")).toBeVisible();
// ✅ This passes (wrong scenario!)
```
**Root cause:** Internal fetch didn’t include test ID header, used default scenario instead of premium scenario.
**❌ Flaky test results:**
* Sometimes premium pricing, sometimes standard
* Different results on different runs
* Race conditions between parallel tests
**Root cause:** Tests sharing scenarios due to missing header propagation.
#### Fix Checklist
When parallel tests fail:
1. ✅ **Next.js:** Add header helpers before all internal fetch calls (use `getScenaristHeadersFromReadonlyHeaders` in Server Components, `getScenaristHeaders` in Route Handlers)
2. ✅ **Express:** Include test ID header in internal fetch calls
3. ✅ **Playwright:** Verify tests switch scenarios before navigation
4. ✅ **Logging:** Add debug logs to confirm headers are present
5. ✅ **Isolation:** Ensure each test calls `switchScenario()` independently
**Quick fix for Next.js Server Components:**
```typescript
import { headers } from "next/headers";
import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app";
// Add this before EVERY external fetch in Server Components
const headersList = await headers();
fetch(url, {
headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList) },
cache: "no-store",
}); // Always include
```
**Quick fix for Next.js Route Handlers:**
```typescript
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
// In your route handler: export async function GET(request: Request)
fetch(url, {
headers: { ...getScenaristHeaders(request) },
cache: "no-store",
}); // Always include
```
### Runtime Scenario Switching
**Verify:** Scenarios can be switched without server restarts
```typescript
test("scenario switching", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
await page.goto("/dashboard");
await expect(page.getByText("Premium Features")).toBeVisible();
await switchScenario(page, "free");
await page.goto("/dashboard");
await expect(page.getByText("Upgrade to Premium")).toBeVisible();
});
```
**Expected behavior:**
* Scenario changes take effect immediately
* No server restart required
* Different responses from same endpoints
* State is cleared when switching scenarios
**Red flags:**
* Need to restart server between scenario changes
* Scenario switches not taking effect
* Previous scenario behavior persisting
### Test ID Isolation
**Verify:** Each test has isolated state via unique test ID
```typescript
test("test 1: add item to cart", async ({ page, switchScenario }) => {
await switchScenario(page, "cart");
await page.goto("/cart");
await page.click('[data-testid="add-product-1"]');
const items = await page.locator('[data-testid="cart-item"]').count();
expect(items).toBe(1);
});
test("test 2: empty cart", async ({ page, switchScenario }) => {
await switchScenario(page, "cart");
await page.goto("/cart");
const items = await page.locator('[data-testid="cart-item"]').count();
expect(items).toBe(0); // Should be empty, not affected by test 1
});
```
**Expected behavior:**
* Each test starts with clean state
* Test 2 doesn’t see items added in Test 1
* Tests can run in any order
**Red flags:**
* Tests depend on execution order
* State leaking between tests
* Need to manually clean up state
### Real Backend Execution
**Verify:** Backend code executes with real middleware and routing
```typescript
test("middleware executes", async ({ page, switchScenario }) => {
await switchScenario(page, "logged-in");
// Should trigger auth middleware
const response = await page.goto("/protected");
// Middleware should have run and allowed access
expect(response?.status()).toBe(200);
await expect(page.getByText("Protected Content")).toBeVisible();
});
```
**Expected behavior:**
* Middleware chains execute normally
* Route handlers run with production logic
* Business logic processes responses correctly
* Only external API calls are mocked
**Red flags:**
* Middleware is being skipped or mocked
* Business logic not executing as in production
* Framework internals are mocked
## Integration Quality Checks
### External APIs Only
**Verify:** Only external API calls are mocked, not framework internals
**Check your test setup:**
```typescript
import type { ScenaristScenarios } from "@scenarist/express-adapter";
// ✅ GOOD - Only external APIs mocked
const scenarios = {
stripe: {
mocks: [
{
method: "POST",
url: "https://api.stripe.com/v1/charges", // External API
response: {
/* ... */
},
},
],
},
} as const satisfies ScenaristScenarios;
// ❌ BAD - Mocking framework internals
const badScenarios = {
nextjs: {
mocks: [
{
method: "GET",
url: "/api/my-endpoint", // Your own endpoint!
response: {
/* ... */
},
},
],
},
}; // Don't do this - mocking your own routes defeats the purpose
```
**What to mock:**
* ✅ Stripe API calls
* ✅ Auth0 API calls
* ✅ SendGrid API calls
* ✅ Any external HTTP service
**What NOT to mock:**
* ❌ Your own API routes
* ❌ Framework request/response objects
* ❌ Internal middleware
### Scenario Reusability
**Verify:** Scenario definitions work across different test suites
```typescript
// scenarios.ts - Shared across all tests
export const premiumScenario = {
id: "premium",
mocks: [
/* ... */
],
};
// Used in multiple test files:
// tests/dashboard.spec.ts
test("dashboard shows premium features", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// ...
});
// tests/checkout.spec.ts
test("checkout with premium discount", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// ...
});
```
**Expected behavior:**
* Same scenario definition works everywhere
* No need to duplicate scenario logic
* Changes to scenario affect all tests using it
**Red flags:**
* Duplicating scenario definitions across test files
* Different scenarios for same behavior
* Scenarios tightly coupled to specific tests
### Mock Accuracy
**Verify:** Mock definitions accurately represent external API contracts
```typescript
// ✅ GOOD - Matches real Stripe response structure
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
id: 'ch_123',
object: 'charge',
amount: 5000,
currency: 'usd',
status: 'succeeded'
// Matches Stripe API documentation
}
}
}
// ❌ BAD - Doesn't match real API structure
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
success: true,
chargeId: '123'
// This is not how Stripe responds
}
}
}
```
**How to verify:**
* Compare mock responses against API documentation
* Use TypeScript types from API client libraries
* Validate responses against API schemas if available
* Test against real API in staging to verify mock accuracy
## Test Coverage Checks
### Edge Cases and Error States
**Verify:** Can test edge cases without complex setup
```typescript
test("handles payment declined", async ({ page, switchScenario }) => {
await switchScenario(page, "payment-declined");
await page.goto("/checkout");
await page.click('[data-testid="submit-payment"]');
await expect(page.getByText("Payment declined")).toBeVisible();
});
test("handles auth timeout", async ({ page, switchScenario }) => {
await switchScenario(page, "auth-timeout");
const response = await page.goto("/dashboard");
expect(response?.status()).toBe(401);
});
```
**Expected behavior:**
* Can test decline codes, timeouts, rate limits easily
* No need to manipulate external APIs
* Error states are deterministic and repeatable
**Red flags:**
* Can’t test specific error codes
* Need complex setup to trigger errors
* Error scenarios are flaky
### User Types and Tiers
**Verify:** Multiple user types can be tested concurrently
```typescript
test.describe.parallel("User tiers", () => {
test("premium user experience", async ({ page, switchScenario }) => {
await switchScenario(page, "premium-user");
// ... test premium features
});
test("free user experience", async ({ page, switchScenario }) => {
await switchScenario(page, "free-user");
// ... test limited features
});
test("trial user experience", async ({ page, switchScenario }) => {
await switchScenario(page, "trial-user");
// ... test trial features
});
});
```
**Expected behavior:**
* All tier tests run in parallel
* Each test uses correct tier data
* No interference between tiers
### API Retry Logic
**Verify:** Retry logic and rate limiting can be tested
```typescript
{
mocks: [
{
method: "POST",
url: "https://api.example.com/action",
sequence: {
responses: [
{ status: 500, body: { error: "Server error" } },
{ status: 500, body: { error: "Server error" } },
{ status: 200, body: { success: true } },
],
repeat: "last",
},
},
];
}
test("retries on failure", async ({ page, switchScenario }) => {
await switchScenario(page, "retry-scenario");
// First two attempts fail, third succeeds
await page.click('[data-testid="submit"]');
// Should eventually show success after retries
await expect(page.getByText("Success")).toBeVisible({ timeout: 10000 });
});
```
**Expected behavior:**
* Sequence advances through responses
* Retry logic can be verified
* Rate limiting scenarios testable
### Performance
**Verify:** Tests remain fast for frequent execution
**Benchmarks to aim for:**
* Test setup (scenario switch): < 100ms
* HTTP requests to your backend: Same speed as without Scenarist
* Mock response time: < 10ms
* Full test suite: Suitable for watch mode during development
**Red flags:**
* Tests are slower than unit tests
* Scenario switching takes multiple seconds
* Mock overhead is noticeable
## Production Tree-Shaking Verification
When deploying Scenarist to production, it’s critical to verify that implementation code is NOT being delivered to production. The adapters use **conditional exports**: when your bundler resolves the `production` export condition, the adapter entry point resolves to a stub whose `createScenarist()` returns `undefined` and imports nothing. You should verify this is working correctly in your build.
### How Conditional Exports Work
1. The adapter’s `package.json` maps the `production` export condition to a `production.js` stub
2. Your bundler resolves that condition when building for production
3. The stub’s `createScenarist()` returns `undefined` and has no imports
4. **Result:** Neither the Scenarist implementation nor MSW is included in the build output
### Step 1: Build Your Application
Build with the `production` condition enabled:
```bash
# esbuild (--conditions=production is required)
esbuild src/server.ts --bundle --platform=node --format=esm \
--outfile=dist/server.js \
--define:process.env.NODE_ENV='"production"' \
--conditions=production
# Next.js
NODE_ENV=production next build
```
For other bundlers, make sure `production` is included in the resolve conditions for production builds.
### Step 2: Verify Implementation is NOT in the Build Output
```bash
# Search for MSW implementation code in the build output
grep -rE '(setupWorker|HttpResponse\.json)' dist/
# Should output nothing (no matches) ✅
```
**Expected result:** No matches. If you see matches, the bundler resolved the default (non-production) entry point and bundled the implementation.
### Red Flags
**❌ Implementation code in the build output:**
```bash
$ grep -rE '(setupWorker|HttpResponse\.json)' dist/
# Found matches ← BAD: Implementation bundled
```
**Fix:** Make sure the bundler resolves the `production` export condition:
* esbuild: Add `--conditions=production`
* Next.js: Build with `NODE_ENV=production`
* Other bundlers: Add `production` to the resolve conditions
### Framework-Specific Verification
#### Express
```bash
# Build (esbuild with --conditions=production)
pnpm build:production
# Verify implementation not in output
! grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/
# (no matches)
```
#### Next.js App Router and Pages Router
```bash
# Build
NODE_ENV=production next build
# Verify implementation not in output
! grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' .next/ --exclude-dir=cache
# (no matches)
```
### What This Proves
✅ **Zero delivery overhead** - Implementation code never reaches production runtime ✅ **Conditional exports work** - Bundler resolved the production stub ✅ **Production safety** - Test infrastructure code completely absent from production execution
### Next Steps
If verification fails:
1. Check that your bundler resolves the `production` export condition
2. Verify `NODE_ENV=production` is set for the build
3. Review [Production Safety Guide](/concepts/production-safety/) for detailed configuration
If verification succeeds: ✅ Your production deployment is safe - Scenarist implementation code is completely tree-shaken!
## Common Issues to Watch
### Issue: Tests Interfere With Each Other
**Symptoms:**
* Tests pass individually but fail in parallel
* Flaky results
* State leaking between tests
**Check:**
* Verify each test calls `switchScenario()` before actions
* Ensure test IDs are unique (generated automatically by helper)
* Check that state is isolated per test ID
**Fix:**
```typescript
// ✅ GOOD - Each test switches scenario
test("test 1", async ({ page, switchScenario }) => {
await switchScenario(page, "scenario1"); // Isolates this test
// ...
});
// ❌ BAD - Shared scenario across tests
const scenarioId = "shared";
test("test 1", async ({ page, switchScenario }) => {
await switchScenario(page, scenarioId); // Don't share!
// ...
});
```
### Issue: Framework Internals Are Mocked
**Symptoms:**
* Middleware doesn’t execute
* Business logic is bypassed
* Tests don’t reflect production behavior
**Check:**
* Review mock URLs - should all be external APIs
* Verify your route handlers execute normally
* Check that middleware chains run
**Fix:**
```typescript
// ❌ BAD - Mocking your own routes
{
url: 'http://localhost:3000/api/my-route', // Your app!
response: { /* ... */ }
}
// ✅ GOOD - Mocking external APIs only
{
url: 'https://api.stripe.com/v1/charges', // External!
response: { /* ... */ }
}
```
### Issue: Scenarios Can’t Switch at Runtime
**Symptoms:**
* Need to restart server to change scenarios
* Scenario changes don’t take effect
* Old scenario behavior persists
**Check:**
* Verify `/__scenario__` endpoint is registered
* Check that `createScenarist()` returned an instance (it returns `undefined` in production builds)
* Ensure test ID headers are being sent
**Fix:**
* Check adapter setup in your application
* Verify Playwright helpers are configured correctly
* Check for errors in scenario registration
### Issue: Tests Are Slow
**Symptoms:**
* Tests slower than expected
* Long wait times for responses
* Unsuitable for watch mode
**Check:**
* Look for unnecessary `delay` in mock responses
* Check for browser interactions that could be HTTP-only
* Verify parallel execution is enabled
**Fix:**
```typescript
// Remove unnecessary delays
{
response: {
status: 200,
body: { /* ... */ },
// delay: 5000 ← Remove this
}
}
// Use parallel execution
test.describe.parallel('Fast tests', () => {
// Tests run concurrently
});
```
## Next Steps
If verification reveals issues:
1. **Review framework guides** - Ensure adapter is set up correctly
* [Next.js →](/frameworks/nextjs-app-router/getting-started/)
* [Express →](/frameworks/express/getting-started/)
2. **Check scenario definitions** - Verify mocks match external APIs
* [Writing Scenarios →](/scenarios/overview/)
3. **Examine test setup** - Ensure test isolation is working
* [How it works: Test Isolation →](/concepts/how-it-works/#how-test-isolation-works-complete-request-flow)
4. **Consult architecture docs** - Understand how pieces fit together
* [Architecture →](/concepts/architecture/)
# Tool Comparison
> How Scenarist compares to WireMock, Nock, Testcontainers, Playwright mocks, and how it builds on MSW
Choosing the right testing tool depends on your architecture, team, and what you’re testing. This guide helps you understand where Scenarist fits and when other tools might be better suited.
## What Scenarist Offers
Scenarist is built on [MSW](https://mswjs.io/) and adds a complete scenario management layer designed for modern testing workflows:
Simple Architecture
Just an HTTP header. No Docker, no separate processes, no network proxies. Install, configure scenarios, test. [Learn more →](/concepts/architecture/)
Test ID Isolation
Run hundreds of [parallel tests](/testing/parallel-testing/) with different scenarios against a single server. Each test’s header routes to its own scenario.
Runtime Switching
Change scenarios mid-test without restarts. Perfect for testing retry flows, error recovery, and multi-step user journeys.
First-Class Playwright
Dedicated [fixtures](/testing/playwright-integration/) with type-safe scenario switching and automatic test ID handling. Each test gets isolated state without manual header management.
Response Sequences
Ever tested a polling UI that shows pending → processing → complete? Most tools need complex state management. [Scenarist: three lines of config](/scenarios/response-sequences/).
Next.js Multi-Process (Solved)
Next.js has [documented singleton issues](https://github.com/vercel/next.js/discussions/68572) that break MSW. Scenarist’s adapter includes built-in `globalThis` guards—one stable MSW instance regardless of module loading.
### Framework Adapters Solve Real Problems
Scenarist's adapters aren't thin wrappers—they solve framework-specific challenges that would otherwise require significant boilerplate:
**Next.js Adapter:**
* **[Module duplication protection](https://github.com/vercel/next.js/discussions/68572)** — Next.js dev mode and Turbopack can load modules multiple times, causing `[MSW] Multiple handlers with the same URL` errors and memory leaks. Scenarist's built-in singleton pattern (`global.__scenarist_instance`) handles this automatically.
* **Pages + App Router isolation** — Separate global state keys enable gradual migration without scenario cross-contamination.
* **Conditional exports** — Zero MSW code in production bundles via automatic tree-shaking.
Without these solutions, you'd need to understand Next.js internals and implement the `globalThis` singleton pattern yourself—Scenarist handles it with a simple `export const`.
### The Parallel Testing Advantage
Most mock tools require separate instances for parallel test isolation. Scenarist uses header-based routing instead:
```plaintext
┌─────────────────────────────────────────────────────────────────┐
│ Traditional approach: 10 parallel tests = 10 mock servers │
│ │
│ Test 1 → WireMock:8081 Test 6 → WireMock:8086 │
│ Test 2 → WireMock:8082 Test 7 → WireMock:8087 │
│ Test 3 → WireMock:8083 Test 8 → WireMock:8088 │
│ Test 4 → WireMock:8084 Test 9 → WireMock:8089 │
│ Test 5 → WireMock:8085 Test 10 → WireMock:8090 │
├─────────────────────────────────────────────────────────────────┤
│ Scenarist: 10 parallel tests = 1 server, 10 headers │
│ │
│ Test 1 ─┬─ x-scenarist-test-id: test-1 ─┬─→ scenario-a │
│ Test 2 ─┤ x-scenarist-test-id: test-2 ─┤─→ scenario-b │
│ ... ├─────────► App Server ─────────┤ ... │
│ Test 9 ─┤ x-scenarist-test-id: test-9 ─┤─→ scenario-a │
│ Test 10─┴─ x-scenarist-test-id: test-10─┴─→ scenario-c │
└─────────────────────────────────────────────────────────────────┘
```
**Result:** No container orchestration, no port allocation, no startup overhead. Tests run faster and CI stays simple.
## Quick Comparison Matrix
| Feature | Scenarist | WireMock | Nock | Testcontainers | Playwright Mocks |
| ---------------------- | ------------------------- | ------------------------------- | ---------------------------- | -------------------------- | ---------------- |
| **Test isolation** | Per-test via header | Per-server instance | Per-test setup | Per-container | Per-page |
| **Runtime switching** | Yes (single API call) | Yes (Admin API) | No | No | No |
| **Server Components** | Full support | Yes (network proxy) | No (E2E) / Yes (integration) | Yes (with WireMock module) | No |
| **Setup complexity** | npm install | JAR/Docker/npm | npm install | Docker required | Built-in |
| **Parallel tests** | Built-in (header routing) | Multiple instances or scenarios | Scope/persist management | Per-container | Per-page |
| **Test framework** | First-class Playwright | Generic HTTP | Generic | Generic | Built-in |
| **Response sequences** | Built-in | Built-in (Scenarios) | Manual | Via mock server | Manual |
| **Stateful mocks** | Built-in capture/inject | Built-in state machine | Manual | Via mock server | Manual |
| **Language** | TypeScript/JS | Language-agnostic | JavaScript | Language-agnostic | JavaScript |
*Note: “Server Components” refers to E2E testing of React Server Components where requests originate on the server.*
## Understanding the Categories
Testing tools fall into different categories based on how and where they intercept requests:
Network-Level Mocking
**Scenarist, Nock, MSW**
Intercept HTTP requests within your Node.js process. No external services needed. Fast setup, TypeScript-native.
Server-Based Mocking
**WireMock**
Standalone mock server that receives real network traffic. Language-agnostic but requires separate process management.
Container-Based Testing
**Testcontainers**
Runs real services in Docker containers. Different purpose—testing against real databases and services, not mocking.
Browser-Level Mocking
**Playwright’s `page.route()`**
Intercepts requests in the browser via Playwright’s built-in API. Perfect for SPAs, but cannot intercept server-side requests (Server Components, API routes).
## Built on MSW
Scenarist is built on top of [Mock Service Worker (MSW)](https://mswjs.io/)—the same battle-tested interception library used by thousands of projects. We don’t replace MSW; we add a scenario management layer on top.
**What MSW provides:**
* Request interception at the network level
* Works with any HTTP client (fetch, axios, etc.)
* Proven reliability and active maintenance
**What Scenarist adds:**
* **[Test ID isolation](/testing/parallel-testing/)** — Parallel tests with different scenarios
* **[Runtime switching](/concepts/how-it-works/#runtime-scenario-switching)** — Change scenarios without restart
* **[First-class Playwright support](/testing/playwright-integration/)** — Dedicated fixtures with type-safe scenario switching and automatic test ID isolation
* **[Response sequences](/scenarios/response-sequences/)** — Built-in support for polling, retry flows, state machines
* **[Stateful mocks](/scenarios/stateful-mocks/)** — Capture values from requests, inject into responses (state isolated per test ID)
* **[Advanced matching](/scenarios/request-matching/)** — Body, headers, query params, regex patterns
* **Framework adapters** — Express, Next.js integration out of the box
If you need low-level control or use MSW for non-testing purposes (API development, storybook), use MSW directly. If you need scenario management for testing, Scenarist handles that layer.
[Learn more about Scenarist + MSW →](/comparison/with-msw/)
## When to Use What
### Choose Scenarist when…
* Testing **server-side code** (Server Components, API routes, middleware)
* Running **parallel tests** that need different external API states
* Wanting to **switch scenarios at runtime** without restarts
* Using **Playwright** and want first-class fixtures with type-safe scenario switching
* Need **response sequences** for polling APIs, retry flows, or state machines
* Need **stateful mocks** that capture request data and inject it into responses
* Working in **TypeScript/JavaScript** ecosystems
* Testing how your app handles various **external API scenarios** (errors, timeouts, edge cases)
### When NOT to use Scenarist
Being explicit about when Scenarist isn’t the right choice:
* **Pure client-side SPAs** — If all HTTP calls originate in the browser, [Playwright’s built-in mocks](/comparison/vs-playwright-mocks/) may be simpler
* **Java/Python/.NET shops** — WireMock’s ecosystem is better suited for non-JavaScript teams
* **Need recording/playback** — Use Nock (nockBack) or WireMock if you need to capture real API responses
* **Simple unit tests without parallelism** — Nock is lighter-weight if you don’t need scenario management
* **Database testing** — Scenarist mocks HTTP only. See [Testing Database Apps](/guides/testing-database-apps/) for database strategies
* **Contract testing** — Use WireMock with Spring Cloud Contract or similar frameworks
### Choose WireMock when…
* Working in **non-JavaScript environments** (Java, Python, .NET)
* Need **recording/playback** of real API interactions
* Want a **standalone mock server** independent of your test process
* Team is already familiar with WireMock’s ecosystem
[Compare: Scenarist vs WireMock →](/comparison/vs-wiremock/)
### Choose Nock when…
* Writing **simple unit tests** with per-test mock setup
* Don’t need **parallel test isolation**
* Prefer **lighter-weight** approach without framework adapters
* Already using Nock and don’t need scenario management
[Compare: Scenarist vs Nock →](/comparison/vs-nock/)
### Choose Testcontainers when…
* Testing against **real databases** (PostgreSQL, MongoDB)
* Need **actual service behavior**, not mocks
* Testing **infrastructure integration** (Redis, Kafka, Elasticsearch)
* Want **production-like** environment in tests
Testcontainers and Scenarist solve different problems—they’re often **complementary**. Many teams use both: Testcontainers for databases, Scenarist for external HTTP APIs.
Database Testing with Scenarist
If your app uses databases, see our [Testing Database Apps](/guides/testing-database-apps/) guide for comprehensive options including:
* **[Repository Pattern](/guides/testing-database-apps/repository-pattern/)** — Test ID isolation for databases (our recommendation)
* **[Testcontainers Hybrid](/guides/testing-database-apps/testcontainers-hybrid/)** — Use both tools together
* **[Parallelism Options](/guides/testing-database-apps/parallelism-options/)** — Compare all approaches and trade-offs
[Compare: Scenarist vs Testcontainers →](/comparison/vs-testcontainers/)
### Choose Playwright’s built-in `page.route()` when…
* Testing **client-side only** applications (SPAs)
* All HTTP calls originate from the **browser**
* Don’t have **server-side rendering** or Server Components
* Want **zero additional dependencies** for simple browser-side mocking
Note: This refers to Playwright’s native [`page.route()`](https://playwright.dev/docs/mock) API for browser-side request interception—not Scenarist’s `@scenarist/playwright-helpers` package, which provides test fixtures for server-side scenario management.
[Compare: Scenarist vs Playwright Mocks →](/comparison/vs-playwright-mocks/)
## Detailed Comparisons
[Scenarist + MSW](/comparison/with-msw/)How Scenarist builds on MSW and when to use each
[vs WireMock](/comparison/vs-wiremock/)In-process vs server-based mocking approaches
[vs Nock](/comparison/vs-nock/)Declarative scenarios vs imperative per-test setup
[vs Testcontainers](/comparison/vs-testcontainers/)Mocking external APIs vs running real services
[vs Playwright Mocks](/comparison/vs-playwright-mocks/)Server-side vs browser-side request interception
## Making the Decision
Still not sure? Here’s a decision tree:
**1. Are you testing server-side code (Server Components, API routes)?**
* Yes → Scenarist, WireMock, or Nock (not Playwright mocks)
* No (client-side only) → Playwright mocks may be sufficient
**2. Do you need parallel tests with different scenarios?**
* Yes → Scenarist (built-in isolation) or WireMock (multiple instances)
* No → Any tool works
**3. Do you need to switch scenarios during a test?**
* Yes → Scenarist (single API call) or WireMock (Admin API)
* No → Any tool works
**4. Are you using Playwright for E2E testing?**
* Yes → Scenarist (first-class fixtures with type-safe scenarios and automatic test ID isolation)
* No → Any tool works
**5. Do you need response sequences or stateful mocks?**
* Yes → Scenarist (built-in, per-test-ID isolation) or WireMock (built-in Scenarios)
* No → Any tool works
**6. Are you in a non-JavaScript environment?**
* Yes → WireMock
* No → Scenarist or Nock
**7. Does your app use databases alongside external HTTP APIs?**
* Yes → See [Testing Database Apps](/guides/testing-database-apps/) for options:
* [Repository Pattern](/guides/testing-database-apps/repository-pattern/) for test ID isolation (recommended)
* [Testcontainers Hybrid](/guides/testing-database-apps/testcontainers-hybrid/) for real database + mocked APIs
* No (HTTP APIs only) → Scenarist handles everything
# Scenarist vs Nock
> Compare declarative scenario management with imperative per-test mock setup
[Nock](https://github.com/nock/nock) is a popular HTTP mocking library for Node.js. Since v14, Nock uses [@mswjs/interceptors](https://github.com/mswjs/interceptors)—the same interception engine as MSW—making it robust and modern. Scenarist takes a different approach—declarative scenario definitions with built-in parallel test isolation.
Same Interception, Different Management
Both Nock (v14+) and Scenarist use @mswjs/interceptors under the hood—the interception quality is identical. The difference is purely in scenario management: how you define mocks, isolate tests, and switch between states.
## What Scenarist Offers
Before diving into comparisons, here's what Scenarist brings to the table:
* **[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
## At a Glance
| Aspect | Scenarist | Nock |
| ------------------------ | ---------------------------- | ------------------------------- |
| **Approach** | Declarative scenarios | Imperative per-test setup |
| **Test isolation** | Per-test via test ID | Manual scope management |
| **Interception level** | Network (MSW) | @mswjs/interceptors (v14+) |
| **Runtime switching** | Yes (API call) | Manual (cleanAll + reconfigure) |
| **Browser support** | Node.js only (server-side) | Node.js only |
| **Setup** | Framework adapters | Standalone library |
| **TypeScript** | Native (scenarios are TS) | Built-in definitions |
| **Recording** | Not supported | nockBack fixture recording |
| **RSC scenario testing** | ✓ (server-side interception) | ✗ (process isolation issue) |
## Key Differences
### Declarative vs Imperative
* Scenarist
```typescript
// Declarative - describe what, not how
const scenarios = {
'user-premium': {
id: 'user-premium', name: 'Premium user', description: 'Active subscription',
mocks: [{
method: 'GET',
url: 'https://api.stripe.com/v1/customers/cus_123',
response: {
status: 200,
body: { id: 'cus_123', subscriptions: { data: [{ status: 'active' }] } }
}
}]
},
'user-free': {
id: 'user-free', name: 'Free user', description: 'No subscriptions',
mocks: [{
method: 'GET',
url: 'https://api.stripe.com/v1/customers/cus_123',
response: {
status: 200,
body: { id: 'cus_123', subscriptions: { data: [] } }
}
}]
}
} as const satisfies ScenaristScenarios;
// Tests select scenarios by name
test('premium features visible', async ({ page, switchScenario }) => {
await switchScenario(page, 'user-premium');
});
```
* Nock
```typescript
// Imperative - describe how to respond
beforeEach(() => {
nock.cleanAll();
});
test('premium features visible', async () => {
// Set up mock inline for this test
nock('https://api.stripe.com')
.get('/v1/customers/cus_123')
.reply(200, {
id: 'cus_123',
subscriptions: { data: [{ status: 'active' }] }
});
// Test code...
});
test('free features visible', async () => {
// Different setup for this test
nock('https://api.stripe.com')
.get('/v1/customers/cus_123')
.reply(200, {
id: 'cus_123',
subscriptions: { data: [] }
});
// Test code...
});
```
**Trade-off:** Scenarist’s declarative approach makes scenarios reusable and inspectable—you can see all scenarios in one place. Nock’s imperative approach is more flexible for one-off mocks but can lead to duplication.
### Parallel Test Isolation
* Scenarist
```typescript
// Built-in test isolation via test ID
test.describe.parallel('Payment flows', () => {
test('success flow', async ({ page, switchScenario }) => {
// x-scenarist-test-id header routes to this scenario
await switchScenario(page, 'payment-success');
// Other tests can run simultaneously with different scenarios
});
test('failure flow', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-declined');
// Same server, same time, isolated by test ID
});
});
```
* Nock
```typescript
// Manual isolation - requires careful scoping
test.describe('Payment flows', () => {
// Serial execution to avoid conflicts
test.describe.serial('success flow', () => {
test.beforeEach(() => {
nock.cleanAll();
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(200, { status: 'succeeded' });
});
test('shows success', async () => { /* ... */ });
});
test.describe.serial('failure flow', () => {
test.beforeEach(() => {
nock.cleanAll();
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(402, { error: 'declined' });
});
test('shows error', async () => { /* ... */ });
});
});
// Parallel tests with Nock require careful isolation
// or separate test processes
```
**Trade-off:** Scenarist was designed specifically for parallel test isolation. Nock can work with parallel tests but requires careful scope management to avoid mock leakage between tests.
### Request Matching
* Scenarist
```typescript
// Declarative matching patterns
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
match: {
body: {
amount: '5000',
currency: 'usd'
},
headers: {
'idempotency-key': /^[a-f0-9-]+$/
}
},
response: {
status: 200,
body: { id: 'ch_123' }
}
}
```
* Nock
```typescript
// Fluent API for matching
nock('https://api.stripe.com')
.post('/v1/charges', {
amount: 5000,
currency: 'usd'
})
.matchHeader('idempotency-key', /^[a-f0-9-]+$/)
.reply(200, { id: 'ch_123' });
// Or with function matchers
nock('https://api.stripe.com')
.post('/v1/charges', (body) => body.amount > 0)
.reply(200, { id: 'ch_123' });
```
**Trade-off:** Both offer rich matching capabilities. Nock allows function matchers for maximum flexibility. Scenarist’s declarative patterns are more restrictive but enable inspection and composition.
### Scope and Cleanup
* Scenarist
```typescript
// Scenarios persist - switch between them
test('multi-step flow', async ({ page, switchScenario }) => {
// Start with error
await switchScenario(page, 'payment-timeout');
await page.click('#submit');
// Switch to success for retry
await switchScenario(page, 'payment-success');
await page.click('#retry');
// No cleanup needed - test ID isolation
});
```
* Nock
```typescript
// Interceptors are consumed once by default
test('multi-step flow', async () => {
// First call
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(504, 'timeout');
await page.click('#submit');
// Interceptor consumed - need another
nock('https://api.stripe.com')
.post('/v1/charges')
.reply(200, { status: 'succeeded' });
await page.click('#retry');
// Can use .persist() for multiple calls
// nock(...).persist().reply(...)
});
```
**Trade-off:** Nock’s one-time consumption is explicit about expected call counts. Scenarist’s persistent scenarios are simpler for flows where the same endpoint is called multiple times.
### Server Component Testing: The Process Isolation Problem
Nock intercepts requests in the **current Node.js process**. In E2E testing with Playwright, your test runs in one process while the Next.js server runs in a **separate process**—Nock in the test process can’t intercept requests from the server process.
* E2E Testing (Nock Fails)
```typescript
// ❌ E2E Test - FAILS
// Test process
import nock from 'nock';
nock('https://api.stripe.com')
.get('/v1/products')
.reply(200, { products: [] });
// Playwright launches browser, browser requests page from Next.js server
// Next.js server (SEPARATE PROCESS) fetches from Stripe
// Nock never sees it - the request goes to real Stripe
await page.goto('/products'); // Fails or uses real API
```
* Integration Testing (Nock Works)
```typescript
// ✅ Integration Test - WORKS (same process)
import nock from 'nock';
import { renderToString } from 'react-dom/server';
import ProductsPage from './app/products/page';
nock('https://api.stripe.com')
.get('/v1/products')
.reply(200, { products: [{ id: 'prod_1' }] });
// Rendering happens in the SAME process as the test
const html = await renderToString();
expect(html).toContain('prod_1'); // ✓ Works
```
* Scenarist E2E (Works)
```typescript
// ✅ E2E Test - WORKS
// Scenarist runs inside the Next.js server process
// Test ID header routes requests to correct scenario
test('shows products', async ({ page, switchScenario }) => {
await switchScenario(page, 'products-available');
await page.goto('/products');
// Next.js server's fetch is intercepted by MSW (Scenarist)
await expect(page.locator('.product')).toHaveCount(3);
});
```
**Key insight:** Scenarist runs inside your application server (via framework adapters), so it intercepts requests where they originate. Nock runs in your test process and can only intercept requests made from that process.
## When to Choose Scenarist
Scenario-based testing with Server Components
Scenarist’s server-side interception works across process boundaries. Nock can’t intercept requests from a separate Next.js server process.
Parallel test isolation
Built-in test ID system enables hundreds of tests with different scenarios running simultaneously.
Scenario libraries
Define scenarios once, reuse everywhere. Changes in one place update all tests.
Runtime switching
Change scenarios mid-test without cleanup/setup. Test retry flows, state machines.
## When to Choose Nock
Integration tests (same process)
When test and server run in the same process, Nock works great. E2E with separate processes—use Scenarist.
Fixture recording (nockBack)
Record real HTTP interactions to JSON files, replay in tests. Great for contract snapshots.
Call counting
Built-in assertions on call counts. Verify exactly N requests were made.
Existing investment
Team already uses Nock. Migration cost outweighs benefits for integration tests.
## Migration Considerations
### From Nock to Scenarist
```typescript
// Nock - inline definitions
beforeEach(() => {
nock('https://api.stripe.com')
.get('/v1/customers/cus_123')
.reply(200, { id: 'cus_123', name: 'Test Customer' });
nock('https://api.sendgrid.com')
.post('/v3/mail/send')
.reply(202);
});
// Scenarist - centralized scenarios
const scenarios = {
default: {
id: 'default', name: 'Default', description: 'Baseline responses',
mocks: [
{
method: 'GET',
url: 'https://api.stripe.com/v1/customers/cus_123',
response: { status: 200, body: { id: 'cus_123', name: 'Test Customer' } }
},
{
url: 'https://api.sendgrid.com/v3/mail/send',
method: 'POST',
response: { status: 202 }
}
]
}
} as const satisfies ScenaristScenarios;
```
**Migration benefits:**
* Scenarios become visible in one place
* Test isolation improves automatically
* Runtime switching becomes possible
**Migration costs:**
* Learn declarative patterns instead of fluent API
* Convert inline mocks to scenarios
* Set up framework adapter
## Summary
| Factor | Scenarist | Nock |
| --------------------- | --------------------- | ------------------------- |
| RSC scenario testing | ✓ Works (server-side) | ✗ Process isolation issue |
| Parallel isolation | ✓ Built-in (test ID) | Manual |
| Scenario reuse | ✓ Declarative | Manual extraction |
| Runtime switching | ✓ Single API call | Reconfigure handlers |
| TypeScript | ✓ Native | ✓ Built-in definitions |
| Recording | Not supported | ✓ nockBack |
| Function matchers | Declarative patterns | ✓ Full flexibility |
| Call count assertions | Not built-in | ✓ Built-in |
| Integration tests | ✓ Works | ✓ Works |
Why No Recording?
Scenarist intentionally doesn’t support recording. Scenarios are **declarative definitions** of expected behavior—you define what your app should handle upfront, not capture what happened to happen during a test run. This makes scenarios reviewable, version-controllable, and independent of external API availability. If you need recorded fixtures, Nock’s nockBack is excellent.
**Bottom line:** Choose Scenarist for scenario-based testing with Server Components (where Nock can’t reach across processes), parallel test isolation, and runtime switching. Choose Nock for integration tests in the same process, when you need fixture recording (nockBack), or when you need function matchers and call counting.
# Scenarist vs Playwright Mocks
> Compare server-side request interception with browser-side mocking for modern web applications
Playwright includes [built-in request interception](https://playwright.dev/docs/mock) via `page.route()` and HAR file recording. This works great for client-side requests. Scenarist intercepts requests on the server—essential for scenario-based testing of Server Components, API routes, and middleware.
Terminology clarification
This page compares Scenarist with **Playwright’s built-in `page.route()` API**—browser-side request interception. This is different from Scenarist’s `@scenarist/playwright-helpers` package, which provides Playwright test fixtures for server-side scenario management. The two can work together: use Scenarist for server-side mocking with first-class Playwright fixtures, and `page.route()` for browser-specific needs.
## What Scenarist Offers
Before diving into comparisons, here's what Scenarist brings to the table:
* **[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
## At a Glance
| Aspect | Scenarist | Playwright Mocks |
| ------------------------ | --------------------------- | ---------------------- |
| **Intercepts at** | Server (Node.js) | Browser |
| **Server Components** | ✓ Full support | ✗ Cannot intercept |
| **API Routes** | ✓ Full support | ✗ Cannot intercept |
| **Client-side fetch** | Via your server routes only | ✓ Yes |
| **Setup** | Framework adapter | Built into Playwright |
| **Test isolation** | Per-test via test ID | Per-page |
| **HAR recording** | Not supported | ✓ Built-in |
| **Next.js 15 testProxy** | Not needed (built-in) | ✓ Experimental support |
## The Critical Difference: Where Requests Originate
```plaintext
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Browser Server (Node.js) │
│ ──────── ───────────────── │
│ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Client │ HTTP Request │ Server Component │ │
│ │ Component │ ──────────────► │ │ │
│ │ │ │ const data = await │ │
│ │ onClick: │ │ fetch('https:// │ │
│ │ fetch('/api') │ api.stripe.com') │ │
│ │ │ │ │ │
│ └──────┬───────┘ └──────────┬───────────┘ │
│ │ │ │
│ │ ◄── Playwright │ │
│ │ can intercept │ ◄── Scenarist │
│ │ │ intercepts │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ External API │ │ External API │ │
│ │ (from │ │ (from server) │ │
│ │ browser) │ │ │ │
│ └──────────────┘ └──────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
**Playwright’s `page.route()`** intercepts requests that originate in the browser.
**Scenarist** intercepts requests that originate on the server (Node.js).
For Server Components, API routes, and middleware—the server makes HTTP requests that never touch the browser. Playwright can’t see them.
## Example: Server Component
* Scenarist (Works)
```typescript
// app/checkout/page.tsx - Server Component
export default async function CheckoutPage() {
// This fetch runs on the SERVER
const products = await fetch('https://api.stripe.com/v1/products');
const data = await products.json();
return ;
}
// checkout.spec.ts
test('displays products', async ({ page, switchScenario }) => {
// Scenarist intercepts the server-side fetch
await switchScenario(page, 'products-available');
await page.goto('/checkout');
// Server Component received mocked Stripe response
await expect(page.locator('.product')).toHaveCount(3);
});
```
* Playwright Mocks (Cannot Work)
```typescript
// app/checkout/page.tsx - Server Component
export default async function CheckoutPage() {
// This fetch runs on the SERVER
const products = await fetch('https://api.stripe.com/v1/products');
const data = await products.json();
return ;
}
// checkout.spec.ts
test('displays products', async ({ page }) => {
// ❌ This CANNOT intercept server-side fetches
await page.route('**/api.stripe.com/**', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ products: [...] })
});
});
await page.goto('/checkout');
// The Server Component already fetched from real Stripe
// Browser route handler never saw the request
// Test sees real Stripe data (or fails if no API key)
});
```
## Playwright’s Additional Features
### HAR File Recording
Playwright can record and replay HTTP traffic using HAR (HTTP Archive) files:
```typescript
// Record HAR during test
await page.routeFromHAR('recording.har', {
url: '**/api/**',
update: true, // Record mode
});
// Replay HAR in subsequent tests
await page.routeFromHAR('recording.har', {
url: '**/api/**',
update: false, // Replay mode
});
```
**Limitation:** HAR only captures browser-level requests. Server-side requests (Server Components, API routes) are never recorded because they don’t pass through the browser.
### Next.js 15 Experimental testProxy
Next.js 15 introduced an experimental `testProxy` feature that proxies server-side fetch calls, allowing MSW to intercept them:
next.config.js
```javascript
module.exports = {
experimental: {
testProxy: true,
},
}
```
**Status:** Experimental with known limitations—doesn’t intercept internal route handler calls (fetches using relative URLs within the same app), and some font loading issues reported.
**Why Scenarist is still valuable:** Scenarist provides scenario management, test ID isolation, runtime switching, and Playwright fixtures on top of MSW—features you’d need to build yourself even with testProxy.
## When Each Tool Works
### Playwright Mocks Work For
Client-side SPAs
React, Vue, Angular apps where all fetches originate in the browser.
Client Components
Next.js Client Components that fetch data on the client side.
Static sites
Pre-rendered pages where dynamic data is fetched client-side.
Zero-config mocking
Built into Playwright—no additional setup required.
### Scenarist Works For
Server Components
Next.js/React Server Components that fetch data during SSR.
API Routes
Next.js API routes, Express endpoints that call external APIs.
Middleware
Authentication middleware, edge functions that make HTTP calls.
Mixed architectures
Apps with both client and server-side data fetching.
## Code Comparison
### Client-Side Fetch (Both Work)
* Playwright Mocks
```typescript
// components/UserProfile.tsx - Client Component
'use client';
export function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
// Client-side fetch - browser makes the request
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
return
{user?.name}
;
}
// profile.spec.ts
test('shows user name', async ({ page }) => {
// ✓ Works - intercepts browser request
await page.route('**/api/user', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ name: 'Test User' })
});
});
await page.goto('/profile');
await expect(page.locator('div')).toContainText('Test User');
});
```
* Scenarist
profile.spec.ts
```typescript
// Same client component
test('shows user name', async ({ page, switchScenario }) => {
// ✓ Works - /api/user runs on your server, and Scenarist intercepts
// the external API calls it makes (browser requests are not intercepted)
await switchScenario(page, 'user-profile');
await page.goto('/profile');
await expect(page.locator('div')).toContainText('Test User');
});
```
### Server-Side Fetch (Only Scenarist Works)
* Scenarist (Works)
```typescript
// app/dashboard/page.tsx - Server Component
export default async function Dashboard() {
// Server-side fetch - runs in Node.js
const analytics = await fetch('https://api.analytics.com/data', {
headers: { 'Authorization': `Bearer ${process.env.ANALYTICS_KEY}` }
});
return ;
}
// dashboard.spec.ts
test('displays analytics', async ({ page, switchScenario }) => {
// ✓ Scenarist intercepts server-side fetch
await switchScenario(page, 'analytics-data');
await page.goto('/dashboard');
await expect(page.locator('.chart')).toBeVisible();
});
```
* Playwright Mocks (Cannot Work)
dashboard.spec.ts
```typescript
// Same Server Component
test('displays analytics', async ({ page }) => {
// ❌ Cannot intercept - request never reaches browser
await page.route('**/api.analytics.com/**', route => {
route.fulfill({ /* ... */ });
});
await page.goto('/dashboard');
// Server already made real request to analytics API
// Test fails or shows real data
});
```
## Using Both Together
For apps with both client and server-side fetching, you might use both tools:
```typescript
// Mixed architecture test
test('complete user flow', async ({ page, switchScenario }) => {
// Scenarist handles server-side fetches (Server Components, API routes)
await switchScenario(page, 'user-flow-success');
await page.goto('/dashboard'); // Server Component fetches analytics
// Playwright route for a specific client-side mock
// (e.g., a third-party widget that loads in browser)
await page.route('**/widget.thirdparty.com/**', route => {
route.fulfill({ status: 200, body: '{}' });
});
await page.click('#load-widget');
await expect(page.locator('.widget')).toBeVisible();
});
```
Scenarist intercepts requests made by your server, including those triggered by client-side calls to your own API routes. Requests the browser sends directly to third-party hosts never reach your server, so use Playwright mocks for those.
## Decision Guide
**Are you testing Server Components, API routes, or middleware?**
* Yes → Use Scenarist (Playwright mocks cannot intercept these)
**Is your app a pure client-side SPA?**
* Yes → Playwright mocks may be sufficient
**Do you need parallel test isolation with different scenarios?**
* Yes → Use Scenarist (built-in test ID isolation)
**Do you want zero additional setup?**
* Yes, for simple cases → Playwright mocks are built-in
* For comprehensive testing → Scenarist is worth the setup
## Summary
| Capability | Scenarist | Playwright Mocks |
| ------------------- | ---------------------- | ----------------------------- |
| Server Components | ✓ | ✗ (or experimental testProxy) |
| API Routes | ✓ | ✗ |
| Middleware | ✓ | ✗ |
| SSR data fetching | ✓ | ✗ |
| Client-side fetch | Via server routes only | ✓ |
| Zero setup | Framework adapter | ✓ Built-in |
| Parallel isolation | ✓ Per-test-ID | Per-page |
| Scenario management | ✓ | Manual |
| HAR recording | Not supported | ✓ |
| Runtime switching | ✓ | Manual |
**Bottom line:** For scenario-based testing of modern server-rendered applications (Next.js App Router, Remix, etc.), Scenarist provides server-side request interception with scenario management and test ID isolation. Playwright mocks are great for pure client-side apps, HAR recording, or supplementing Scenarist for browser-specific needs. Next.js 15’s experimental testProxy may help with server-side interception, but Scenarist still provides the scenario management layer.
# Scenarist vs Testcontainers
> Mocking external APIs vs running real containerized services - complementary approaches
[Testcontainers](https://testcontainers.com/) runs real services in Docker containers for testing—PostgreSQL, Redis, Kafka, and more. Scenarist mocks HTTP APIs. These tools solve **different problems** and are often **complementary** rather than competing.
## What Scenarist Offers
Before diving into comparisons, here's what Scenarist brings to the table:
* **[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
## Different Problems, Different Solutions
```plaintext
┌─────────────────────────────────────────────────────────────────┐
│ Your Application │
│ │
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ │ │ │ │
│ │ Database │ │ External APIs │ │
│ │ PostgreSQL │ │ Stripe, Auth0, │ │
│ │ Redis │ │ SendGrid, Twilio │ │
│ │ MongoDB │ │ │ │
│ │ │ │ │ │
│ └────────┬────────┘ └────────────┬─────────────┘ │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ Testcontainers │ │ Scenarist │ │
│ │ │ │ │ │
│ │ Real services │ │ Mocked responses │ │
│ │ in containers │ │ Scenario-based │ │
│ └─────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
| Aspect | Testcontainers | Scenarist |
| ------------------ | ------------------------- | ------------------------------ |
| **Purpose** | Run real services | Mock HTTP APIs |
| **Use case** | Databases, message queues | Third-party APIs |
| **What runs** | Actual software in Docker | Your app with mocked responses |
| **Fidelity** | Real behavior | Controlled scenarios |
| **Startup time** | Seconds to minutes | Instant |
| **Resource usage** | Container per service | In-process |
## When Each Tool Applies
### Testcontainers is for…
Database testing
PostgreSQL, MySQL, MongoDB, Redis. Test real queries, migrations, transactions.
Message queues
Kafka, RabbitMQ, SQS (LocalStack). Test actual message processing.
Infrastructure
Elasticsearch, MinIO, Keycloak. Test integrations with real services.
Contract validation
Verify your code works with the actual database version you’ll deploy.
### Scenarist is for…
External HTTP APIs
Stripe, Auth0, SendGrid, Twilio. APIs you don’t control and can’t run locally.
Error scenarios
Test how your app handles API timeouts, rate limits, specific error codes.
Parallel testing
Run many tests simultaneously with different API states.
Edge cases
Scenarios you can’t easily create with real services.
## Example: Using Both Together
A typical application might use **both** tools:
test-setup.ts
```typescript
// Testcontainers for your database
import { PostgreSqlContainer } from "@testcontainers/postgresql";
const postgres = await new PostgreSqlContainer().withDatabase("testdb").start();
// Your app connects to real PostgreSQL in Docker
process.env.DATABASE_URL = postgres.getConnectionUri();
// Scenarist for external APIs
// Stripe, SendGrid, etc. are mocked - they don't have containers
export const scenarist = createScenarist({
enabled: true,
scenarios: {
default: {
id: "default",
name: "Default",
description: "Baseline responses",
mocks: [
{
url: "https://api.stripe.com/v1/charges",
method: "POST",
response: { status: 200, body: { id: "ch_123" } },
},
{
url: "https://api.sendgrid.com/v3/mail/send",
method: "POST",
response: { status: 202 },
},
],
},
},
});
```
checkout.spec.ts
```typescript
test("creates order and sends confirmation", async ({
page,
switchScenario,
}) => {
// Database operations use real PostgreSQL (Testcontainers)
// Stripe payment uses mocked API (Scenarist)
// SendGrid email uses mocked API (Scenarist)
await switchScenario(page, "payment-success");
await page.goto("/checkout");
await page.fill('[name="card"]', "4242424242424242");
await page.click("#submit");
// Real database write happened
// Mocked Stripe returned success
// Mocked SendGrid accepted email
await expect(page.locator(".success")).toBeVisible();
// Verify database state (real PostgreSQL)
const order = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]);
expect(order.status).toBe("completed");
});
```
## Can Testcontainers Mock HTTP APIs?
**Yes, but with more complexity.** Testcontainers has modules for HTTP mock servers:
| Module | Package | Status |
| ---------- | ---------------------------------------- | ---------------------------------------------------------------- |
| MockServer | `@testcontainers/mockserver` | Official |
| WireMock | `@wiremock/wiremock-testcontainers-node` | [Official Partner](https://testcontainers.com/modules/wiremock/) |
```typescript
// Testcontainers with WireMock for HTTP mocking
import { WireMockContainer } from "@wiremock/wiremock-testcontainers-node";
const container = await new WireMockContainer()
.withMapping({
request: { method: "GET", urlPath: "/v1/products" },
response: { status: 200, jsonBody: { products: [] } },
})
.start();
// Point your app at the container
process.env.STRIPE_API_URL = container.getBaseUrl();
```
**Trade-offs vs Scenarist:**
| Factor | Testcontainers + WireMock | Scenarist |
| ----------------- | ----------------------------- | ------------------------ |
| Setup | Docker + container management | npm install |
| Startup time | Seconds (container spin-up) | Instant (in-process) |
| Test isolation | Per-container (heavier) | Per-header (lightweight) |
| Runtime switching | Admin API | Single API call |
| Network config | Required (proxy/env vars) | None (in-process) |
**When HTTP mocking via Testcontainers makes sense:**
* You’re already heavily invested in Testcontainers
* You want identical mocking approach to your Java/Go services
* You need WireMock’s specific features (recording, contract testing)
**When Scenarist is simpler:**
* You want the lightest possible setup
* You’re already in a Node.js/TypeScript environment
* You value instant test startup over container isolation
## Why Not Use Scenarist for Everything?
Mocking databases is possible but loses fidelity:
* Mocked Database (Limited)
```typescript
// You could mock database API calls...
{
url: 'http://localhost:5432/query',
match: { body: { sql: /SELECT.*FROM users/ } },
response: {
body: { rows: [{ id: 1, email: 'test@example.com' }] }
}
}
// But you lose:
// - Real SQL query execution
// - Database constraints
// - Transaction behavior
// - Migration testing
// - Performance characteristics
```
* Real Database (Better)
```typescript
// Testcontainers gives you the real thing
const postgres = await new PostgreSqlContainer().start();
// Real queries against real PostgreSQL
const result = await db.query(`
SELECT * FROM users
WHERE email = $1
AND deleted_at IS NULL
`, ['test@example.com']);
// Real constraints, real behavior
// Your ORM/query builder works exactly like production
```
**Rule of thumb:**
* **Use Testcontainers** for services you own or can run locally
* **Use Scenarist** for services you don’t control (third-party APIs)
## Comparison Summary
| Factor | Testcontainers | Scenarist |
| ------------------- | ------------------------- | -------------------------- |
| Databases | ✓ Best choice | Not recommended |
| Message queues | ✓ Best choice | Not recommended |
| HTTP API mocking | ✓ Via WireMock/MockServer | ✓ Built-in |
| Setup complexity | Docker + containers | npm install |
| Startup time | Seconds (containers) | ✓ Instant |
| Resource usage | Higher (containers) | ✓ Minimal |
| Fidelity | ✓ Real behavior | Controlled |
| Parallel isolation | Per-container | ✓ Per-header (lightweight) |
| Runtime switching | Admin API | ✓ Single API call |
| Playwright fixtures | Manual setup | ✓ First-class support |
## Hybrid Architecture
For comprehensive testing, use both:
```plaintext
┌─────────────────────────────────────────────────────────────────┐
│ Test Suite │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Your Application │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────────┐ │ │
│ │ │ Database │ │ External APIs │ │ │
│ │ │ Layer │ │ Layer │ │ │
│ │ └──────┬───────┘ └────────┬─────────┘ │ │
│ └───────────┼─────────────────────────────┼───────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────┐ ┌────────────────────────────┐ │
│ │ Testcontainers │ │ Scenarist │ │
│ │ │ │ │ │
│ │ • PostgreSQL │ │ • Stripe (mocked) │ │
│ │ • Redis │ │ • Auth0 (mocked) │ │
│ │ • Kafka │ │ • SendGrid (mocked) │ │
│ │ │ │ │ │
│ │ Real services │ │ Controlled scenarios │ │
│ │ Real behavior │ │ Parallel isolation │ │
│ └───────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
**This hybrid approach gives you:**
* Real database behavior (Testcontainers)
* Controlled external API scenarios (Scenarist)
* Comprehensive test coverage
* Fast parallel execution (Scenarist’s test ID isolation)
See our [Testcontainers Hybrid guide](/guides/testing-database-apps/testcontainers-hybrid/) for detailed setup instructions.
## Bottom Line
**Testcontainers and Scenarist solve different problems.** Use Testcontainers for services you can run locally (databases, queues). Use Scenarist for services you can’t control (third-party APIs). For many applications, you’ll use both together.
# Scenarist vs WireMock
> Compare in-process network mocking with server-based mock servers for API testing
[WireMock](https://wiremock.org/) is a mature, widely-adopted mock server for simulating HTTP APIs. It runs as a standalone Java process and can be used from any language. Scenarist takes a different approach—intercepting requests within your Node.js process using MSW.
## What Scenarist Offers
Before diving into comparisons, here's what Scenarist brings to the table:
* **[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
## At a Glance
| Aspect | Scenarist | WireMock |
| ------------------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Architecture** | In-process (MSW) | Standalone server |
| **Language** | TypeScript/JavaScript | Language-agnostic (Node.js via [wiremock](https://www.npmjs.com/package/wiremock) CLI or [wiremock-captain](https://www.npmjs.com/package/wiremock-captain) client) |
| **Test isolation** | Per-test via header | Per-server instance or Scenarios |
| **Runtime switching** | Yes (single API call) | Yes (Admin API) |
| **Setup** | npm install | JAR, Docker, or npm + network configuration |
| **Test framework integration** | First-class Playwright fixtures | Generic HTTP API |
| **Response sequences** | Built-in (polling, state machines) | Built-in (Scenarios feature) |
| **Stateful mocks** | Built-in capture/inject per test ID | Built-in state machine |
| **Recording** | Not supported | Built-in record/playback |
| **Templating** | Template strings | Handlebars templating |
## Key Differences
### Architecture: In-Process vs Standalone
* Scenarist
```typescript
// Mocks run in the same process as your app
import { createScenarist } from '@scenarist/express-adapter';
export const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios: {
default: {
id: "default",
name: "Default",
description: "Baseline responses",
mocks: [
{
url: "https://api.stripe.com/v1/charges",
method: "POST",
response: { status: 200, body: { id: "ch_123" } },
},
],
},
},
});
// No external process to manage
// No network latency between your app and mocks
// Debugging in the same process
```
* WireMock
```java
// Standalone server - must be started separately
// docker run -p 8080:8080 wiremock/wiremock
// Then configure via API or JSON files
{
"request": {
"method": "POST",
"url": "/v1/charges"
},
"response": {
"status": 200,
"jsonBody": { "id": "ch_123" }
}
}
// Your app calls WireMock instead of real API
// STRIPE_API_URL=http://localhost:8080 npm test
```
**Trade-off:** Scenarist’s in-process approach means simpler setup—just npm install and configure. No separate server process, no network configuration to route your app’s requests. WireMock’s server approach works with any language (via JAR, Docker, or [npm package](https://www.npmjs.com/package/wiremock)) but requires running a separate process and configuring your app to route requests to the mock server.
### Test Isolation
* Scenarist
```typescript
// Each test gets isolated scenarios via test ID
test('payment success', async ({ page, switchScenario }) => {
// This test sees 'payment-success' responses
await switchScenario(page, 'payment-success');
await page.goto('/checkout');
await expect(page.locator('.success')).toBeVisible();
});
test('payment declined', async ({ page, switchScenario }) => {
// Same app, same time, different scenario
await switchScenario(page, 'payment-declined');
await page.goto('/checkout');
await expect(page.locator('.error')).toBeVisible();
});
// Both tests run in parallel against ONE server
// Test ID header routes to correct scenario
```
* WireMock
```typescript
// Each test typically needs its own WireMock instance
// Or careful state management between tests
beforeEach(async () => {
// Reset all stubs
await fetch('http://localhost:8080/__admin/mappings/reset', {
method: 'POST'
});
// Configure for this specific test
await fetch('http://localhost:8080/__admin/mappings', {
method: 'POST',
body: JSON.stringify({
request: { method: 'POST', url: '/v1/charges' },
response: { status: 200, jsonBody: { id: 'ch_123' } }
})
});
});
// Parallel tests require multiple WireMock instances
// Or sophisticated scenario management
```
**Trade-off:** Scenarist’s test ID system was designed specifically for parallel test isolation. WireMock can achieve similar results but requires more infrastructure (multiple instances) or careful state management.
### Runtime Scenario Switching
* Scenarist
```typescript
// Switch scenarios mid-test without restart
test('retry after failure', async ({ page, switchScenario }) => {
// Start with failure
await switchScenario(page, 'payment-timeout');
await page.goto('/checkout');
await page.click('[data-testid="submit"]');
await expect(page.locator('.retry-button')).toBeVisible();
// Switch to success - same test, same page
await switchScenario(page, 'payment-success');
await page.click('.retry-button');
await expect(page.locator('.success')).toBeVisible();
});
```
* WireMock
```typescript
// Changing scenarios requires API calls or restart
test('retry after failure', async ({ page }) => {
// Set up failure scenario
await fetch('http://localhost:8080/__admin/mappings', {
method: 'POST',
body: JSON.stringify({
request: { method: 'POST', url: '/v1/charges' },
response: { status: 504, body: 'Gateway Timeout' }
})
});
await page.goto('/checkout');
await page.click('[data-testid="submit"]');
await expect(page.locator('.retry-button')).toBeVisible();
// Delete and recreate mapping for success
await fetch('http://localhost:8080/__admin/mappings/reset', {
method: 'POST'
});
await fetch('http://localhost:8080/__admin/mappings', {
method: 'POST',
body: JSON.stringify({
request: { method: 'POST', url: '/v1/charges' },
response: { status: 200, jsonBody: { id: 'ch_123' } }
})
});
await page.click('.retry-button');
});
```
**Trade-off:** Both tools support runtime switching. Scenarist uses a single API call that automatically routes by test ID. WireMock’s Admin API is powerful but requires managing stub state and can be more complex in parallel test environments where you need to coordinate which test sees which stubs.
### First-Class Playwright Integration
Scenarist provides dedicated [Playwright fixtures](/testing/playwright-integration/) that handle test ID generation, scenario switching, and header propagation automatically.
* Scenarist
```typescript
// tests/fixtures.ts - One-time setup
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
export const test = withScenarios(scenarios);
export { expect };
// tests/checkout.spec.ts - Clean, type-safe tests
import { test, expect } from './fixtures';
test('premium user checkout', async ({ page, switchScenario }) => {
// Type-safe scenario ID with autocomplete
await switchScenario(page, 'premium-user');
await page.goto('/checkout');
await expect(page.locator('.premium-discount')).toBeVisible();
});
test('payment declined', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-declined');
await page.goto('/checkout');
await page.click('[data-testid="submit"]');
await expect(page.locator('.error-message')).toContainText('declined');
});
```
* WireMock
```typescript
// tests/checkout.spec.ts - Manual setup in each test
import { test, expect } from '@playwright/test';
test('premium user checkout', async ({ page }) => {
// Generate unique ID manually
const testId = `test-${Date.now()}-${Math.random()}`;
// Configure WireMock via HTTP
await fetch('http://localhost:8080/__admin/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
request: { method: 'GET', url: '/api/user' },
response: {
status: 200,
jsonBody: { tier: 'premium' }
}
})
});
// Set headers manually
await page.setExtraHTTPHeaders({ 'x-test-id': testId });
await page.goto('/checkout');
await expect(page.locator('.premium-discount')).toBeVisible();
// Clean up for parallel safety
await fetch('http://localhost:8080/__admin/mappings/reset', {
method: 'POST'
});
});
```
**Trade-off:** Scenarist’s [Playwright helpers](/testing/playwright-integration/) provide automatic test ID isolation—each test gets its own scenario state without manual header management. WireMock requires manual HTTP calls and careful state management. Future Scenarist releases plan to add similar first-class support for Cypress.
### Dynamic Response Features
Both Scenarist and WireMock support dynamic responses, but with different approaches and trade-offs.
#### Response Sequences (Polling, State Machines)
* Scenarist
```typescript
// Built-in sequence support
{
method: 'GET',
url: 'https://api.example.com/job/:id/status',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete', result: 'success' } }
],
repeat: 'last' // Stay at final response
}
}
// Perfect for testing polling UIs
test('shows job progress', async ({ page, switchScenario }) => {
await switchScenario(page, 'job-processing');
await page.goto('/jobs/123');
await expect(page.locator('.status')).toContainText('pending');
await page.click('[data-testid="refresh"]');
await expect(page.locator('.status')).toContainText('processing');
await page.click('[data-testid="refresh"]');
await expect(page.locator('.status')).toContainText('complete');
});
```
* WireMock
```java
// WireMock's built-in Scenarios feature (state machine)
// State 1: Initial
stubFor(get(urlEqualTo("/job/123/status"))
.inScenario("Job Progress")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse()
.withBody("{\"status\": \"pending\"}")
)
.willSetStateTo("processing"));
// State 2: Processing
stubFor(get(urlEqualTo("/job/123/status"))
.inScenario("Job Progress")
.whenScenarioStateIs("processing")
.willReturn(aResponse()
.withBody("{\"status\": \"processing\"}")
)
.willSetStateTo("complete"));
// State 3: Complete
stubFor(get(urlEqualTo("/job/123/status"))
.inScenario("Job Progress")
.whenScenarioStateIs("complete")
.willReturn(aResponse()
.withBody("{\"status\": \"complete\", \"result\": \"success\"}")
));
```
**Trade-off:** Both tools have built-in sequence/state machine support. Scenarist’s approach is declarative and isolated per test ID. WireMock’s Scenarios are powerful state machines but the state is global to the WireMock instance—parallel tests need separate instances or careful coordination.
#### Advanced Request Matching
* Scenarist
```typescript
// Declarative matching with body, headers, query, regex
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
match: {
body: {
amount: '5000',
currency: 'usd',
orderId: /^order-\d+$/ // Regex support (top-level body fields)
},
headers: {
'idempotency-key': /.+/ // Must be present
}
},
response: { status: 200, body: { id: 'ch_premium_123' } }
}
// Specificity-based selection - most specific match wins
// No need to carefully order your mocks
```
* WireMock
```json
{
"request": {
"method": "POST",
"url": "/v1/charges",
"bodyPatterns": [
{ "matchesJsonPath": "$.amount" },
{ "matchesJsonPath": "$.currency" },
{ "matchesJsonPath": "$.metadata.orderId",
"matches": "^order-\\d+$" }
],
"headers": {
"idempotency-key": { "matches": ".+" }
}
},
"response": {
"status": 200,
"jsonBody": { "id": "ch_premium_123" }
}
}
```
#### Stateful Mocks (Capture & Inject)
* Scenarist
```typescript
// Capture state from requests, inject into responses
const scenarios = {
'cart-flow': {
id: 'cart-flow', name: 'Cart flow', description: 'Cart accumulates items',
mocks: [
// Capture item when added to cart
{
method: 'POST',
url: '/api/cart/items',
captureState: {
'cartItems[]': 'body.item' // Append to array
},
response: { status: 201, body: { success: true } }
},
// Inject captured state into response
{
method: 'GET',
url: '/api/cart',
response: {
status: 200,
body: {
items: '{{state.cartItems}}',
count: '{{state.cartItems.length}}'
}
}
}
]
}
};
// Test multi-step flows with stateful behavior
test('cart accumulates items', async ({ page, switchScenario }) => {
await switchScenario(page, 'cart-flow');
await page.goto('/products');
await page.click('[data-product="widget"]');
await page.click('[data-product="gadget"]');
await page.goto('/cart');
await expect(page.locator('.cart-count')).toContainText('2');
});
```
* WireMock
```java
// WireMock Scenarios track state transitions (which state you're in)
// but don't capture/inject arbitrary data from requests
// Built-in: State machine transitions
stubFor(post("/api/cart/items")
.inScenario("Cart")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(201))
.willSetStateTo("has-items"));
stubFor(get("/api/cart")
.inScenario("Cart")
.whenScenarioStateIs("has-items")
.willReturn(aResponse()
.withBody("{\"items\": [...]}"))); // Static response
// For data capture/injection: Custom extension or Handlebars templating
// Handlebars can access request data:
// {{request.body}} or {{jsonPath request.body '$.item'}}
```
**Trade-off:** WireMock’s Scenarios handle state transitions (tracking which state you’re in). Scenarist’s state capture goes further—capturing arbitrary data from requests and injecting it into subsequent responses—with per-test-ID isolation for parallel tests. WireMock can achieve data capture via Handlebars templating but state is global to the instance.
## When to Choose Scenarist
Simple setup
Just npm install and configure scenarios. No separate server process or network configuration required.
TypeScript-first development
Scenarios are TypeScript objects with full type safety. IDE autocomplete, compile-time errors, refactoring support.
Parallel test isolation
Built-in test ID system lets hundreds of tests run simultaneously with different scenarios—no separate instances needed.
First-class Playwright support
Dedicated fixtures with automatic test ID handling. Each test gets its own scenario state with type-safe switching.
Per-test-ID state isolation
Response sequences and stateful mocks are isolated per test ID—parallel tests never share state.
Next.js multi-process handling
Next.js has
[documented singleton issues](https://github.com/vercel/next.js/discussions/68572)
that break MSW. Scenarist’s adapter includes built-in `globalThis` guards—one stable MSW instance regardless of how Next.js loads modules.
## When to Choose WireMock
Polyglot environment
Your team uses multiple languages (Java, Python, .NET). WireMock works with any HTTP client.
Record and playback
Need to capture real API interactions and replay them. WireMock’s recording is battle-tested.
Existing WireMock investment
Team already knows WireMock, has existing stub libraries, or uses WireMock Cloud.
Contract testing
Using WireMock for contract testing with Spring Cloud Contract or similar frameworks.
## Migration Considerations
### From WireMock to Scenarist
If you’re considering migrating from WireMock:
1. **Stub definitions translate directly** — WireMock JSON mappings map cleanly to Scenarist scenarios
2. **Test isolation improves** — No more managing multiple WireMock instances for parallel tests
3. **Recording doesn’t migrate** — You’ll lose record/playback capabilities
```typescript
// WireMock JSON
{
"request": {
"method": "POST",
"url": "/v1/charges",
"bodyPatterns": [{ "matchesJsonPath": "$.amount" }]
},
"response": {
"status": 200,
"jsonBody": { "id": "ch_123", "status": "succeeded" }
}
}
// Equivalent Scenarist scenario
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
match: { body: { amount: /.*/ } },
response: {
status: 200,
body: { id: 'ch_123', status: 'succeeded' }
}
}
```
### Using Both Together
You can use WireMock and Scenarist together:
* **WireMock** for services your team doesn’t own (microservices from other teams)
* **Scenarist** for third-party APIs you need to test scenarios for (Stripe, Auth0)
Point your app at WireMock for internal services while Scenarist mocks external APIs.
## Summary
| Factor | Scenarist | WireMock |
| ----------------------- | ---------------------------------------------------------------------------------- | ------------------------------- |
| Setup simplicity | ✓ npm install | JAR/Docker/npm + network config |
| TypeScript integration | ✓ Native | Via wiremock-captain client |
| Parallel test isolation | ✓ Header-based | Multiple instances |
| Runtime switching | ✓ Single API call | Admin API |
| Playwright integration | ✓ First-class fixtures | Manual HTTP calls |
| Response sequences | ✓ Built-in | ✓ Built-in (Scenarios) |
| Stateful mocks | ✓ Per-test-ID isolation | ✓ Global state machine |
| Next.js multi-process | ✓ [Built-in singleton guards](https://github.com/vercel/next.js/discussions/68572) | Manual workarounds needed |
| Language support | JavaScript/TypeScript | ✓ Any language |
| Record/playback | Not supported | ✓ Built-in |
| Ecosystem maturity | Newer | ✓ Battle-tested |
| Contract testing | Not supported | ✓ Spring Cloud Contract |
**Bottom line:** For Node.js projects, Scenarist keeps you in a single ecosystem—TypeScript scenarios, npm dependencies, Playwright fixtures, no context-switching to Java or Docker. Choose Scenarist when you value in-process simplicity, header-based parallel test isolation, and first-class Playwright support. Choose WireMock when you’re in a polyglot environment, need recording, contract testing, or when your team already has WireMock expertise. Both tools are capable—the choice is primarily about staying in your ecosystem vs. language flexibility.
# Scenarist + MSW
> How Scenarist builds on Mock Service Worker and when to use each
Scenarist is built on top of [Mock Service Worker (MSW)](https://mswjs.io/)—we don’t compete with MSW, we extend it. Understanding this relationship helps you decide when to use MSW directly vs when Scenarist’s scenario layer adds value.
## What Scenarist Offers
Before diving into comparisons, here's what Scenarist brings to the table:
* **[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
## The Relationship
```plaintext
┌─────────────────────────────────────────────┐
│ Your Test Suite │
│ ┌───────────────────────────────────────┐ │
│ │ Scenarist │ │
│ │ • Scenario management │ │
│ │ • Test ID isolation │ │
│ │ • Runtime switching │ │
│ │ • Framework adapters │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ MSW │ │ │
│ │ │ • Request interception │ │ │
│ │ │ • Network-level mocking │ │ │
│ │ │ • Handler matching │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
**MSW provides the foundation:**
* Intercepts HTTP/HTTPS requests at the network level
* Works with any HTTP client (fetch, axios, got, etc.)
* Proven, battle-tested, actively maintained
**Scenarist adds the testing layer:**
* Scenario management (group mocks by test case, switch at runtime)
* Complete test ID isolation (scenarios, sequences, and captured state—all per test ID)
* Declarative scenario definitions (inspectable, composable, type-safe)
* Framework adapters (Express, Next.js integration out of the box)
* [First-class Playwright fixtures](/testing/playwright-integration/) (automatic test ID handling)
*Note: MSW v2.2.0+ added `server.boundary()` for isolating handler registrations in concurrent tests. Scenarist’s test ID system provides broader isolation—including sequence positions and captured state—and works with any test framework.*
## When to Use MSW Directly
Storybook/Development
Mocking APIs during development or Storybook stories. No test isolation needed.
Single-Test Mocking
Simple tests where each test defines its own handlers inline. No scenario sharing.
API Design/Prototyping
Building frontend before backend exists. Interactive development, not testing.
Maximum Control
Need low-level MSW features like request timing, custom resolvers, or WebSocket mocking. (Scenarist focuses on HTTP request/response patterns.)
### Example: MSW for Development
```typescript
// src/mocks/handlers.ts - MSW for development
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/user', () => {
return HttpResponse.json({
id: 'user-123',
name: 'Development User',
email: 'dev@example.com'
});
}),
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'test@example.com') {
return HttpResponse.json({ token: 'mock-jwt-token' });
}
return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 });
})
];
// Use in browser with setupWorker for Storybook
// Use in Node with setupServer for development server
```
**This is a great use of MSW directly.** No test isolation needed, no scenario switching—just consistent mock data for development.
## When to Use Scenarist
Parallel Testing
Multiple tests running simultaneously need different API states. Test ID isolation is essential.
Scenario Libraries
Reusable scenarios across test suites. “payment-success”, “payment-declined”, “auth-timeout” used everywhere.
Runtime Switching
Tests that change scenarios mid-execution. Retry flows, state transitions, multi-step user journeys.
Framework Integration
Testing Next.js Server Components, Express middleware, or other server-side code with scenarios.
### Example: Scenarist for Parallel Tests
```typescript
// scenarios/payment.ts - Scenarist for testing
const scenarios = {
default: {
id: 'default', name: 'Default', description: 'Baseline responses',
mocks: [{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { id: 'ch_123' } } }]
},
'payment-success': {
id: 'payment-success', name: 'Payment success', description: 'Charge succeeds',
mocks: [{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { id: 'ch_456', status: 'succeeded' } } }]
},
'payment-declined': {
id: 'payment-declined', name: 'Payment declined', description: 'Card declined',
mocks: [{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 402, body: { error: { code: 'card_declined' } } } }]
},
'payment-timeout': {
id: 'payment-timeout', name: 'Payment timeout', description: 'Gateway times out',
mocks: [{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 504, delay: 30000 } }]
}
} as const satisfies ScenaristScenarios;
// payment.spec.ts - Tests run in parallel
test('shows success message', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-success');
// Test isolation via x-scenarist-test-id header
});
test('shows decline message', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-declined');
// Different scenario, same time, same server
});
test('shows timeout message', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-timeout');
// Third scenario, all running in parallel
});
```
**This is where Scenarist shines.** Multiple tests, different scenarios, running simultaneously against one server instance.
## The Scenario Management Problem
MSW is excellent at intercepting requests. But as your test suite grows, you face challenges:
### Challenge 1: Parallel Test Isolation
MSW v2.2.0+ introduced `server.boundary()` to help with parallel test isolation by scoping handler registrations. However, Scenarist takes a different approach with broader isolation.
* MSW server.boundary()
```typescript
// MSW v2.2.0+ - server.boundary() isolates handler registrations
import { server } from './mocks/server';
test.concurrent('Test A', server.boundary(async () => {
server.use(http.post('/api/payment', () =>
HttpResponse.json({ status: 'succeeded' })
));
// Handler only visible within this boundary
}));
test.concurrent('Test B', server.boundary(async () => {
server.use(http.post('/api/payment', () =>
HttpResponse.json({ status: 'failed' })
));
// Different handler, isolated from Test A
}));
// ✅ Handler registration is isolated
// ⚠️ Other state (sequences, captured data) is NOT isolated
```
* Scenarist
```typescript
// Scenarist - complete isolation via test ID
test('Test A - expects success', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-success');
// x-scenarist-test-id: test-a → payment-success scenario
// Sequences, state capture, everything isolated
});
test('Test B - expects failure', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-declined');
// x-scenarist-test-id: test-b → payment-declined scenario
// Complete isolation including sequences and captured state
});
```
**Key difference:** `server.boundary()` isolates handler *registrations* (which `server.use()` calls are visible). Scenarist isolates *everything*—the active scenario, sequence positions, and captured state—all keyed by test ID.
### Challenge 2: Scenario Reuse
* MSW Only
payment.test.ts
```typescript
// Problem: Duplicating handler setup across tests
beforeEach(() => {
server.use(
http.post('/api/payment', () => HttpResponse.json({ status: 'succeeded' })),
http.get('/api/user', () => HttpResponse.json({ tier: 'premium' }))
);
});
// checkout.test.ts - same setup duplicated
beforeEach(() => {
server.use(
http.post('/api/payment', () => HttpResponse.json({ status: 'succeeded' })),
http.get('/api/user', () => HttpResponse.json({ tier: 'premium' }))
);
});
// What if payment API response format changes?
// Update everywhere!
```
* With Scenarist
scenarios/index.ts
```typescript
// Solution: Centralized scenario definitions
export const scenarios = {
'premium-checkout': {
id: 'premium-checkout', name: 'Premium checkout', description: 'Premium user pays successfully',
mocks: [
{ method: 'POST', url: '/api/payment', response: { status: 200, body: { status: 'succeeded' } } },
{ method: 'GET', url: '/api/user', response: { status: 200, body: { tier: 'premium' } } }
]
}
} as const satisfies ScenaristScenarios;
// All tests reference the same scenario
// payment.test.ts
await switchScenario(page, 'premium-checkout');
// checkout.test.ts
await switchScenario(page, 'premium-checkout');
// Change in one place, tests stay green
```
### Challenge 3: Runtime Switching
* MSW Only
```typescript
// Problem: Changing handlers mid-test
test('retry after failure', async () => {
// Set up failure
server.use(http.post('/api/payment', () => HttpResponse.error()));
await page.click('#submit');
await expect(page.locator('.retry')).toBeVisible();
// Need to change to success...
server.resetHandlers(); // Affects other parallel tests!
server.use(http.post('/api/payment', () => HttpResponse.json({ status: 'ok' })));
await page.click('.retry');
});
```
* With Scenarist
```typescript
// Solution: Runtime switching per test ID
test('retry after failure', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-timeout');
await page.click('#submit');
await expect(page.locator('.retry')).toBeVisible();
// Switch scenario for THIS test only
await switchScenario(page, 'payment-success');
await page.click('.retry');
await expect(page.locator('.success')).toBeVisible();
});
```
## Using Both Together
You can use MSW directly for some use cases while using Scenarist for testing:
```typescript
// src/mocks/browser.ts - MSW for Storybook
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
// scenarios/index.ts - Scenarist for tests
export const scenarios = {
default: { mocks: [...] },
'edge-case': { mocks: [...] }
} as const satisfies ScenaristScenarios;
```
**They don’t conflict.** MSW handles development/Storybook. Scenarist handles your test suite with scenario management.
## Summary
| Use Case | MSW Directly | Scenarist |
| --------------------------------------------------- | ------------------- | ----------- |
| Development mocking | ✓ | |
| Storybook | ✓ | |
| API prototyping | ✓ | |
| Simple single-test mocks | ✓ | ✓ |
| Handler isolation (concurrent tests) | ✓ (server.boundary) | ✓ (test ID) |
| Complete state isolation (sequences, captured data) | | ✓ |
| Scenario libraries | | ✓ |
| Runtime scenario switching | | ✓ |
| Framework adapters | | ✓ |
| Playwright fixtures | | ✓ |
**Bottom line:** MSW is the foundation for all request interception. MSW v2.2.0+ added `server.boundary()` for handler isolation in concurrent tests. Use MSW directly for development mocking or simple test scenarios. Use Scenarist when you need scenario management—complete per-test-ID isolation (including sequences and state), runtime switching, and reusable scenario libraries with first-class Playwright support.
# Roadmap
> Planned features and future direction for Scenarist
Scenarist is evolving based on community needs. This page outlines planned features we’re considering. **We’re waiting for community feedback before building these**—if something here would help your workflow, let us know!
Help shape the roadmap
Your input matters. Open an issue or discussion on [GitHub](https://github.com/citypaul/scenarist/discussions) to share your thoughts on these features or suggest new ones.
## Scenarist DevTools
**Status:** Planned — awaiting community feedback
We’re exploring a browser-based developer tool for scenario management. Think [React Query DevTools](https://tanstack.com/query/latest/docs/react/devtools)—a floating panel that lets you inspect and control state—but for scenarios, and framework-agnostic (not React-specific).
### The Vision
A floating panel in your browser that lets you:
* **See active scenario** — Know exactly which scenario is active for the current session
* **Switch scenarios instantly** — Click to change scenarios without touching code or restarting the server
* **Browse available scenarios** — See all registered scenarios with their descriptions
* **Explore scenario details** — Inspect what each scenario mocks before switching to it
```plaintext
┌─────────────────────────────────────────────────┐
│ Scenarist DevTools [−] [×] │
├─────────────────────────────────────────────────┤
│ Active: premium-user │
│ ───────────────────────────────────────────── │
│ Available Scenarios: │
│ │
│ ○ default Happy path baseline │
│ ● premium-user Premium tier with all │
│ features unlocked │
│ ○ payment-declined Card declined at checkout │
│ ○ api-timeout External APIs are slow │
│ ○ rate-limited API returns 429 │
│ │
│ [View Details] [Switch Scenario] │
└─────────────────────────────────────────────────┘
```
### Use Cases
Local Development
Quickly explore different application states while developing. See how your UI handles premium users, error states, or edge cases without mocking code changes.
Stakeholder Demos
Product owners and stakeholders can explore different scenarios during demos. “Let me show you the premium user experience” — click, done.
Design Reviews
Designers can see how their work looks with different data states. Empty states, error states, loading states — all accessible without code.
### Key Design Principles
**Framework-Agnostic** Unlike React-specific devtools, Scenarist DevTools would work with any framework—React, Vue, Svelte, Angular, Solid, or vanilla JavaScript. The tool communicates with your Scenarist server, not with framework internals.
**Non-Production by Default** The DevTools would only be available in development and testing environments. Your production bundle stays clean with zero overhead. Optionally, you could enable it in staging environments for stakeholder demos.
**Simple Integration** A single script tag or import—that’s it. No complex configuration. The tool discovers your scenarios automatically from the server.
### Tell Us What You Need
Before we build this, we want to hear from you:
* Would this tool help your workflow?
* What features are most important to you?
* Would you use it primarily for development, testing, or demos?
* What frameworks are you using?
**[Share your thoughts on GitHub →](https://github.com/citypaul/scenarist/discussions)**
***
## Additional Framework Adapters
**Status:** Planned — prioritized by community demand
Scenarist currently supports **Express** and **Next.js** (App Router and Pages Router). We’re planning adapters for additional frameworks to bring scenario-based testing to more ecosystems.
### Planned Adapters
SvelteKit
Full support for SvelteKit’s server-side rendering and API routes.
Angular
Adapter for Angular Universal and Angular SSR applications.
Solid Start
Support for Solid’s meta-framework with SSR and API routes.
Remix
Adapter for Remix loaders and actions.
Nuxt
Support for Nuxt’s server routes and SSR.
Hono
Lightweight adapter for Hono’s multi-runtime framework.
### Prioritization
We’ll prioritize adapters based on community demand. If you’re using a framework not listed here, or want to see a specific adapter prioritized, let us know!
**[Request an adapter on GitHub →](https://github.com/citypaul/scenarist/discussions)**
***
## Cypress Helpers
**Status:** Planned — awaiting community feedback
Scenarist currently provides [first-class Playwright fixtures](/testing/playwright-integration/) with type-safe scenario switching and automatic test ID handling. We’re planning equivalent support for Cypress.
### What Cypress Helpers Would Provide
Type-Safe Scenarios
Autocomplete for scenario IDs, compile-time validation, and full TypeScript support—just like our Playwright fixtures.
Automatic Test Isolation
Each Cypress test gets a unique test ID. Run tests in parallel with different scenarios, no conflicts.
Custom Commands
`cy.switchScenario('premium-user')` — simple, readable commands that handle all the HTTP plumbing.
Intercept Integration
Works alongside Cypress’s `cy.intercept()` for browser-side mocks while Scenarist handles server-side scenarios.
### Example API (Draft)
cypress/support/e2e.ts
```typescript
import { withScenarios } from '@scenarist/cypress-helpers';
import { scenarios } from '../../lib/scenarios';
withScenarios(scenarios);
// cypress/e2e/checkout.cy.ts
describe('Checkout', () => {
it('shows premium pricing for premium users', () => {
cy.switchScenario('premium-user'); // Type-safe! Autocomplete works
cy.visit('/checkout');
cy.contains('Premium Plan').should('be.visible');
cy.contains('$99.00').should('be.visible');
});
it('handles payment failure gracefully', () => {
cy.switchScenario('payment-declined');
cy.visit('/checkout');
cy.get('[data-testid="pay-button"]').click();
cy.contains('Payment declined').should('be.visible');
});
});
```
### Tell Us What You Need
If you’re using Cypress and want first-class Scenarist support:
* What features are most important to you?
* How do you currently handle scenario switching in Cypress?
* Would you prefer custom commands, fixtures, or a different API?
**[Share your Cypress use case on GitHub →](https://github.com/citypaul/scenarist/discussions)**
***
## Community Contributions
Scenarist is open source. If you’re interested in contributing to any of these features—or proposing new ones—we’d love to collaborate.
* **[GitHub Repository](https://github.com/citypaul/scenarist)** — Star the repo, open issues, submit PRs
* **[Discussions](https://github.com/citypaul/scenarist/discussions)** — Share ideas and feedback
* **[Contributing Guide](https://github.com/citypaul/scenarist/blob/main/CONTRIBUTING.md)** — How to contribute
***
## Current Capabilities
While we work on future features, Scenarist already provides:
* **[Test ID isolation](/testing/parallel-testing/)** — Parallel tests with different scenarios
* **[Runtime switching](/concepts/how-it-works/#runtime-scenario-switching)** — Change scenarios mid-test
* **[First-class Playwright fixtures](/testing/playwright-integration/)** — Type-safe scenario management
* **[Response sequences](/scenarios/response-sequences/)** — Polling, retry flows, state machines
* **[Stateful mocks](/scenarios/stateful-mocks/)** — Capture and inject request data
* **[Advanced matching](/scenarios/request-matching/)** — Body, headers, query, regex patterns
* **Production safety** — Zero overhead in production bundles
[Get started with Scenarist →](/getting-started/quick-start/)