@scenarist/express-adapter
v0.4.3
Published
Express middleware adapter for Scenarist
Maintainers
Readme
@scenarist/express-adapter
Express.js adapter for Scenarist - manage MSW mock scenarios in your Express applications for testing and development.
📖 Documentation
→ Express Getting Started Guide — Full setup instructions and examples
| Topic | Link | | ----------------------- | ------------------------------------------------------------------------------------------------ | | Why Scenarist? | scenarist.io/getting-started/why-scenarist | | Tool Comparison | scenarist.io/comparison | | Parallel Testing | scenarist.io/testing/parallel-testing | | Writing Scenarios | scenarist.io/scenarios/basic-structure | | Request Matching | scenarist.io/scenarios/request-matching | | Response Sequences | scenarist.io/scenarios/response-sequences | | State-Aware Mocking | scenarist.io/scenarios/state-aware-mocking | | Logging & Debugging | scenarist.io/reference/logging |
What is Scenarist?
Scenarist enables concurrent tests to run with different backend states by switching mock scenarios at runtime via test IDs. Your real application code executes while external API responses are controlled by scenarios. No application restarts needed, no complex per-test mocking, just simple scenario switching.
Before Scenarist:
// Every test has fragile per-test mocking
beforeEach(() => {
server.use(http.get("/api/user", () => HttpResponse.json({ role: "admin" })));
});
// Repeat 100 times across test files, hope they don't conflictWith Scenarist:
// Define scenario once
const adminScenario = {
id: "admin",
mocks: [
/* complete backend state */
],
};
// Use in any test with one line
await setScenario("test-1", "admin");
// Test runs with complete "admin" backend state, isolated from other testsWhy Use Scenarist with Express?
Runtime Scenario Switching
- Change entire backend state with one API call
- No server restarts between tests
- Instant feedback during development
True Parallel Testing
- 100+ tests run concurrently with different scenarios
- Each test ID has isolated scenario state
- No conflicts, no serialization needed
Automatic Test ID Propagation (Express advantage)
- AsyncLocalStorage propagates test IDs automatically
- No manual header forwarding in route handlers
- MSW handlers receive test ID transparently
Reusable Scenarios
- Define scenarios once, use across all tests
- Version control your mock scenarios
- Share scenarios across teams
Zero Boilerplate
- One function call (
createScenarist()) wires everything - Middleware + endpoints + MSW automatically configured
- Just add
app.use(scenarist.middleware)and you're done
What is this package?
This package provides a complete Express integration for Scenarist's scenario management system. With one function call, you get:
- Runtime scenario switching via HTTP endpoints (
/__scenario__) - Test isolation using unique test IDs (
x-scenarist-test-idheader) - Automatic MSW integration for request interception
- AsyncLocalStorage for automatic test ID propagation
- Zero boilerplate - everything wired automatically
Installation
# npm
npm install --save-dev @scenarist/express-adapter msw
# pnpm
pnpm add -D @scenarist/express-adapter msw
# yarn
yarn add -D @scenarist/express-adapter mswNote: All Scenarist types (ScenaristScenario, ScenaristMock, etc.) are re-exported from @scenarist/express-adapter for convenience. You don't need to install @scenarist/core or @scenarist/msw-adapter separately - they're already included as dependencies.
Peer Dependencies:
express^4.18.0 || ^5.0.0msw^2.0.0
📖 Documentation
Full documentation at scenarist.io
| Topic | Link | | ----------------------------- | ---------------------------------------------------------------------------------------------------------- | | Why Scenarist? | scenarist.io/getting-started/why-scenarist | | Getting Started (Express) | scenarist.io/frameworks/express/getting-started | | Tool Comparison | scenarist.io/comparison | | Parallel Testing | scenarist.io/testing/parallel-testing | | Testing Philosophy | scenarist.io/concepts/philosophy | | Architecture | scenarist.io/concepts/architecture |
Quick Start
1. Define Scenarios
// test/scenarios.ts
import type {
ScenaristScenario,
ScenaristScenarios,
} from "@scenarist/express-adapter";
const defaultScenario: ScenaristScenario = {
id: "default",
name: "Default Scenario",
description: "Baseline responses for all APIs",
mocks: [
{
method: "GET",
url: "https://api.example.com/user",
response: {
status: 200,
body: {
id: "000",
name: "Default User",
role: "user",
},
},
},
],
};
const adminUserScenario: ScenaristScenario = {
id: "admin-user",
name: "Admin User",
description: "User with admin privileges",
mocks: [
{
method: "GET",
url: "https://api.example.com/user",
response: {
status: 200,
body: {
id: "123",
name: "Admin User",
role: "admin",
},
},
},
],
};
// Export as typed scenarios object for type safety
export const scenarios = {
default: defaultScenario,
adminUser: adminUserScenario,
} as const satisfies ScenaristScenarios;2. Create Scenarist Instance
// src/app.ts
import express from "express";
import { createScenarist } from "@scenarist/express-adapter";
import { scenarios } from "./scenarios";
// Factory function for Express app setup
export const createApp = () => {
const app = express();
app.use(express.json());
// Create Scenarist instance (synchronous)
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios, // All scenarios registered upfront
strictMode: false,
});
// Add Scenarist middleware (only if enabled)
if (scenarist) {
app.use(scenarist.middleware);
}
// Your application routes
app.get("/api/user", async (req, res) => {
const response = await fetch("https://api.example.com/user");
const user = await response.json();
res.json(user);
});
return { app, scenarist };
};3. Use in Tests
// test/api.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
// Factory function for test setup - no let variables
const createTestSetup = () => {
const { app, scenarist } = createApp();
return { app, scenarist };
};
describe("User API", () => {
const { app, scenarist } = createTestSetup();
beforeAll(() => {
scenarist?.start();
});
afterAll(async () => {
await scenarist?.stop();
});
it("should return admin user", async () => {
// Set scenario for this test
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", "admin-test")
.send({ scenario: "admin-user" });
// Make request - MSW intercepts automatically
const response = await request(app)
.get("/api/user")
.set("x-scenarist-test-id", "admin-test");
expect(response.status).toBe(200);
expect(response.body.role).toBe("admin");
});
});API Reference
createScenarist(options)
Creates a Scenarist instance with everything wired automatically.
Note: This function is synchronous and returns the instance directly (or undefined in production).
Parameters:
type ExpressAdapterOptions<T extends ScenaristScenarios> = {
enabled: boolean; // Whether mocking is enabled
scenarios: T; // REQUIRED - scenarios object
strictMode?: boolean; // Return 501 for unmocked requests (default: false)
headers?: {
testId?: string; // Header for test ID (default: 'x-scenarist-test-id')
};
endpoints?: {
setScenario?: string; // POST endpoint (default: '/__scenario__')
getScenario?: string; // GET endpoint (default: '/__scenario__')
};
defaultTestId?: string; // Default test ID (default: 'default-test')
registry?: ScenarioRegistry; // Custom registry (default: InMemoryScenarioRegistry)
store?: ScenarioStore; // Custom store (default: InMemoryScenarioStore)
};Returns:
Promise<ExpressScenarist<T> | undefined>;Where ExpressScenarist<T> is:
type ExpressScenarist<T extends ScenaristScenarios> = {
config: ScenaristConfig; // Resolved configuration (endpoints, headers, etc.)
middleware: Router; // Express middleware (includes test ID extraction + scenario endpoints)
switchScenario: (
testId: string,
scenarioId: keyof T,
variant?: string,
) => ScenaristResult<void, Error>;
getActiveScenario: (testId: string) => ActiveScenario | undefined;
getScenarioById: (scenarioId: string) => ScenaristScenario | undefined;
listScenarios: () => ReadonlyArray<ScenaristScenario>;
clearScenario: (testId: string) => void;
start: () => void; // Start MSW server
stop: () => Promise<void>; // Stop MSW server
};Example:
const scenarios = {
default: defaultScenario,
success: successScenario,
error: errorScenario,
} as const satisfies ScenaristScenarios;
const scenarist = createScenarist({
enabled: true,
scenarios,
strictMode: false,
});
// Only apply middleware if scenarist is defined (not production)
if (scenarist) {
app.use(scenarist.middleware);
beforeAll(() => scenarist.start());
afterAll(() => scenarist.stop());
}Scenario Endpoints
The middleware automatically exposes these endpoints:
POST /__scenario__ - Set Active Scenario
Request:
{
scenario: string; // Scenario ID (required)
variant?: string; // Variant name (optional)
}Response (200):
{
success: true;
testId: string;
scenario: string;
variant?: string;
}Example:
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", "test-123")
.send({ scenario: "user-logged-in" });GET /__scenario__ - Get Active Scenario
Response (200):
{
testId: string;
scenarioId: string;
scenarioName?: string;
}Response (404) - No Active Scenario:
{
error: "No active scenario for this test ID";
testId: string;
}Example:
// After setting a scenario
const response = await request(app)
.get("/__scenario__")
.set("x-scenarist-test-id", "test-123");
expect(response.status).toBe(200);
expect(response.body.scenarioId).toBe("success");
// Before setting a scenario
const response2 = await request(app)
.get("/__scenario__")
.set("x-scenarist-test-id", "new-test");
expect(response2.status).toBe(404);
expect(response2.body.error).toBe("No active scenario for this test ID");GET /__scenarist__/state - Debug State Endpoint
Inspect the current test state for debugging. Useful when testing multi-stage flows with afterResponse.setState.
Response (200):
{
testId: string;
state: Record<string, unknown>; // Current test state
}Example:
const response = await request(app)
.get("/__scenarist__/state")
.set("x-scenarist-test-id", "test-123");
expect(response.status).toBe(200);
console.log(response.body.state); // { submitted: true, phase: "review" }When to use:
- Debugging failing tests with state-aware mocking
- Verifying
afterResponse.setStatemutations - Testing conditional
afterResponsebehavior (see ADR-0020)
Note: This endpoint is automatically included in scenarist.middleware - no additional setup required!
Core Capabilities
Scenarist provides 20+ powerful features for scenario-based testing. All capabilities work seamlessly with Express via automatic test ID propagation.
Request Matching (6 capabilities)
Body matching (partial match) - Match requests based on request body fields
{
method: 'POST',
url: '/api/items',
match: { body: { itemId: 'premium-item' } },
response: { status: 200, body: { price: 100 } }
}Header matching (exact match) - Perfect for user tier testing
{
method: 'GET',
url: '/api/data',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { limit: 1000 } }
}Query parameter matching - Different responses for filtered requests
{
method: 'GET',
url: '/api/search',
match: { query: { filter: 'active' } },
response: { status: 200, body: { results: [...] } }
}Combined matching - Combine body + headers + query (all must pass) Specificity-based selection - Most specific mock wins (no need to order carefully) Fallback mocks - Mocks without match criteria act as catch-all
Response Sequences (4 capabilities)
Single responses - Return same response every time Response sequences (ordered) - Return different response on each call
{
method: 'GET',
url: '/api/job/:id',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete' } }
],
repeat: 'last' // Stay at final response
}
}Repeat modes - last (stay at final), cycle (loop), none (exhaust)
Sequence exhaustion with fallback - Exhausted sequences skip to next mock
Stateful Mocks (6 capabilities)
State capture from requests - Extract values from body/headers/query
State injection via templates - Inject captured state using {{state.X}}
// Capture from POST
{
method: 'POST',
url: '/api/cart/items',
captureState: { 'cartItems[]': 'body.item' }, // Append to array
response: { status: 200 }
}
// Inject into GET
{
method: 'GET',
url: '/api/cart',
response: {
status: 200,
body: {
items: '{{state.cartItems}}',
count: '{{state.cartItems.length}}'
}
}
}Array append support - Syntax: stateKey[] appends to array
Nested state paths - Support dot notation: user.profile.name
State isolation per test ID - Each test ID has isolated state
State reset on scenario switch - Fresh state for each scenario
Core Features (4 capabilities)
Multiple API mocking - Mock any number of external APIs in one scenario Default scenario fallback - Unmocked endpoints fall back to default scenario Test ID isolation - Run 100+ tests concurrently without conflicts Runtime scenario switching - Change backend state with one API call
Additional Features
Path parameters (/users/:id), Wildcard URLs (*/api/*), Response delays, Custom headers, Strict mode (fail on unmocked requests)
Want to learn more? See Core Functionality Documentation for detailed explanations and examples.
Core Concepts
Test ID Isolation
Each request can include an x-scenarist-test-id header. Scenarist uses this to isolate scenarios, enabling concurrent tests with different backend states:
// Test 1 uses scenario A
await request(app).get("/api/data").set("x-scenarist-test-id", "test-1"); // Uses scenario A
// Test 2 uses scenario B (runs concurrently!)
await request(app).get("/api/data").set("x-scenarist-test-id", "test-2"); // Uses scenario BAutomatic Test ID Propagation
Express advantage: Unlike frameworks without middleware (like Next.js), Express uses AsyncLocalStorage to automatically propagate test IDs throughout the request lifecycle.
What this means:
- Middleware extracts test ID from
x-scenarist-test-idheader once - Test ID stored in AsyncLocalStorage for the request duration
- MSW handlers automatically access test ID from AsyncLocalStorage
- No manual header forwarding needed when making external API calls
Example - No manual forwarding:
// routes/products.ts
app.get("/api/products", async (req, res) => {
// Test ID automatically available to MSW via AsyncLocalStorage
// No need to manually forward headers!
const response = await fetch("http://external-api.com/products");
const products = await response.json();
res.json(products);
});Compare to Next.js (which requires manual forwarding):
// Next.js - MUST manually forward headers
const response = await fetch("http://external-api.com/products", {
headers: {
...getScenaristHeaders(req), // Required!
},
});Express - NO manual forwarding needed (AsyncLocalStorage handles it):
// Express - AsyncLocalStorage propagates automatically
const response = await fetch("http://external-api.com/products");
// MSW handlers receive test ID from AsyncLocalStorageWhy this works:
- Express middleware runs before all routes
- Middleware extracts
x-scenarist-test-idand stores in AsyncLocalStorage - MSW dynamic handler reads from AsyncLocalStorage
- All external API calls intercepted with correct test ID
For architectural details, see: ADR-0007: Framework-Specific Header Forwarding
Automatic MSW Integration
The createScenarist() function automatically:
- Creates an MSW server with a dynamic handler
- Wires test ID extraction from headers via AsyncLocalStorage
- Sets up scenario control endpoints (POST/GET
/__scenario__) - Looks up the active scenario for each test ID
- Returns mocked responses based on the scenario
The middleware includes everything:
- Test ID extraction from
x-scenarist-test-idheader (stored in AsyncLocalStorage) - Scenario control endpoints (
/__scenario__) - All wired together - just add
app.use(scenarist.middleware)
You never see MSW code - it's all handled internally.
Default Scenario Fallback
If a mock isn't found in the active scenario, Scenarist automatically falls back to the 'default' scenario (enforced via schema validation):
const scenarios = {
default: {
// REQUIRED - must have 'default' key
id: "default",
name: "Default Happy Path",
description: "Base responses for all APIs",
mocks: [
{
method: "GET",
url: "*/api/users",
response: { status: 200, body: [] },
},
{
method: "GET",
url: "*/api/orders",
response: { status: 200, body: [] },
},
],
},
userError: {
id: "user-error",
name: "User API Error",
mocks: [
{
method: "GET",
url: "*/api/users",
response: { status: 500, body: { error: "Server error" } },
},
// Orders endpoint falls back to default scenario
],
},
} as const satisfies ScenaristScenarios;
const scenarist = createScenarist({
enabled: true,
scenarios, // 'default' key is validated at runtime
});Type-Safe Scenario IDs
The new API provides full type safety with TypeScript autocomplete for scenario IDs:
// scenarios.ts - define scenarios with type constraint
import type { ScenaristScenarios } from "@scenarist/express-adapter";
export const scenarios = {
default: defaultScenario,
success: successScenario,
githubNotFound: githubNotFoundScenario,
weatherError: weatherErrorScenario,
stripeFailure: stripeFailureScenario,
} as const satisfies ScenaristScenarios;
// setup.ts - create scenarist with type parameter
import { scenarios } from "./scenarios";
export const createTestSetup = () => {
const scenarist = createScenarist({
enabled: true,
scenarios, // ✅ Autocomplete + type-checked!
});
return scenarist;
};
// test.ts - type-safe scenario switching
const scenarist = createTestSetup();
scenarist?.switchScenario("test-123", "success"); // ✅ Autocomplete works!
scenarist?.switchScenario("test-123", "invalid-name"); // ❌ TypeScript error!
await request(app)
.post(scenarist.config.endpoints.setScenario)
.set(SCENARIST_TEST_ID_HEADER, "test-123")
.send({ scenario: "success" }); // ✅ Type-safe!Benefits:
- ✅ Autocomplete for scenario IDs in your editor
- ✅ Refactor-safe (rename propagates everywhere)
- ✅ Compile-time errors for typos
- ✅ Single source of truth
Common Patterns
Pattern 1: Test Helpers
Create helper functions to reduce boilerplate:
// test/helpers.ts
import request from "supertest";
import { app } from "../src/app";
export const setScenario = async (
testId: string,
scenario: string,
variant?: string,
) => {
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", testId)
.send({ scenario, variant });
};
export const makeRequest = (testId: string) => {
return request(app).set("x-scenarist-test-id", testId);
};Usage:
import { setScenario, makeRequest } from "./helpers";
test("payment flow", async () => {
const testId = "payment-test";
await setScenario(testId, "payment-success");
const response = await makeRequest(testId).post("/api/charge");
expect(response.status).toBe(200);
});Pattern 2: Unique Test IDs
Generate unique test IDs automatically:
import { randomUUID } from "crypto";
describe("API Tests", () => {
let testId: string;
beforeEach(() => {
testId = randomUUID();
});
it("should process payment", async () => {
await setScenario(testId, "payment-success");
const response = await makeRequest(testId).post("/api/charge");
expect(response.status).toBe(200);
});
});Pattern 3: Development Workflows
Enable scenario switching during development:
const scenarist = createScenarist({
enabled:
process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test",
scenarios,
strictMode: false,
});Manually switch scenarios with curl:
# Switch to error scenario
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-d '{"scenario": "payment-declined"}'
# Check active scenario
curl http://localhost:3000/__scenario__Configuration
Environment-Specific
// Test-only
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
strictMode: true, // Fail if any unmocked request
});
// Development and test
const scenarist = createScenarist({
enabled:
process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development",
scenarios,
strictMode: false, // Allow passthrough to real APIs
});
// Opt-in with environment variable
const scenarist = createScenarist({
enabled: process.env.ENABLE_MOCKING === "true",
scenarios,
strictMode: false,
});Custom Headers and Endpoints
const scenarist = createScenarist({
enabled: true,
scenarios,
headers: {
testId: "x-my-test-id",
},
endpoints: {
setScenario: "/api/scenarios/set",
getScenario: "/api/scenarios/active",
},
});Production Tree-Shaking
Scenarist is designed to be completely eliminated from production bundles when NODE_ENV=production. The implementation automatically disables itself and returns undefined, allowing bundlers to remove all Scenarist and MSW code through tree-shaking.
Unbundled Deployments (Most Express Apps) ✅
For most Express applications that deploy unbundled code directly to production (the standard pattern), tree-shaking works automatically with zero configuration:
# Deploy your application
NODE_ENV=production node src/server.jsHow it works:
process.env.NODE_ENV === 'production'evaluates totrueat runtimecreateScenarist()returnsundefinedwithout loading dependencies- MSW and all Scenarist code never loads into memory
- Zero performance impact, zero bundle bloat
This is the default use case - most Express applications don't bundle their server code.
Bundled Deployments (esbuild, webpack, Vite, rollup)
For teams that bundle their Express server code, additional bundler configuration is required to enable complete tree-shaking.
Why configuration is needed:
Scenarist uses conditional package.json exports to provide a production-specific entry point with zero dependencies:
{
"exports": {
".": {
"production": "./dist/setup/production.js", // Zero imports
"default": "./dist/index.js" // Full implementation
}
}
}The "production" condition is a custom condition (not a Node.js built-in like "import" or "require"). Bundlers must be explicitly configured to recognize it.
Without configuration:
- MSW code included in bundle (~320kb)
- Code never executes (safe)
- Wastes bandwidth
With configuration:
- MSW code completely eliminated
- Bundle size reduced by ~52% (618kb → 298kb)
- Optimal production deployment
Bundler Configuration
esbuild
Add the --conditions=production flag:
{
"scripts": {
"build": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js --define:process.env.NODE_ENV='\"production\"' --conditions=production"
}
}webpack
Add conditionNames to resolve configuration:
// webpack.config.js
module.exports = {
mode: "production",
resolve: {
conditionNames: ["production", "import", "require"],
},
plugins: [
new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify("production"),
}),
],
};Vite
Add conditions to resolve configuration:
// vite.config.js
export default {
resolve: {
conditions: ["production"],
},
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
};rollup
Add exportConditions to node-resolve plugin:
// rollup.config.js
import resolve from "@rollup/plugin-node-resolve";
import replace from "@rollup/plugin-replace";
export default {
plugins: [
resolve({
exportConditions: ["production"],
}),
replace({
"process.env.NODE_ENV": JSON.stringify("production"),
preventAssignment: true,
}),
],
};Verifying Tree-Shaking
The Express example app includes a verification script you can adapt:
{
"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": "pnpm build:production && ! grep -rE '(setupWorker|startWorker|http\\.(get|post|put|delete|patch)|HttpResponse\\.json)' dist/"
},
"devDependencies": {
"esbuild": "^0.27.0"
}
}Run verification:
pnpm verify:treeshakingSuccess output:
dist/server.js 298.4kb
✨ Done in 13msThe script checks that MSW-specific implementation patterns (setupWorker, HttpResponse.json, etc.) are not present in the production bundle.
Bundle Size Comparison
Without tree-shaking configuration:
- Bundle size: ~618kb
- Includes: Application + Zod + MSW + Scenarist
- Status: Code included but never executes
With tree-shaking configuration:
- Bundle size: ~298kb (52% reduction)
- Includes: Application + Zod only
- Status: MSW and Scenarist completely eliminated
Trade-Offs
| Deployment Type | Configuration Required | Tree-Shaking | Bundle Impact | | -------------------------------- | ---------------------- | ------------ | -------------------------- | | Unbundled (standard Express) | ✅ None | ✅ Automatic | ✅ Zero (code never loads) | | Bundled without config | ❌ None | ❌ Partial | ⚠️ ~320kb dead code | | Bundled with config | ✅ One line | ✅ Complete | ✅ Zero (eliminated) |
Recommendation:
- If you're deploying unbundled code: No action needed ✅
- If you're bundling: Add the one-line bundler configuration for optimal bundle size
Logging & Debugging
Scenarist includes a flexible logging system for debugging scenario matching, state management, and request handling. Logging is disabled by default and must be explicitly enabled. For comprehensive documentation including log categories, custom loggers, and Vitest configuration, see the full logging guide.
Quick Start
import {
createScenarist,
createConsoleLogger,
} from "@scenarist/express-adapter";
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
// Enable logging with pretty format
logger: createConsoleLogger({ level: "info", format: "pretty" }),
});Environment Variable Pattern
For easy toggling without code changes:
import {
createScenarist,
createConsoleLogger,
noOpLogger,
type LogLevel,
type LogFormat,
} from "@scenarist/express-adapter";
// Type-safe environment variable parsing
const LOG_LEVELS: ReadonlyArray<Exclude<LogLevel, "silent">> = [
"error",
"warn",
"info",
"debug",
"trace",
];
const LOG_FORMATS: ReadonlyArray<LogFormat> = ["pretty", "json"];
const parseLogLevel = (
value: string | undefined,
): Exclude<LogLevel, "silent"> =>
LOG_LEVELS.includes(value as Exclude<LogLevel, "silent">)
? (value as Exclude<LogLevel, "silent">)
: "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 via SCENARIST_LOG=1 environment variable
logger: process.env.SCENARIST_LOG
? createConsoleLogger({
level: parseLogLevel(process.env.SCENARIST_LOG_LEVEL),
format: parseLogFormat(process.env.SCENARIST_LOG_FORMAT),
})
: noOpLogger,
});Then run tests with logging:
# Enable info-level logging
SCENARIST_LOG=1 pnpm test
# Enable debug-level logging for match troubleshooting
SCENARIST_LOG=1 SCENARIST_LOG_LEVEL=debug pnpm testNote:
SCENARIST_LOGis a convention for your code, not something Scenarist reads automatically. You must explicitly pass aloggertocreateScenarist()as shown above.
Log 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 |
Sample Output
Pretty format (default) - human-readable with emojis and colors:
12:34:56.789 INF 🎬 [test-user-login] scenario | scenario_switched scenarioId=premium-user
12:34:56.801 INF 🎯 [test-user-login] matching | mock_selected mockIndex=2 specificity=5
12:34:56.810 INF 💾 [test-user-login] state | state_captured key=userId value=user-123
12:34:56.815 WRN 🎯 [test-user-login] matching | mock_no_match url=/api/unknownJSON format - for log aggregation tools (Datadog, Splunk, etc.):
{"level":"info","category":"scenario","message":"scenario_switched","testId":"test-user-login","scenarioId":"premium-user","timestamp":1732650896789}
{"level":"info","category":"matching","message":"mock_selected","testId":"test-user-login","data":{"mockIndex":2,"specificity":5},"timestamp":1732650896801}For more details including log categories, custom loggers, and Vitest configuration, see the full logging documentation.
Troubleshooting
Scenarios switch but requests aren't mocked
Problem: Scenario endpoints work but external API calls go to real endpoints.
Solution: Ensure you've called scenarist.start() before tests and scenarist.stop() after:
beforeAll(() => scenarist.start()); // Starts MSW server
afterAll(() => scenarist.stop()); // Stops MSW serverTests see each other's scenarios
Problem: Different tests are seeing each other's active scenarios.
Solution: Ensure you're sending the x-scenarist-test-id header with every request:
// ❌ Wrong - missing header on second request
await setScenario("test-1", "my-scenario");
const response = await request(app).get("/api/data"); // No test ID!
// ✅ Correct - header on all requests
await setScenario("test-1", "my-scenario");
const response = await request(app)
.get("/api/data")
.set("x-scenarist-test-id", "test-1");Scenario not found error
Problem: Scenario not found when setting scenario.
Solution: Ensure the scenario ID exists in your scenarios object:
const scenarios = {
default: defaultScenario,
myScenario: myScenario, // ✅ Registered
} as const satisfies ScenaristScenarios;
const scenarist = createScenarist({
enabled: true,
scenarios,
});
await setScenario("test-1", "myScenario"); // ✅ Works
await setScenario("test-1", "unknown"); // ❌ Error: Scenario not foundTypeScript
This package is written in TypeScript and includes full type definitions.
Exported Types:
// Adapter-specific types
import type {
ExpressAdapterOptions,
ExpressScenarist,
} from "@scenarist/express-adapter";
// Core types (re-exported for convenience)
import type {
ScenaristScenario,
ScenaristMock,
ScenaristResponse,
ScenaristSequence,
ScenaristMatch,
ScenaristCaptureConfig,
ScenaristScenarios,
ScenaristConfig,
ScenaristResult,
} from "@scenarist/express-adapter";Note: All core types are re-exported from @scenarist/express-adapter, so you only need one import path for all Scenarist types.
Examples
See the Express Example App for a complete working example demonstrating:
- ✅ Runtime scenario switching - Change API behavior without restart
- ✅ Test ID isolation - 20 tests with concurrent scenarios
- ✅ Default fallback - Partial scenarios automatically falling back
- ✅ Real API integration - Actual Express routes calling external APIs
- ✅ Multiple scenarios - Success, errors, timeouts, mixed results
The example includes:
- Complete Express application with GitHub, Weather, and Stripe API integrations
- 7 different scenario definitions
- 20 passing scenario-based tests demonstrating all features
- Comprehensive documentation and usage patterns
Documentation
📖 Full Documentation - Complete guides, API reference, and examples.
Contributing
See CONTRIBUTING.md for development setup and guidelines.
License
MIT
Related Packages
- @scenarist/core - Core scenario management
- @scenarist/msw-adapter - MSW integration (used internally)
Note: The MSW adapter is used internally by this package. Users of @scenarist/express-adapter don't need to interact with it directly.
