Express - Getting Started
Test your Express APIs with runtime scenario switching. Zero boilerplate setup using AsyncLocalStorage.
Installation
Section titled “Installation”npm install @scenarist/express-adapternpm install -D @playwright/test @scenarist/playwright-helpersBasic Setup
Section titled “Basic Setup”1. Define your scenarios:
import type { ScenaristScenario, ScenaristScenarios,} from "@scenarist/express-adapter";
// ✅ RECOMMENDED - Default scenario with complete happy pathconst defaultScenario: ScenaristScenario = { id: "default", name: "Happy Path", description: "All external APIs succeed with valid responses", mocks: [ // Stripe: Successful payment { method: "POST", url: "https://api.stripe.com/v1/charges", response: { status: 200, body: { id: "ch_123", status: "succeeded", amount: 5000 }, }, }, // Auth0: Authenticated user { method: "GET", url: "https://api.auth0.com/userinfo", response: { status: 200, body: { sub: "user_123", email: "john@example.com", tier: "standard" }, }, }, // SendGrid: Email sent successfully { method: "POST", url: "https://api.sendgrid.com/v3/mail/send", response: { status: 202, body: { message_id: "msg_123" }, }, }, ],};
// Specialized scenario: Override ONLY Stripe for payment failureconst cardDeclinedScenario: ScenaristScenario = { id: "cardDeclined", name: "Card Declined", description: "Stripe declines payment, everything else succeeds", mocks: [ // Override: Stripe declines payment { method: "POST", url: "https://api.stripe.com/v1/charges", response: { status: 402, body: { error: { code: "card_declined", message: "Your card was declined" }, }, }, }, // Auth0 and SendGrid automatically fall back to default (happy path) ],};
export const scenarios = { default: defaultScenario, cardDeclined: cardDeclinedScenario,} as const satisfies ScenaristScenarios;2. Set up Scenarist in your Express app:
import express from "express";import { createScenarist, type ExpressScenarist,} from "@scenarist/express-adapter";import { scenarios } from "./scenarios";
// Use async factory pattern for Express appsexport const createApp = async () => { const app = express(); app.use(express.json());
// Create Scenarist instance (async - returns Promise) const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", scenarios, });
// Add Scenarist middleware BEFORE your routes (only if enabled) if (scenarist) { app.use(scenarist.middleware); }
// Your routes run normally - Scenarist just mocks external APIs app.post("/api/checkout", async (req, res) => { const { amount, token } = req.body;
// Your validation runs if (amount < 1) { return res.status(400).json({ error: "Invalid amount" }); }
// External Stripe call is mocked by Scenarist const charge = await fetch("https://api.stripe.com/v1/charges", { method: "POST", headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` }, body: JSON.stringify({ amount, source: token }), });
const result = await charge.json();
// Your business logic runs if (charge.status === 200) { return res.json({ success: true, chargeId: result.id }); } else { return res.status(402).json({ error: result.error.message }); } });
return { app, scenarist };};
// src/server.ts - Entry pointconst main = async () => { const { app, scenarist } = await createApp();
if (scenarist) { scenarist.start(); }
app.listen(3000, () => console.log("Server running on :3000"));};
main().catch(console.error);**3. Install testing dependencies:**
```bashnpm install -D vitest supertest @types/supertest4. Write your tests:
import { describe, it, expect, beforeAll, afterAll } from "vitest";import request from "supertest";import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";import { createApp } from "../src/app";
// Factory function for test setup - no let variablesconst createTestSetup = async () => { const { app, scenarist } = await createApp(); return { app, scenarist };};
describe("Checkout API", () => { const testContext = createTestSetup();
beforeAll(async () => { const { scenarist } = await testContext; scenarist?.start(); // Start MSW });
afterAll(async () => { const { scenarist } = await testContext; scenarist?.stop(); // Stop MSW });
it("processes payment successfully", async () => { const { app, scenarist } = await testContext;
// Switch to default scenario await request(app) .post(scenarist!.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-1") .send({ scenario: "default" });
// Make API request - your Express route runs normally const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-1") .send({ amount: 5000, token: "tok_test" });
expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.chargeId).toBe("ch_123"); });
it("handles card declined error", async () => { const { app, scenarist } = await testContext;
// Switch to cardDeclined scenario await request(app) .post(scenarist!.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-2") .send({ scenario: "cardDeclined" });
const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-2") .send({ amount: 5000, token: "tok_test" });
expect(response.status).toBe(402); expect(response.body.error).toContain("declined"); });});Test ID Headers
Section titled “Test ID Headers”Every test request must include a test ID header for proper test isolation. This is what enables parallel test execution without interference.
How Test Isolation Works
Section titled “How Test Isolation Works”- Unique test ID per test: Each test gets a unique identifier (e.g.,
'test-1','test-2') - Header on every request: Send the test ID with every HTTP request
- AsyncLocalStorage tracking: Express adapter uses AsyncLocalStorage to track which test ID is active for the current request
- Scenario isolation: Each test ID has its own active scenario and state
Required Headers
Section titled “Required Headers”Include the test ID header on both scenario switch requests AND actual API requests:
// Step 1: Switch scenario (with test ID header)await request(app) .post(scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-1") // ← Test ID header .send({ scenario: "cardDeclined" });
// Step 2: Make API request (with SAME test ID header)const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-1") // ← Same test ID .send({ amount: 5000, token: "tok_test" });What happens:
- First request switches to
'cardDeclined'scenario for test ID'test-1' - Second request includes same test ID header
- AsyncLocalStorage associates the request with
'test-1' - Scenarist uses the
'cardDeclined'scenario for this request - Different test using
'test-2'would use its own scenario
Header Name
Section titled “Header Name”The header name is standardized to 'x-scenarist-test-id':
// These are equivalent:.set('x-scenarist-test-id', 'test-1').set(SCENARIST_TEST_ID_HEADER, 'test-1') // RecommendedWhy use SCENARIST_TEST_ID_HEADER?
- Avoids magic strings (clear intent)
- Type-safe (TypeScript will catch typos)
- Consistent across all Scenarist packages
Parallel Test Execution
Section titled “Parallel Test Execution”Test IDs enable parallel test execution without interference:
describe("Parallel checkout tests", () => { it("test 1: successful payment", async () => { // Uses test ID 'test-1' await request(app) .post(scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-1") .send({ scenario: "default" });
const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-1") .send({ amount: 5000, token: "tok_test" });
expect(response.status).toBe(200); });
it("test 2: card declined", async () => { // Uses test ID 'test-2' - runs simultaneously with test 1 await request(app) .post(scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-2") .send({ scenario: "cardDeclined" });
const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-2") .send({ amount: 5000, token: "tok_test" });
expect(response.status).toBe(402); });});Both tests run in parallel:
- Test 1 uses scenario
'default'via test ID'test-1' - Test 2 uses scenario
'cardDeclined'via test ID'test-2' - No interference because test IDs isolate scenarios and state
Missing Headers = Default Scenario
Section titled “Missing Headers = Default Scenario”If you forget to include the test ID header:
// ❌ Missing test ID headerconst response = await request(app) .post("/api/checkout") // .set(SCENARIST_TEST_ID_HEADER, 'test-1') ← MISSING! .send({ amount: 5000, token: "tok_test" });What happens:
- Request uses the
'default'scenario (fallback behavior) - If you switched to a different scenario, it won’t be used
- May cause confusing test failures (wrong scenario active)
Always include the header on every request!
Internal Fetch Calls
Section titled “Internal Fetch Calls”If your Express routes make internal fetch calls to other services, you must manually propagate the test ID header:
app.get("/api/dashboard", async (req, res) => { // Extract test ID from incoming request const testId = req.get(SCENARIST_TEST_ID_HEADER) || "default-test";
// Include test ID in internal fetch const response = await fetch("http://localhost:3001/api/user", { headers: { [SCENARIST_TEST_ID_HEADER]: testId, }, });
const data = await response.json(); res.json(data);});Why this is needed:
- AsyncLocalStorage only tracks the current request
- Internal fetch calls are new requests (separate context)
- Must explicitly pass test ID header to maintain isolation
What Makes Express Setup Special
Section titled “What Makes Express Setup Special”Zero Boilerplate - Scenarist uses AsyncLocalStorage to automatically track test IDs. No manual header passing required.
Test Isolation - Each test gets its own scenario state. Tests run in parallel without interference.
Your Code Runs - Your Express routes, middleware, validation, and business logic all execute normally. Only external API calls are mocked.
Production Tree-Shaking
Section titled “Production Tree-Shaking”Scenarist automatically eliminates all test code from production builds. The implementation differs based on your deployment model:
Unbundled Deployments (Most Common) ✅
Section titled “Unbundled Deployments (Most Common) ✅”Most Express applications deploy unbundled code directly to production:
NODE_ENV=production node src/server.jsTree-shaking is automatic with zero configuration:
createScenarist()returnsundefinedat runtime- Dynamic imports never execute
- MSW code never loads into memory
- ✅ No configuration required
Bundled Deployments (esbuild, webpack, Vite, rollup)
Section titled “Bundled Deployments (esbuild, webpack, Vite, rollup)”For teams bundling Express server code, additional bundler configuration is required for complete tree-shaking.
Scenarist uses conditional package.json exports to provide different entry points:
{ "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:
{ "scripts": { "build": "esbuild src/server.ts --bundle --conditions=production --define:process.env.NODE_ENV='\"production\"'" }}webpack:
module.exports = { mode: "production", resolve: { conditionNames: ["production", "import", "require"], },};Vite:
export default { resolve: { conditions: ["production"], },};rollup:
import resolve from "@rollup/plugin-node-resolve";
export default { plugins: [ resolve({ exportConditions: ["production"], }), ],};Results:
- ✅ Bundle size: 618kb → 298kb (52% reduction)
- ✅ Zero MSW code in production bundle
Verification:
# Build with bundler configurationnpm run build:production
# Verify MSW code eliminated! grep -rE '(setupWorker|HttpResponse\.json)' dist/For detailed configuration examples, see the Express Adapter README - Production Tree-Shaking.
Debugging with Logging
Section titled “Debugging with Logging”If mocks aren’t matching as expected, enable logging to see what’s happening:
import { createScenarist, createConsoleLogger } from "@scenarist/express-adapter";
const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", scenarios, logger: createConsoleLogger({ level: "debug", categories: ["matching", "scenario"], }),});This shows which mocks are being evaluated and why they match or don’t. See Logging Reference for all options.
Next Steps
Section titled “Next Steps”- Debugging: Learn about logging options for troubleshooting
- Production safety: Learn why Scenarist is safe for production
- Example app: See complete Express example with comprehensive test suite
- Architecture: Learn how Scenarist works