Getting started and concepts: installation, quick start, how Scenarist works, philosophy, production safety, and architecture
# Quick Start
> Get up and running with Scenarist in 5 minutes
Modern frameworks like Next.js blur the lines between frontend and backend. A single Server Component might validate input, query a database, call Stripe, and render HTML—all in one request. **The testing dilemma:** * **Mock everything?** You lose integration confidence—you’re not testing how your app actually works * **Hit real external APIs?** Slow, flaky, expensive, and impossible to test edge cases like payment failures **Scenarist’s approach:** Run Playwright tests against your real application. Your Server Components render, your middleware executes, your validation runs—for real. Only external services (Stripe, Auth0, SendGrid) are mocked, and you control exactly what they return per test. ## How It Works 1. **Define scenarios** — Declarative objects describing what external APIs should return 2. **Add middleware** — One line to integrate with your framework 3. **Switch scenarios per test** — Each test can use different API responses, running in parallel
```typescript
import type { ScenaristScenarios } from '@scenarist/express-adapter';
// Or: import type { ScenaristScenarios } from '@scenarist/nextjs-adapter/app';
// Define scenarios as data (not functions)
const scenarios = {
default: {
id: 'default',
name: 'Default',
description: 'Payment succeeds',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: { status: 200, body: { id: 'ch_123', status: 'succeeded' } },
},
],
},
cardDeclined: {
id: 'cardDeclined',
name: 'Card declined',
description: 'Stripe rejects the charge',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: { status: 402, body: { error: { code: 'card_declined' } } },
},
],
},
} as const satisfies ScenaristScenarios;
```
## Choose Your Framework Pick your framework to get started with a complete, working setup: [Express](/frameworks/express/getting-started/)API testing with Supertest + Vitest. Fast parallel execution. [Next.js App Router](/frameworks/nextjs-app-router/getting-started/)Server Components, Route Handlers, Server Actions. [Next.js Pages Router](/frameworks/nextjs-pages-router/getting-started/)API routes, getServerSideProps, getStaticProps. ## Example Apps Each framework has a complete, working example you can clone and run: | Framework | Example App | | -------------------- | -------------------------------------------------------------------------------------------------------------------- | | Express | [apps/express-example](https://github.com/citypaul/scenarist/tree/main/apps/express-example) | | Next.js App Router | [apps/nextjs-app-router-example](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-app-router-example) | | Next.js Pages Router | [apps/nextjs-pages-router-example](https://github.com/citypaul/scenarist/tree/main/apps/nextjs-pages-router-example) | ## What You’ll Learn Each framework guide covers: * **Installation** — Package setup for your framework * **Scenario definition** — How to structure mocks with default fallbacks * **App integration** — Framework-specific middleware/endpoint setup * **Test patterns** — Real test examples using your framework’s tools * **Header forwarding** — How to propagate test IDs to external APIs * **Production safety** — Tree-shaking and deployment considerations ## Core Concepts Before diving into your framework guide, here’s what makes Scenarist different: Test Real Code Your routes, middleware, and business logic execute normally. Only external HTTP calls are mocked. Declarative Scenarios Scenarios are data structures, not functions. Inspectable, composable, and versionable. Parallel Execution Each test gets isolated scenario state via test IDs. Run hundreds of tests simultaneously. Zero Production Code Conditional exports eliminate all Scenarist code from production builds. ## Debugging with Logs When scenarios don’t match as expected, pass a logger to `createScenarist()` to see exactly what’s happening. The example apps pass `createConsoleLogger()` when a `SCENARIST_LOG` environment variable is set:
```bash
# See which mocks are matching your requests
SCENARIST_LOG=1 pnpm test
```
```plaintext
09:49:09.715 DBG [test-checkout] 🎯 matching mock_candidates_found count=5 url="/api/cart"
09:49:09.716 INF [test-checkout] 🎯 matching mock_selected mockIndex=2 specificity=5
```
**[→ Full logging guide](/reference/logging/)** ## Debugging in Playwright Tests When tests fail, inspect the current mock state directly from your Playwright tests using the debug fixtures:
```typescript
import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'checkout');
await page.goto('/cart');
await page.click('#add-item');
// Inspect current state captured by mocks
const state = await debugState(page);
console.log('Cart state:', state);
// → { 'cart.items': 1, 'cart.total': 29.99 }
});
```
For async workflows, wait for state to reach a condition:
```typescript
test('approval flow', async ({ page, switchScenario, waitForDebugState }) => {
await switchScenario(page, 'approvalFlow');
await page.click('#submit-for-approval');
// Wait for backend state to update
const state = await waitForDebugState(
page,
(s) => s['approval.status'] === 'approved',
{ timeout: 10000 }
);
});
```
**[→ Full Playwright debug helpers guide](/testing/playwright-integration/#debugging-state)** ## Next Steps 1. **[Choose your framework](#choose-your-framework)** — Follow the complete getting-started guide 2. **[Read the philosophy](/concepts/philosophy/)** — Understand the “test behavior, not implementation” approach 3. **[Explore dynamic capabilities](/scenarios/overview/)** — Request matching, sequences, stateful mocks
# Installation
> How to install Scenarist in your project
Scenarist is distributed as a set of packages. Install the adapter for your framework and the appropriate testing tools. ## Package Overview | Package | Purpose | | ------------------------------- | ----------------------------------------------------- | | `@scenarist/nextjs-adapter` | Next.js App Router and Pages Router integration | | `@scenarist/express-adapter` | Express middleware integration | | `@scenarist/playwright-helpers` | Test utilities for Playwright (browser-based testing) | ## Next.js App Router Install the Next.js adapter and Playwright helpers:
```bash
# pnpm
pnpm add @scenarist/nextjs-adapter msw
pnpm add -D @scenarist/playwright-helpers @playwright/test
# npm
npm install @scenarist/nextjs-adapter msw
npm install -D @scenarist/playwright-helpers @playwright/test
# yarn
yarn add @scenarist/nextjs-adapter msw
yarn add -D @scenarist/playwright-helpers @playwright/test
```
Import from the `/app` subpath:
```typescript
import { createScenarist } from "@scenarist/nextjs-adapter/app";
```
**Peer dependencies:** `next@^14.0.0 || ^15.0.0 || ^16.0.0`, `msw@^2.0.0` After installation, follow the [Next.js App Router Getting Started guide](/frameworks/nextjs-app-router/getting-started/) to configure your app. ## Next.js Pages Router Install the Next.js adapter and Playwright helpers:
```bash
# pnpm
pnpm add @scenarist/nextjs-adapter msw
pnpm add -D @scenarist/playwright-helpers @playwright/test
# npm
npm install @scenarist/nextjs-adapter msw
npm install -D @scenarist/playwright-helpers @playwright/test
# yarn
yarn add @scenarist/nextjs-adapter msw
yarn add -D @scenarist/playwright-helpers @playwright/test
```
Import from the `/pages` subpath:
```typescript
import { createScenarist } from "@scenarist/nextjs-adapter/pages";
```
**Peer dependencies:** `next@^14.0.0 || ^15.0.0 || ^16.0.0`, `msw@^2.0.0` After installation, follow the [Next.js Pages Router Getting Started guide](/frameworks/nextjs-pages-router/getting-started/) to configure your app. ## Express ### API Testing with Supertest (Recommended) For testing Express APIs directly without a browser, use **Supertest** with **Vitest**:
```bash
# pnpm
pnpm add @scenarist/express-adapter msw
pnpm add -D vitest supertest @types/supertest
# npm
npm install @scenarist/express-adapter msw
npm install -D vitest supertest @types/supertest
# yarn
yarn add @scenarist/express-adapter msw
yarn add -D vitest supertest @types/supertest
```
This is the recommended approach for Express API testing—fast, parallel test execution without browser overhead. **Example test with Supertest:**
```typescript
import { describe, it, expect } from "vitest";
import request from "supertest";
import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";
it("processes payment successfully", async () => {
await request(app)
.post("/__scenario__")
.set(SCENARIST_TEST_ID_HEADER, "test-1")
.send({ scenario: "default" });
const response = await request(app)
.post("/api/checkout")
.set(SCENARIST_TEST_ID_HEADER, "test-1")
.send({ amount: 5000 });
expect(response.status).toBe(200);
});
```
See the [complete Express example tests](https://github.com/citypaul/scenarist/tree/main/apps/express-example/tests) for comprehensive patterns including scenario switching, test isolation, and dynamic responses. ### Full-Stack Testing with Playwright (Optional) If you have a **full-stack application** with an Express backend and want browser-based scenario testing, add the Playwright helpers:
```bash
# pnpm
pnpm add @scenarist/express-adapter msw
pnpm add -D @scenarist/playwright-helpers @playwright/test
# npm
npm install @scenarist/express-adapter msw
npm install -D @scenarist/playwright-helpers @playwright/test
# yarn
yarn add @scenarist/express-adapter msw
yarn add -D @scenarist/playwright-helpers @playwright/test
```
Use [Playwright helpers](/testing/playwright-integration/) when you need to test user interactions through a browser (clicks, form submissions, visual verification). **Peer dependencies:** `express@^4.18.0 || ^5.0.0`, `msw@^2.0.0` After installation, follow the [Express Getting Started guide](/frameworks/express/getting-started/) to configure your app. ## Requirements * **Node.js 18+** - Required for all packages * **TypeScript 5+** - Recommended for type-safe scenario IDs * **MSW 2.x** - Peer dependency for all adapters ## Verifying Installation After installing, verify the packages are correctly installed:
```bash
# Check package versions
pnpm list @scenarist/nextjs-adapter @scenarist/express-adapter @scenarist/playwright-helpers
```
You should see the installed packages and their versions listed. ## Next Steps * Follow the [Quick Start](/getting-started/quick-start/) to set up your first scenario * Read the framework-specific guides for detailed configuration: * [Next.js App Router](/frameworks/nextjs-app-router/getting-started/) * [Next.js Pages Router](/frameworks/nextjs-pages-router/getting-started/) * [Express](/frameworks/express/getting-started/)
# Using Scenarist with AI Assistants
> Give Claude, ChatGPT, Cursor, Copilot, and other coding agents accurate Scenarist documentation through llms.txt
Coding assistants often guess at APIs they have not seen. Scenarist publishes its documentation in plain Markdown, following the [llms.txt standard](https://llmstxt.org/), so an assistant can read the real API instead of inventing one. ## Which file to use | URL | What it contains | Use it when | | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | [`scenarist.io/llms.txt`](/llms.txt) | A short index: what Scenarist is, the rules assistants most often get wrong, and links to the most useful docs pages and to the bundles below | The assistant can fetch URLs itself. Start here. | | [`scenarist.io/llms-full.txt`](/llms-full.txt) | Every docs page in one Markdown file | You want the assistant to have everything, and its context window is large | | [`scenarist.io/llms-small.txt`](/llms-small.txt) | The same pages with tips, notes, and comparison pages removed | The full file is too large for the tool you are using | `llms.txt` also links to smaller topic bundles, such as Writing scenarios, Next.js App Router, and Express, that fit in almost any context window. The full, small, and topic files are generated from the documentation on every deploy, so they always match this site. The index and rules in `llms.txt` are written by hand and reviewed with each docs change. ## Point your assistant at the docs ### Agents that read project instructions Claude Code, Codex, Cursor, GitHub Copilot, and similar agents read a project instructions file such as `AGENTS.md`, `CLAUDE.md`, or `.github/copilot-instructions.md`. Add a line to it so the agent fetches the documentation before writing Scenarist code:
```markdown
## Scenarist
Before writing or changing Scenarist scenarios, adapters, or tests, read
https://scenarist.io/llms.txt and follow the links relevant to the task.
```
### Editors that index documentation In an editor that can index documentation by URL, such as Cursor’s **@Docs**, add `https://scenarist.io/llms-full.txt`. The editor can then pull in Scenarist’s docs when you mention them in a prompt. ### Chat assistants In Claude, ChatGPT, Gemini, or another chat assistant, paste `https://scenarist.io/llms.txt` into your message and ask the assistant to read it first. If the assistant cannot browse, download `llms-small.txt` or a topic bundle and attach it to the conversation. ## What the index tells an assistant up front Beyond links, `llms.txt` states the facts assistants most often get wrong, so they are right on the first attempt: * Scenarios are plain data, never functions, and must include a `default` scenario * Next.js apps forward the test ID header on every outgoing `fetch` * The Next.js App Router scenario route lives in `app/api/%5F%5Fscenario%5F%5F/route.ts` * Playwright tests import `test` from a fixtures file built with `withScenarios` * Scenarist mocks HTTP requests only, not database calls If an assistant still produces code that does not match these docs, [open an issue](https://github.com/citypaul/scenarist/issues) with the prompt you used so the index can be improved.
# Why Scenarist?
> Understanding scenario-based testing and how Scenarist fills the gap between unit tests and end-to-end tests
## What is Scenario-Based Testing? **Scenario-based testing** is an integration testing approach where your real application code executes while external dependencies (third-party APIs, microservices) return controlled responses. Unlike true end-to-end tests that use zero mocks, scenario-based tests mock only the external services you don’t control. | Testing Approach | Your Code | External APIs | Best For | | ------------------------ | --------- | ------------- | ------------------------------------------------- | | **Unit Tests** | Mocked | Mocked | Isolated function logic | | **Scenario-Based Tests** | Real | Mocked | Application behavior with controlled dependencies | | **End-to-End Tests** | Real | Real | Full system validation (production-like) | **Why “scenario-based”?** Because you define complete backend *scenarios* (success, error, timeout, user tiers) and switch between them at runtime. Each test selects a scenario that describes the complete external API state, enabling comprehensive testing without external dependencies. **The key distinction from E2E:** True end-to-end tests use real external APIs with zero mocks—ideal for validating complete production behavior, but slow, expensive, and limited in edge case coverage. Scenario-based tests give you the speed of unit tests with the realism of integration tests by running your real code against controlled external responses. *** ## The Testing Gap Modern web development has blurred the traditional separation between frontend and backend code. Frameworks like **Next.js**, **Remix**, **SvelteKit**, and **SolidStart** run server-side logic alongside UI components. Traditional backends built with **Express**, **Hono**, or **Fastify** face the same challenge: all make HTTP calls to external services (Stripe, Auth0, SendGrid) that need different behaviors in tests. This creates a testing challenge: **Server Components, loaders, and API routes** execute server-side but are defined alongside components. Your UI code calls external APIs directly on the server. Testing this requires either mocking framework internals or running full end-to-end tests. **Traditional backend services** call the same external APIs. Testing payment flows, authentication errors, or email delivery requires simulating different API responses. ## What Scenarist Offers * **[Simple Architecture](/concepts/architecture/)** — Just an HTTP header (`x-scenarist-test-id`). No Docker, no separate processes, no complex network configuration * **[Test ID Isolation](/testing/parallel-testing/)** — Run hundreds of parallel tests with different scenarios against one server. Each test's header routes to its own scenario * **Runtime Switching** — Change scenarios mid-test without restarts (retry flows, error recovery) * **[First-Class Playwright](/testing/playwright-integration/)** — Dedicated fixtures with type-safe scenarios and automatic test ID handling * **[Response Sequences](/scenarios/response-sequences/)** — Built-in polling, retry flows, state machines * **[Stateful Mocks](/scenarios/stateful-mocks/)** — Capture request values, inject into responses. State is isolated per test ID, so parallel tests never conflict * **[Advanced Matching](/scenarios/request-matching/)** — Body, headers, query params, regex with specificity-based selection * **[Framework Adapters](/getting-started/why-scenarist/#nextjs-multi-process-handling)** — Not thin wrappers—they solve real problems. For example, the Next.js adapter includes built-in singleton protection for the [module duplication issue](https://github.com/vercel/next.js/discussions/68572) that breaks MSW * **Developer Tools ([Roadmap](/roadmap/))** — Planned browser-based plugin for switching scenarios during development and debugging—making scenario exploration instant and visual *** ## Testing Options and Their Trade-offs **Unit tests** can test server-side logic, but require mocking framework internals (Next.js `fetch`, `cookies`, `headers`) or HTTP clients. This creates distance between test execution and production behavior. **End-to-end tests** provide confidence by testing the complete system, but cannot reach most edge case states. How do you make Stripe return a specific decline code? Or Auth0 timeout? Or SendGrid fail with a particular error? You can’t control real external APIs to test these scenarios. Testing the few scenarios you can reach would also be prohibitively slow. **Between these approaches lies a gap:** Running your real server-side code against any external API response you choose, without calling live third-party services or mocking framework internals. **Scenarist fills this gap** by testing your server-side HTTP layer with mocked external APIs. Your code—Server Components, loaders, middleware, business logic—executes normally. Only HTTP requests (fetch, axios, etc.) are intercepted, returning scenario-defined responses based on test ID. This enables testing full user journeys through the browser using [Playwright helpers](/testing/playwright-integration/), with each test isolated and running in parallel. **Test extensive external API scenarios in parallel** without expensive cloud API calls or complex test infrastructure. ### Next.js Multi-Process Handling (Solved) Next.js presents a unique challenge for MSW-based testing. It has a [well-documented singleton problem](https://github.com/vercel/next.js/discussions/68572) where webpack bundles the same module multiple times, breaking classic singleton patterns. This is compounded by [MSW's challenges with Next.js's process model](https://github.com/mswjs/msw/issues/1644)—Next.js keeps multiple Node.js processes that make global module patches difficult to maintain. **Scenarist solves this automatically.** The Next.js adapter includes built-in `globalThis` singleton guards that ensure only one MSW instance exists, regardless of how Next.js loads your modules. You don't need to understand Next.js internals or implement manual workarounds—just use `export const scenarist = createScenarist(...)` and Scenarist handles the complexity. ## What You Can Test **When your app calls external HTTP APIs, Scenarist gives you full control.** You can test complete user journeys—from browser interaction through Server Components, API routes, and middleware—with your real server-side code executing, while you control exactly what responses come back from external services. ### Perfect For * **Server Components** fetching from external APIs (Stripe, Auth0, SendGrid) * **API routes** that call third-party services * **Middleware** that validates tokens or checks permissions * **Full user journeys** through real frontend + backend code
```typescript
// Server Component - Your real code executes
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
export default async function CheckoutPage() {
// ✅ This call is intercepted - you control the response
const payment = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: {
// Forward the test ID so the call gets this test's scenario
...getScenaristHeadersFromReadonlyHeaders(await headers()),
'Authorization': `Bearer ${process.env.STRIPE_KEY}`,
},
body: JSON.stringify({ amount: 5000 }),
cache: 'no-store',
});
// Your real rendering logic
const result = await payment.json();
return ;
}
```
**Test any scenario:** Payment success, card declined, network timeout, rate limiting, webhook failures—all controlled by your test scenarios, running in parallel. ### The Requirement: Network Requests Scenarist intercepts HTTP requests that traverse the network. This works because MSW (Mock Service Worker) operates at the network level, intercepting requests from any HTTP client (fetch, axios, etc.). **What this means in practice:**
```typescript
// ✅ WORKS - External API
const stripe = await fetch("https://api.stripe.com/v1/products");
// ✅ WORKS - Different port on localhost
const products = await fetch("http://localhost:3001/products");
// ❌ Does NOT work - Same host/port internal route
const products = await fetch("http://localhost:3000/api/products");
```
**Why internal routes don’t work:** When a Server Component calls an API route on the same host/port, Next.js handles this internally without making a network request. MSW only sees requests that go through the network stack. ### What Cannot Be Intercepted * **Direct database access** (PostgreSQL, MongoDB, Prisma) - no HTTP request * **Internal API routes** on the same host/port - Next.js internal routing * **File system operations** - no HTTP request * **WebSocket connections** - MSW supports WebSockets, but Scenarist’s scenario system focuses on HTTP **If your app uses direct database access:** See [Testing Database Apps](/guides/testing-database-apps/) for strategies. We recommend the [Repository Pattern](/guides/testing-database-apps/repository-pattern/) for scalable parallel testing with the same test ID isolation model as Scenarist. [Learn how it works →](/concepts/how-it-works/) ### Why Framework Documentation Recommends E2E This gap is evident in how framework authors struggle to provide testing guidance. The [Next.js testing docs](https://nextjs.org/docs/app/building-your-application/testing) focus primarily on unit testing and E2E testing, acknowledging that async Server Components present unique testing challenges. [Remix testing guidance](https://remix.run/docs/en/main/guides/testing) notes the complexity of testing components that depend on Remix context and loaders. SvelteKit faces similar challenges with server route testing. The pattern is clear: when “frontend” components run on the server and call external APIs directly, traditional testing approaches break down. **Scenarist fills this gap** by testing real server-side code with mocked external APIs. ## Testing Behavior, Not Implementation Scenarist enables behavior-focused testing by letting you test your server’s response to different external API behaviors without mocking internal implementation details. **Your tests describe scenarios:** * “Premium user checkout with valid payment” * “Payment declined due to insufficient funds” * “Auth0 timeout during login” **Not implementation details:** * ~~“Mock stripe.charges.create to throw error”~~ * ~~“Stub authClient.getSession to return null”~~ * ~~“Mock sendgrid.send to resolve with 500”~~ This follows Test-Driven Development principles: tests document expected behavior, implementation details can change as long as behavior stays consistent. [Learn about our testing philosophy →](/concepts/philosophy/) ## Comparing Testing Approaches Scenarist fills a gap between unit tests and end-to-end tests. Each approach serves different purposes—they complement rather than replace each other: * **Unit tests** verify individual functions and modules in isolation * **Scenarist** verifies HTTP-level behavior with different external API scenarios * **E2E tests** verify the complete user experience including browser interactions For detailed comparisons with other tools (WireMock, Nock, Testcontainers, Playwright mocks), see our [Tool Comparison](/comparison/) guide. The comparison includes a [decision guide](/comparison/#making-the-decision) to help you choose the right approach for your needs. Scenarist Complements E2E Testing Scenarist tests server-side HTTP behavior, not complete user workflows. Browser interactions, JavaScript execution, and visual rendering still require end-to-end tests. Use Scenarist alongside E2E tests, not as a replacement. ## Limitations and Trade-offs **HTTP only**: Scenarist intercepts HTTP requests only. It cannot mock database calls, file system operations, or WebSocket connections (MSW supports WebSockets, but Scenarist’s scenario system is designed for HTTP request/response patterns). For apps with direct database access, see [Testing Database Apps](/guides/testing-database-apps/) for recommended strategies. **Database parallelism**: While Scenarist enables parallel HTTP tests via test ID isolation, database testing requires different strategies. We recommend the [Repository Pattern](/guides/testing-database-apps/repository-pattern/), which provides the same test ID isolation model for database access. See [Parallelism Options](/guides/testing-database-apps/parallelism-options/) for all approaches and trade-offs. **Single-server deployment**: Scenarist stores test ID to scenario mappings in memory. This works well for local development and single-instance CI environments. Load-balanced deployments would require additional state management. **Mock maintenance**: Scenario definitions need updates when external APIs change. Scenarist doesn’t validate that mocks match real API contracts—this is a deliberate trade-off for test isolation and speed. **Learning curve**: Understanding scenario definitions, test ID isolation, and the relationship between mocks and real backend code requires initial investment. The documentation and examples aim to reduce this learning time. ## Getting Started Choose your framework to see specific installation and usage instructions: * [Next.js →](/frameworks/nextjs/) - Test Server Components and API routes * [Express →](/frameworks/express/) - Test middleware and route handlers Or explore core concepts that apply to all frameworks: * [Overview: How It Works →](/concepts/how-it-works/) * [Capabilities: Writing Scenarios →](/scenarios/overview/) * [Scenarios & Format →](/scenarios/basic-structure/) * [Architecture →](/concepts/architecture/)
# How it works
> Understanding Scenarist's execution model and runtime scenario switching
Scenarist fills the testing gap by enabling **HTTP-level integration testing** with **runtime scenario switching**: * Tests make real HTTP requests to your backend * Your backend code executes normally (middleware, routing, business logic) * External API calls are intercepted and return scenario-defined responses * Different scenarios run in parallel against the same server instance * Each test is isolated via unique test identifiers ## One Server, Unlimited Scenarios **The key insight:** Each scenario is a **complete set of API mocks** that defines how every external API behaves for that test. The diagram shows 2 scenarios as examples—you can define as many as you need, and each scenario can mock as many APIs as your application uses. **Understanding the pattern:** Each test switches to a specific scenario, and that scenario controls **all external API responses** for the duration of that test: * **Test 1** switches to `allSucceed` → Stripe succeeds, Auth0 authenticates, SendGrid sends * **Test 2** switches to `paymentFails` → Stripe declines, Auth0 authenticates, SendGrid sends Notice how each scenario defines the complete behavior: in `paymentFails`, only Stripe fails—Auth0 and SendGrid still succeed. This lets you test **exactly** the edge case you care about. **Default scenario pattern (recommended):** Define a `default` scenario with your **happy path** responses for all external APIs. Then create specialized scenarios that override only what changes:
```typescript
import type { ScenaristScenarios } from "@scenarist/nextjs-adapter/app";
const scenarios = {
default: {
// Happy path - all APIs succeed
id: "default",
name: "All succeed",
description: "Happy path for every external API",
mocks: [
{
method: "POST",
url: "https://api.stripe.com/...",
response: { status: 200, body: { status: "succeeded" } },
},
{
method: "GET",
url: "https://api.auth0.com/...",
response: { status: 200, body: { user: "john@example.com" } },
},
{
method: "POST",
url: "https://api.sendgrid.com/...",
response: { status: 200, body: { status: "sent" } },
},
],
},
paymentFails: {
// Only override Stripe - Auth0 and SendGrid automatically fall back to default
id: "paymentFails",
name: "Payment fails",
description: "Stripe declines; everything else succeeds",
mocks: [
{
method: "POST",
url: "https://api.stripe.com/...",
response: { status: 402, body: { status: "declined" } },
},
],
},
} as const satisfies ScenaristScenarios;
```
When you switch to `paymentFails`, Scenarist uses that scenario’s mocks (Stripe declines) **and automatically falls back to the default scenario** for any APIs not defined (Auth0 and SendGrid succeed). This eliminates duplication—you only define what changes. **What this enables:** * ✅ **Unlimited scenarios** - Premium users, free users, error states, edge cases—as many as you need * ✅ **Unlimited APIs per scenario** - Mock Stripe, Auth0, SendGrid, GitHub, Twilio—as many as your app uses * ✅ **Default fallback** - Define happy path once, override only what changes in each scenario * ✅ **Test edge cases exhaustively** - Can’t make real Stripe decline with a specific error code, but your scenario can * ✅ **Fast parallel testing** - All scenarios run simultaneously against the same server ## Execution Model When testing with Scenarist, your backend executes as it would in production: **Green boxes**: Your code executes with production behavior **Yellow boxes**: External API calls are intercepted and handled by scenario definitions ## Example This example demonstrates HTTP-level testing with Next.js. Each framework has its own adapter that integrates Scenarist into your application. **Step 1: Framework-specific setup** (done once per application)
```typescript
// lib/scenarist.ts - Next.js App Router
import { createScenarist } from "@scenarist/nextjs-adapter/app";
import { scenarios } from "./scenarios";
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
if (typeof window === "undefined" && scenarist) {
scenarist.start();
}
// app/api/%5F%5Fscenario%5F%5F/route.ts - serves /api/__scenario__
import { scenarist } from "../../../lib/scenarist";
const handler = scenarist?.createScenarioEndpoint();
export const POST = handler;
export const GET = handler;
```
**Step 2: Define scenarios** (reusable across tests) scenarios.ts
```typescript
import type { ScenaristScenarios } from "@scenarist/nextjs-adapter/app";
export const scenarios = {
default: {
id: "default",
name: "Default",
description: "Baseline mocks for every test",
mocks: [],
},
premiumUser: {
id: "premiumUser",
name: "Premium User",
description: "Auth provider returns a premium session",
mocks: [
{
method: "GET",
url: "https://api.auth-provider.com/session",
response: {
status: 200,
body: { tier: "premium", userId: "user-123" },
},
},
],
},
} as const satisfies ScenaristScenarios;
```
**Step 3: [Set up Playwright fixtures](/testing/playwright-integration/)** (one-time setup) tests/fixtures.ts
```typescript
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "./scenarios"; // Import your scenarios
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };
```
**Step 4: Write tests** (import from fixtures, not @playwright/test) tests/premium-features.spec.ts
```typescript
import { test, expect } from "./fixtures"; // ✅ Import from fixtures, NOT @playwright/test
test("premium users access advanced features", async ({
page,
switchScenario,
}) => {
await switchScenario(page, "premiumUser"); // ✅ Type-safe! Autocomplete works
// Real HTTP request → Next.js route → middleware → business logic
await page.goto("/dashboard");
// External auth API call intercepted, returns mocked premium tier
// Your business logic processes the tier correctly
await expect(page.getByText("Advanced Analytics")).toBeVisible();
});
```
**What’s happening:** 1. Framework adapter integrates Scenarist into your Next.js app 2. Scenarios define how external APIs behave 3. [Playwright fixtures](/testing/playwright-integration/) create type-safe test helpers with scenario autocomplete 4. Tests import from fixtures (not @playwright/test directly) 5. Test switches to scenario and makes real HTTP requests 6. Your backend code executes with production behavior 7. External API calls return scenario-defined responses **See complete working examples:** * [Next.js Example App →](/frameworks/nextjs-app-router/example-app/) * [Express Example App →](/frameworks/express/example-app/) **Framework-specific guides:** * [Next.js setup →](/frameworks/nextjs-app-router/getting-started/) * [Express setup →](/frameworks/express/getting-started/) ## Ephemeral Endpoints: Test-Only Activation Scenarist creates special scenario endpoints (`/__scenario__` in Express, `/api/__scenario__` in the Next.js example route files) that **only exist in non-production builds**. These ephemeral endpoints enable runtime scenario switching while maintaining production safety. **What are ephemeral endpoints?** * `POST /__scenario__` - Switch the active scenario for a test * `GET /__scenario__` - Check which scenario is currently active **Why “ephemeral”?** The endpoints only exist when `createScenarist()` returns an instance. In production builds, each adapter’s `production` export condition resolves to a stub whose `createScenarist()` returns `undefined`, so there is nothing to mount:
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true
scenarios,
}); // undefined when the `production` export condition is resolved
```
**In development/test builds:** * Endpoints accept requests and switch scenarios * MSW intercepts external API calls * Test ID headers route requests to correct scenarios **When `enabled: false`:** * `createScenarist()` returns `undefined`, so the endpoints have no working handler: Express never mounts them (404), and Next.js scenario routes answer 405 (or your fallback handler’s status) * Zero overhead - no middleware, no MSW, no scenario infrastructure * Your app runs exactly as it would without Scenarist In production, `createScenarist()` also returns `undefined` even if you accidentally deploy with `enabled: true`: Next.js builds resolve the `production` export condition, and the Express adapter checks `NODE_ENV`. This keeps scenario switching infrastructure **out of production**. See [Production Safety](/concepts/production-safety/). [Learn more about ephemeral endpoints →](/reference/ephemeral-endpoints/) ## Runtime Scenario Switching Traditional end-to-end tests cannot switch external API behavior at runtime. Testing different scenarios (premium vs free users, error states) typically requires separate deployments, complex data setup, or conditional logic in application code. Scenarist addresses this through runtime scenario switching using test identifiers:
```typescript
// Define multiple scenarios
const scenarios = {
premium: {
/* premium tier mocks */
},
free: {
/* free tier mocks */
},
error: {
/* error state mocks */
},
} as const satisfies ScenaristScenarios;
// Tests run concurrently
test("premium features", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// Test with premium scenario
});
test("free features", async ({ page, switchScenario }) => {
await switchScenario(page, "free");
// Test with free scenario - runs simultaneously
});
```
### How Test Isolation Works: Complete Request Flow Here’s how two tests run in parallel with different scenarios, showing the complete journey from scenario setup through multiple requests: **The test isolation mechanism:** 1. **Each test gets a unique ID** (generated automatically) 2. **Test switches scenario once** via `POST /__scenario__` with its test ID 3. **All subsequent requests** include the test ID in headers (`x-scenarist-test-id: abc-123`) 4. **Scenarist routes based on test ID** - same URL, different responses per test 5. **Scenario persists** for the entire test journey (dashboard → checkout → confirmation) 6. **Tests run in parallel** - Test 1 and Test 2 execute simultaneously without affecting each other This enables: * ✅ **Unlimited scenarios** - Test premium, free, errors, edge cases all in parallel * ✅ **No interference** - Each test isolated by unique test ID * ✅ **One backend server** - All tests share same server instance * ✅ **Real HTTP execution** - Your middleware, routing, and logic run normally * ✅ **Fast execution** - No expensive external API calls This enables parallel test execution without process coordination or port conflicts. ## Framework Independence Scenarist uses hexagonal architecture to maintain framework independence. The core has no web framework dependencies. Benefits: * Scenario definitions work across all frameworks * Framework-specific adapters handle integration * Switching frameworks doesn’t require rewriting scenarios Supported frameworks: Express and Next.js (Pages and App Router). Additional adapters planned. [Learn about the architecture →](/concepts/architecture/) ## Next Steps * [Dynamic Capabilities →](/scenarios/overview/) - Request matching, sequences, stateful mocks * [Scenario Format →](/scenarios/basic-structure/) - Complete scenario structure reference * [Framework Guides →](/frameworks/express/getting-started/) - Integrating with your framework * [Architecture Details →](/concepts/architecture/) - Deep dive into hexagonal architecture
# Testing Philosophy
> Test behavior, not implementation - the core principle behind Scenarist
Scenarist is built on a core testing principle: **test behavior, not implementation**. This philosophy shapes everything about how you write scenarios and structure your tests. ## Test Behavior, Not Implementation Traditional testing often focuses on implementation details—mocking internal functions, spying on method calls, verifying that specific code paths execute. This creates **fragile tests** that break whenever you refactor, even when the external behavior stays exactly the same. Behavior-focused testing asks a different question: **“What does the user experience?”** * Implementation (Fragile)
```typescript
// Tests are coupled to internal structure
it('should call stripe.charges.create with correct params', () => {
const stripeMock = jest.spyOn(stripe.charges, 'create');
await processPayment({ amount: 100 });
expect(stripeMock).toHaveBeenCalledWith({
amount: 10000,
currency: 'usd',
source: expect.any(String),
});
});
```
**Problems:** * Breaks if you switch from `stripe.charges.create` to `stripe.paymentIntents.create` * Breaks if you add a wrapper function * Tests internal structure, not user experience * Behavior (Robust)
```typescript
// Tests describe user-visible outcomes
it('displays success message after valid payment', async ({ page, switchScenario }) => {
await switchScenario(page, 'payment-success');
await page.goto('/checkout');
await page.fill('[name="card"]', '4242424242424242');
await page.click('button[type="submit"]');
await expect(page.locator('.success')).toContainText('Payment complete');
});
```
**Benefits:** * Tests what users actually see * Survives internal refactoring * Documents expected behavior The shift from implementation to behavior testing changes how you think: | Instead of asking… | Ask… | | ----------------------------------- | ----------------------------------- | | “Did the Stripe SDK get called?” | “Can users complete a purchase?” | | “Was the auth token validated?” | “Are unauthorized users blocked?” | | “Did the email service return 200?” | “Does the user see a confirmation?” | Your tests become **documentation of expected behavior**, not a specification of internal mechanisms. ## Why Declarative Patterns Matter Scenarist enforces **declarative scenario definitions**—you describe **what** should happen, not **how** to make it happen. This isn’t an arbitrary constraint; it’s fundamental to maintainable testing.
```typescript
// ✅ Declarative - describes what response to return
const paymentSuccessMock = {
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_123', status: 'succeeded' },
},
};
// ❌ Imperative - describes how to generate response
server.use('/api/charges', (req, res) => {
const amount = req.body.amount;
if (amount > 10000) {
return res.status(402).json({ error: 'amount_too_large' });
}
return res.status(200).json({ id: generateId(), status: 'succeeded' });
});
```
Inspectable Declarative scenarios are data. You can see exactly what response will be returned—no tracing through conditionals. Composable Add request matching, sequences, or state capture without rewriting procedural logic. ### Constraints Guide Better Design Scenarist deliberately prevents functions in scenarios. When you’re tempted to write `if (req.something)`, the constraint forces you to ask: “What pattern am I actually trying to express?” | System | Constraint | What It Forces | | ------------- | ----------------------------- | ------------------------------------------ | | SQL | No procedural loops | Think in set operations | | React | No imperative DOM updates | Think in component composition | | **Scenarist** | **No functions in scenarios** | **Think in match/sequence/state patterns** | The answer is usually one of: * **Match criteria** — Different responses based on request content * **Sequences** — Ordered progression through states * **State capture** — Values that flow between requests These patterns are explicit, composable, and debuggable. ## Applying the Philosophy ### Start with Behavior Questions When writing tests, ask: 1. **What does the user see?** Focus on visible outcomes, not internal state. 2. **What scenarios matter?** List the external conditions that affect behavior. 3. **What could go wrong?** Error cases are often more important than happy paths. ### Think in Scenarios, Not Mocks Don’t think of definitions as “mocking Stripe”—think of them as **describing scenarios**:
```typescript
// ❌ Thinking in mocks
const stripeMock = { /* ... */ };
// ✅ Thinking in scenarios
const scenarios = {
'checkout-success': { /* describes what happens */ },
'card-declined': { /* describes what happens */ },
'stripe-timeout': { /* describes what happens */ },
} as const satisfies ScenaristScenarios;
```
Scenario names describe **business outcomes**, not technical implementations. ### Keep Scenarios Focused Each scenario should test **one thing**:
```typescript
// ❌ Kitchen sink scenario
const scenarios = {
'everything': {
id: 'everything',
mocks: [
{ /* stripe success */ },
{ /* auth success */ },
{ /* email success */ },
],
},
};
// ✅ Focused scenarios
const scenarios = {
'payment-success': { /* only stripe */ },
'payment-declined': { /* only stripe, declined */ },
'email-failure': { /* stripe success, email failure */ },
} as const satisfies ScenaristScenarios;
```
Focused scenarios make failures clear: when `payment-declined` fails, you know it’s a payment handling issue. ## Next Steps * [Quick Start](/getting-started/quick-start/) — Choose your framework and get started * [Scenario Format](/scenarios/basic-structure/) — All the declarative patterns available * [Dynamic Capabilities](/scenarios/overview/) — Request matching, sequences, stateful mocks
# 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.