Skip to content

Express - Getting Started

Test your Express APIs with runtime scenario switching. Zero boilerplate setup using AsyncLocalStorage.

Terminal window
npm install @scenarist/express-adapter
npm install -D @playwright/test @scenarist/playwright-helpers

1. Define your scenarios:

src/scenarios.ts
import type {
ScenaristScenario,
ScenaristScenarios,
} from "@scenarist/express-adapter";
// ✅ RECOMMENDED - Default scenario with complete happy path
const defaultScenario: ScenaristScenario = {
id: "default",
name: "Happy Path",
description: "All external APIs succeed with valid responses",
mocks: [
// Stripe: Successful payment
{
method: "POST",
url: "https://api.stripe.com/v1/charges",
response: {
status: 200,
body: { id: "ch_123", status: "succeeded", amount: 5000 },
},
},
// Auth0: Authenticated user
{
method: "GET",
url: "https://api.auth0.com/userinfo",
response: {
status: 200,
body: { sub: "user_123", email: "john@example.com", tier: "standard" },
},
},
// SendGrid: Email sent successfully
{
method: "POST",
url: "https://api.sendgrid.com/v3/mail/send",
response: {
status: 202,
body: { message_id: "msg_123" },
},
},
],
};
// Specialized scenario: Override ONLY Stripe for payment failure
const cardDeclinedScenario: ScenaristScenario = {
id: "cardDeclined",
name: "Card Declined",
description: "Stripe declines payment, everything else succeeds",
mocks: [
// Override: Stripe declines payment
{
method: "POST",
url: "https://api.stripe.com/v1/charges",
response: {
status: 402,
body: {
error: { code: "card_declined", message: "Your card was declined" },
},
},
},
// Auth0 and SendGrid automatically fall back to default (happy path)
],
};
export const scenarios = {
default: defaultScenario,
cardDeclined: cardDeclinedScenario,
} as const satisfies ScenaristScenarios;

2. Set up Scenarist in your Express app:

src/app.ts
import express from "express";
import {
createScenarist,
type ExpressScenarist,
} from "@scenarist/express-adapter";
import { scenarios } from "./scenarios";
// Use async factory pattern for Express apps
export const createApp = async () => {
const app = express();
app.use(express.json());
// 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 point
const main = async () => {
const { app, scenarist } = await createApp();
if (scenarist) {
scenarist.start();
}
app.listen(3000, () => console.log("Server running on :3000"));
};
main().catch(console.error);
**3. Install testing dependencies:**
```bash
npm install -D vitest supertest @types/supertest

4. Write your tests:

tests/checkout.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import request from "supertest";
import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";
import { createApp } from "../src/app";
// Factory function for test setup - no let variables
const createTestSetup = async () => {
const { app, scenarist } = await createApp();
return { app, scenarist };
};
describe("Checkout API", () => {
const testContext = createTestSetup();
beforeAll(async () => {
const { scenarist } = await testContext;
scenarist?.start(); // Start MSW
});
afterAll(async () => {
const { scenarist } = await testContext;
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");
});
});

Every test request must include a test ID header for proper test isolation. This is what enables parallel test execution without interference.

  1. Unique test ID per test: Each test gets a unique identifier (e.g., 'test-1', 'test-2')
  2. Header on every request: Send the test ID with every HTTP request
  3. AsyncLocalStorage tracking: Express adapter uses AsyncLocalStorage to track which test ID is active for the current request
  4. Scenario isolation: Each test ID has its own active scenario and state

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:

  1. First request switches to 'cardDeclined' scenario for test ID 'test-1'
  2. Second request includes same test ID header
  3. AsyncLocalStorage associates the request with 'test-1'
  4. Scenarist uses the 'cardDeclined' scenario for this request
  5. Different test using 'test-2' would use its own scenario

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') // Recommended

Why use SCENARIST_TEST_ID_HEADER?

  • Avoids magic strings (clear intent)
  • Type-safe (TypeScript will catch typos)
  • Consistent across all Scenarist packages

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

If you forget to include the test ID header:

// ❌ Missing test ID header
const response = await request(app)
.post("/api/checkout")
// .set(SCENARIST_TEST_ID_HEADER, 'test-1') ← MISSING!
.send({ amount: 5000, token: "tok_test" });

What happens:

  • Request uses the 'default' scenario (fallback behavior)
  • If you switched to a different scenario, it won’t be used
  • May cause confusing test failures (wrong scenario active)

Always include the header on every request!

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

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.

Scenarist automatically eliminates all test code from production builds. The implementation differs based on your deployment model:

Most Express applications deploy unbundled code directly to production:

Terminal window
NODE_ENV=production node src/server.js

Tree-shaking is automatic with zero configuration:

  • createScenarist() returns undefined at 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:

webpack.config.js
module.exports = {
mode: "production",
resolve: {
conditionNames: ["production", "import", "require"],
},
};

Vite:

vite.config.js
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:

Terminal window
# Build with bundler configuration
npm 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.

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.