Express: setting up and testing Express apps # Express > Using Scenarist with Express ## Testing Express with Scenarist Scenarist provides first-class support for testing Express applications at the HTTP level, enabling you to test middleware, route handlers, and external API integrations with real HTTP requests. ### The Challenge Testing Express applications traditionally requires choosing between: * Unit testing route handlers in isolation (misses middleware integration) * Mocking Express request/response objects (creates distance from production) * Full E2E tests with browser automation (too slow for comprehensive coverage) ### How Scenarist Helps Scenarist enables HTTP-level integration testing for Express: * Test middleware chains with real HTTP requests * Verify route handlers with different external API scenarios * Test error handling and edge cases comprehensively * Run parallel tests without interference ## Working Example See Scenarist in action with a complete Express application: [**Explore the Express Example App →**](/frameworks/express/example-app/) The example demonstrates: * Testing Express middleware and route handlers * Test ID isolation for parallel execution * Request matching for content-based responses * Sequences for polling and async operations * Stateful mocks with state capture and injection * Complete installation and usage instructions **[View source on GitHub →](https://github.com/citypaul/scenarist/tree/main/apps/express-example)** ## Getting Started Ready to integrate Scenarist into your Express application? [Get started with Express →](/frameworks/express/getting-started/) # Express Example App > Working example demonstrating Scenarist with Express ## Overview The Express example demonstrates HTTP-level integration testing for Express applications using Scenarist. This example focuses on testing middleware, route handlers, and external API integrations. **GitHub:** [apps/express-example](https://github.com/citypaul/scenarist/tree/main/apps/express-example) ## What It Demonstrates This example app showcases all major Scenarist features with Express: ### Core Features * **Middleware Testing** - Test Express middleware chains with real HTTP requests * **Route Handlers** - Test route handlers with different external API responses * **Test ID Isolation** - Parallel test execution without interference * **Runtime Scenario Switching** - Switch scenarios during test execution ### Dynamic Response Features * **Request Matching** - Different responses based on request content * **Sequences** - Multi-step processes (polling, async operations) * **Stateful Mocks** - State capture and injection across requests * **Default Fallback** - Graceful handling when no scenario is active ## Installation ### Prerequisites * Node.js 22+ * pnpm 11+ ### Clone and Install ```bash # Clone the repository git clone https://github.com/citypaul/scenarist.git cd scenarist # Install dependencies pnpm install # Navigate to Express example cd apps/express-example ``` ## Running the Example ### Development Mode ```bash # Start the Express server pnpm dev ``` Server runs on . ### Run Tests ```bash # Run all tests pnpm test # Run specific test file pnpm test scenario-switching # Run with coverage pnpm test --coverage ``` ## Key Files ### Scenarist Setup **`src/app.ts`** - Express app with Scenarist integration (simplified; [view on GitHub](https://github.com/citypaul/scenarist/blob/main/apps/express-example/src/app.ts)) ```typescript import express from "express"; import { createScenarist, type ExpressScenarist, } from "@scenarist/express-adapter"; import { scenarios } from "./scenarios.js"; export const createApp = () => { const app = express(); app.use(express.json()); // Create Scenarist instance (synchronous - returns undefined in production builds) const scenarist = createScenarist({ enabled: true, scenarios, strictMode: false, }); // Register Scenarist middleware (skipped in production builds) if (scenarist) { app.use(scenarist.middleware); } // Your routes (the app registers GitHub, weather, Stripe, cart, form and more) app.get("/api/github/user/:username", async (req, res) => { const response = await fetch( `https://api.github.com/users/${encodeURIComponent(req.params.username)}`, ); const data = await response.json(); res.status(response.status).json(data); }); return { app, scenarist }; }; ``` **`src/server.ts`** - Entry point ```typescript import { createApp } from "./app.js"; const { app, scenarist } = createApp(); scenarist?.start(); app.listen(process.env.PORT || 3000); ``` ### Scenario Definitions **`src/scenarios.ts`** - All scenario definitions ([view on GitHub](https://github.com/citypaul/scenarist/blob/main/apps/express-example/src/scenarios.ts)) Key scenarios: **`default`** - Standard responses for all tests **`github-not-found`** - GitHub user lookup returns 404 ```typescript { id: "github-not-found", name: "GitHub User Not Found", description: "GitHub API returns 404 for user lookup", mocks: [{ method: "GET", url: "https://api.github.com/users/:username", response: { status: 404, body: { message: "Not Found", documentation_url: "https://docs.github.com" } } }] } ``` **`github-polling`** - Polling sequence ```typescript { id: "github-polling", name: "GitHub Job Polling Sequence", description: "Simulates async GitHub job polling with state progression", mocks: [{ method: "GET", url: "https://api.github.com/users/:username", sequence: { responses: [ { status: 200, body: { status: "pending", progress: 0, login: "user1" } }, { status: 200, body: { status: "processing", progress: 50, login: "user2" } }, { status: 200, body: { status: "complete", progress: 100, login: "user3" } } ], repeat: "last" } }] } ``` **`shoppingCart`** - Stateful shopping cart ```typescript { id: "shoppingCart", name: "Shopping Cart (Stateful)", description: "Stateful shopping cart with capture and injection", mocks: [ { method: "GET", url: "http://localhost:3001/cart", response: { status: 200, body: { items: "{{state.cartItems}}", count: "{{state.cartItems.length}}", total: 0 } } }, { method: "PATCH", url: "http://localhost:3001/cart", captureState: { cartItems: "body.items" }, response: { status: 200, body: { items: "{{state.cartItems}}", count: "{{state.cartItems.length}}", message: "Item added to cart" } } } ] } ``` ### Test Examples The tests share a `createTestFixtures()` helper from `tests/test-helpers.ts` that calls `createApp()`, starts MSW, and returns an async `cleanup()` that stops it. **`tests/scenario-switching.test.ts`** - Basic scenario switching ```typescript import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter"; import { describe, it, expect, afterAll } from "vitest"; import request from "supertest"; import { createTestFixtures } from "./test-helpers.js"; const fixtures = createTestFixtures(); describe("Scenario Switching E2E", () => { afterAll(async () => { await fixtures.cleanup(); }); it("should switch to success scenario via POST /__scenario__", async () => { await request(fixtures.app) .post(fixtures.scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "success-test-1") .send({ scenario: "success" }); // Request uses success scenario const response = await request(fixtures.app) .get("/api/github/user/testuser") .set(SCENARIST_TEST_ID_HEADER, "success-test-1"); expect(response.status).toBe(200); }); }); ``` **`tests/test-id-isolation.test.ts`** - Parallel test isolation ```typescript describe("Test ID Isolation E2E", () => { it("should allow different test IDs to use different scenarios concurrently", async () => { // Test ID 1 uses success await request(fixtures.app) .post(fixtures.scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-id-1") .send({ scenario: "success" }); // Test ID 2 uses github-not-found await request(fixtures.app) .post(fixtures.scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-id-2") .send({ scenario: "github-not-found" }); // Requests are isolated const res1 = await request(fixtures.app) .get("/api/github/user/testuser") .set(SCENARIST_TEST_ID_HEADER, "test-id-1"); expect(res1.status).toBe(200); expect(res1.body.login).toBe("testuser"); const res2 = await request(fixtures.app) .get("/api/github/user/testuser") .set(SCENARIST_TEST_ID_HEADER, "test-id-2"); expect(res2.status).toBe(404); }); }); ``` **`tests/dynamic-sequences.test.ts`** - Polling with sequences ```typescript describe("Dynamic Response Sequences E2E (Phase 2)", () => { it("should return responses in sequence order (pending → processing → complete)", async () => { await request(fixtures.app) .post(fixtures.scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "polling-test-1") .send({ scenario: "github-polling" }); // First request: pending const res1 = await request(fixtures.app) .get("/api/github/user/testuser") .set(SCENARIST_TEST_ID_HEADER, "polling-test-1"); expect(res1.body.status).toBe("pending"); // Second request: processing const res2 = await request(fixtures.app) .get("/api/github/user/testuser") .set(SCENARIST_TEST_ID_HEADER, "polling-test-1"); expect(res2.body.status).toBe("processing"); // Third request: complete const res3 = await request(fixtures.app) .get("/api/github/user/testuser") .set(SCENARIST_TEST_ID_HEADER, "polling-test-1"); expect(res3.body.status).toBe("complete"); }); }); ``` **`tests/stateful-scenarios.test.ts`** - State capture and injection ```typescript describe("Stateful Scenarios E2E (Phase 3)", () => { it("should capture items and inject into cart response", async () => { await request(fixtures.app) .post(fixtures.scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "cart-test-1") .send({ scenario: "shoppingCart" }); // Add items - the route PATCHes the cart, state captured await request(fixtures.app) .post("/api/cart/add") .set(SCENARIST_TEST_ID_HEADER, "cart-test-1") .send({ item: "Apple" }); await request(fixtures.app) .post("/api/cart/add") .set(SCENARIST_TEST_ID_HEADER, "cart-test-1") .send({ item: "Banana" }); // Get cart - state injected const response = await request(fixtures.app) .get("/api/cart") .set(SCENARIST_TEST_ID_HEADER, "cart-test-1"); expect(response.body.items).toEqual(["Apple", "Banana"]); }); }); ``` ## Architecture ### How It Works 1. **Middleware Registration** - Scenarist middleware added to Express app 2. **Test ID Extraction** - Middleware extracts `x-scenarist-test-id` header from requests 3. **Scenario Activation** - Test calls `POST /__scenario__` to set active scenario 4. **Request Handling** - Express routes execute normally 5. **External API Interception** - MSW intercepts external API calls 6. **Scenario Response** - Returns response defined in scenario ### Middleware Flow ```plaintext Incoming Request ↓ [Scenarist Middleware] - Extracts x-scenarist-test-id header ↓ [Express Route] - Executes normally ↓ [External API Call] - fetch('https://api.example.com/...') ↓ [MSW Intercepts] - Checks active scenario for test ID ↓ [Scenario Response] - Returns mocked response ↓ [Express Route] - Continues with mocked data ↓ Response to Client ``` ### File Structure ## Common Patterns ### Testing Middleware ```typescript // Middleware that fetches user from external API app.use(async (req, res, next) => { const response = await fetch("https://api.auth.example.com/user"); req.user = await response.json(); next(); }); // Test with different user scenarios describe("Auth Middleware", () => { it("should set premium user", async () => { const testId = "test-premium"; await request(app) .post("/__scenario__") .set("x-scenarist-test-id", testId) .send({ scenario: "premiumUser" }); const response = await request(app) .get("/api/profile") .set("x-scenarist-test-id", testId); expect(response.body.user.tier).toBe("premium"); }); }); ``` ### Testing with Request Matching ```typescript import type { ScenaristScenarios } from "@scenarist/express-adapter"; // Different responses based on request body const scenarios = { checkout: { id: "checkout", name: "Checkout", description: "Charge response depends on the amount", mocks: [ { method: "POST", url: "https://api.payment.example.com/charge", match: { body: { amount: 100 } }, response: { status: 200, body: { success: true } }, }, { method: "POST", url: "https://api.payment.example.com/charge", match: { body: { amount: 10000 } }, response: { status: 400, body: { error: "Amount too large" } }, }, ], }, } as const satisfies ScenaristScenarios; ``` ### Testing Default Fallback ```typescript describe("Default Fallback", () => { it("should use default scenario when none specified", async () => { // No scenario switch - uses default const response = await request(app).get("/api/user"); expect(response.status).toBe(200); expect(response.body.tier).toBe("standard"); // default scenario }); }); ``` ## Next Steps * [Express Getting Started →](/frameworks/express/getting-started/) - Integrate Scenarist into your Express app * [Request Matching →](/scenarios/request-matching/) - Learn about request content matching * [Sequences →](/scenarios/response-sequences/) - Learn about response sequences * [Stateful Mocks →](/scenarios/stateful-mocks/) - Learn about state capture and injection # Express - Getting Started > Set up Scenarist with Express in 5 minutes Test your Express APIs with runtime scenario switching. Zero boilerplate setup using AsyncLocalStorage. ## Installation ```bash npm install @scenarist/express-adapter npm install -D msw @playwright/test @scenarist/playwright-helpers ``` `msw` (v2) is a required peer dependency of `@scenarist/express-adapter`. ## Basic Setup **1. Define your scenarios:** src/scenarios.ts ```typescript import type { ScenaristScenario, ScenaristScenarios, } from "@scenarist/express-adapter"; // ✅ RECOMMENDED - Default scenario with complete happy path const defaultScenario: ScenaristScenario = { id: "default", name: "Happy Path", description: "All external APIs succeed with valid responses", mocks: [ // Stripe: Successful payment { method: "POST", url: "https://api.stripe.com/v1/charges", response: { status: 200, body: { id: "ch_123", status: "succeeded", amount: 5000 }, }, }, // Auth0: Authenticated user { method: "GET", url: "https://api.auth0.com/userinfo", response: { status: 200, body: { sub: "user_123", email: "john@example.com", tier: "standard" }, }, }, // SendGrid: Email sent successfully { method: "POST", url: "https://api.sendgrid.com/v3/mail/send", response: { status: 202, body: { message_id: "msg_123" }, }, }, ], }; // Specialized scenario: Override ONLY Stripe for payment failure const cardDeclinedScenario: ScenaristScenario = { id: "cardDeclined", name: "Card Declined", description: "Stripe declines payment, everything else succeeds", mocks: [ // Override: Stripe declines payment { method: "POST", url: "https://api.stripe.com/v1/charges", response: { status: 402, body: { error: { code: "card_declined", message: "Your card was declined" }, }, }, }, // Auth0 and SendGrid automatically fall back to default (happy path) ], }; export const scenarios = { default: defaultScenario, cardDeclined: cardDeclinedScenario, } as const satisfies ScenaristScenarios; ``` **2. Set up Scenarist in your Express app:** src/app.ts ```typescript import express from "express"; import { createScenarist, type ExpressScenarist, } from "@scenarist/express-adapter"; import { scenarios } from "./scenarios"; // Use async factory pattern for Express apps export const createApp = async () => { const app = express(); app.use(express.json()); // Returns undefined when enabled is false or NODE_ENV is production const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", scenarios, }); // Add Scenarist middleware BEFORE your routes (scenarist is undefined in production builds) if (scenarist) { app.use(scenarist.middleware); } // Your routes run normally - Scenarist just mocks external APIs app.post("/api/checkout", async (req, res) => { const { amount, token } = req.body; // Your validation runs if (amount < 1) { return res.status(400).json({ error: "Invalid amount" }); } // External Stripe call is mocked by Scenarist const charge = await fetch("https://api.stripe.com/v1/charges", { method: "POST", headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` }, body: JSON.stringify({ amount, source: token }), }); const result = await charge.json(); // Your business logic runs if (charge.status === 200) { return res.json({ success: true, chargeId: result.id }); } else { return res.status(402).json({ error: result.error.message }); } }); return { app, scenarist }; }; // src/server.ts - Entry point const main = async () => { const { app, scenarist } = await createApp(); if (scenarist) { scenarist.start(); } app.listen(3000, () => console.log("Server running on :3000")); }; main().catch(console.error); ``` **3. Install testing dependencies:** ```bash npm install -D vitest supertest @types/supertest ``` **4. Write your tests:** tests/checkout.test.ts ```typescript import { describe, it, expect, beforeAll, afterAll } from "vitest"; import request from "supertest"; import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter"; import { createApp } from "../src/app"; // Factory function for test setup - no let variables const createTestSetup = async () => { const { app, scenarist } = await createApp(); return { app, scenarist }; }; describe("Checkout API", () => { const testContext = createTestSetup(); beforeAll(async () => { const { scenarist } = await testContext; scenarist?.start(); // Start MSW }); afterAll(async () => { const { scenarist } = await testContext; await scenarist?.stop(); // Stop MSW }); it("processes payment successfully", async () => { const { app, scenarist } = await testContext; // Switch to default scenario await request(app) .post(scenarist!.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-1") .send({ scenario: "default" }); // Make API request - your Express route runs normally const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-1") .send({ amount: 5000, token: "tok_test" }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.chargeId).toBe("ch_123"); }); it("handles card declined error", async () => { const { app, scenarist } = await testContext; // Switch to cardDeclined scenario await request(app) .post(scenarist!.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-2") .send({ scenario: "cardDeclined" }); const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-2") .send({ amount: 5000, token: "tok_test" }); expect(response.status).toBe(402); expect(response.body.error).toContain("declined"); }); }); ``` ## Test ID Headers **Every test request must include a test ID header** for proper test isolation. This is what enables parallel test execution without interference. ### How Test Isolation Works 1. **Unique test ID per test:** Each test gets a unique identifier (e.g., `'test-1'`, `'test-2'`) 2. **Header on every request:** Send the test ID with every HTTP request 3. **AsyncLocalStorage tracking:** Express adapter uses AsyncLocalStorage to track which test ID is active for the current request 4. **Scenario isolation:** Each test ID has its own active scenario and state ### Required Headers Include the test ID header on **both** scenario switch requests AND actual API requests: ```typescript // Step 1: Switch scenario (with test ID header) await request(app) .post(scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-1") // ← Test ID header .send({ scenario: "cardDeclined" }); // Step 2: Make API request (with SAME test ID header) const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-1") // ← Same test ID .send({ amount: 5000, token: "tok_test" }); ``` **What happens:** 1. First request switches to `'cardDeclined'` scenario for test ID `'test-1'` 2. Second request includes same test ID header 3. AsyncLocalStorage associates the request with `'test-1'` 4. Scenarist uses the `'cardDeclined'` scenario for this request 5. Different test using `'test-2'` would use its own scenario ### Header Name The header name is standardized to `'x-scenarist-test-id'`: ```typescript // These are equivalent: .set('x-scenarist-test-id', 'test-1') .set(SCENARIST_TEST_ID_HEADER, 'test-1') // Recommended ``` **Why use `SCENARIST_TEST_ID_HEADER`?** * Avoids magic strings (clear intent) * Type-safe (TypeScript will catch typos) * Consistent across all Scenarist packages ### Parallel Test Execution Test IDs enable **parallel test execution** without interference: ```typescript describe("Parallel checkout tests", () => { it("test 1: successful payment", async () => { // Uses test ID 'test-1' await request(app) .post(scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-1") .send({ scenario: "default" }); const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-1") .send({ amount: 5000, token: "tok_test" }); expect(response.status).toBe(200); }); it("test 2: card declined", async () => { // Uses test ID 'test-2' - runs simultaneously with test 1 await request(app) .post(scenarist.config.endpoints.setScenario) .set(SCENARIST_TEST_ID_HEADER, "test-2") .send({ scenario: "cardDeclined" }); const response = await request(app) .post("/api/checkout") .set(SCENARIST_TEST_ID_HEADER, "test-2") .send({ amount: 5000, token: "tok_test" }); expect(response.status).toBe(402); }); }); ``` **Both tests run in parallel:** * Test 1 uses scenario `'default'` via test ID `'test-1'` * Test 2 uses scenario `'cardDeclined'` via test ID `'test-2'` * No interference because test IDs isolate scenarios and state ### Missing Headers = Default Scenario If you forget to include the test ID header: ```typescript // ❌ Missing test ID header const response = await request(app) .post("/api/checkout") // .set(SCENARIST_TEST_ID_HEADER, 'test-1') ← MISSING! .send({ amount: 5000, token: "tok_test" }); ``` **What happens:** * Request uses the `'default'` scenario (fallback behavior) * If you switched to a different scenario, it won’t be used * May cause confusing test failures (wrong scenario active) **Always include the header on every request!** ### Internal Fetch Calls If your Express routes make internal fetch calls to other services, you must manually propagate the test ID header: ```typescript app.get("/api/dashboard", async (req, res) => { // Extract test ID from incoming request const testId = req.get(SCENARIST_TEST_ID_HEADER) || "default-test"; // Include test ID in internal fetch const response = await fetch("http://localhost:3001/api/user", { headers: { [SCENARIST_TEST_ID_HEADER]: testId, }, }); const data = await response.json(); res.json(data); }); ``` **Why this is needed:** * AsyncLocalStorage only tracks the current request * Internal fetch calls are new requests (separate context) * Must explicitly pass test ID header to maintain isolation ## What Makes Express Setup Special **Zero Boilerplate** - Scenarist uses `AsyncLocalStorage` to automatically track test IDs. No manual header passing required. **Test Isolation** - Each test gets its own scenario state. Tests run in parallel without interference. **Your Code Runs** - Your Express routes, middleware, validation, and business logic all execute normally. Only external API calls are mocked. ## Production Tree-Shaking Scenarist keeps its endpoints and request interception out of production through two layers: 1. **The `production` export condition** resolves `@scenarist/express-adapter` to a stub that returns `undefined` and imports nothing. No Scenarist or MSW code is loaded or bundled. 2. **A runtime guard**: when `NODE_ENV` is `production`, `createScenarist()` returns `undefined` even if the condition was not applied. No middleware, no `/__scenario__` endpoints, no interception. `createScenarist()` also returns `undefined` when `enabled` is `false`, so always guard with `if (scenarist)`. ### Unbundled Deployments (Most Common) `production` is a custom condition, so Node.js only applies it when you pass it explicitly: ```bash # Recommended: resolves the zero-dependency stub NODE_ENV=production node --conditions=production src/server.js ``` With the flag: * `createScenarist()` returns `undefined` at runtime * The production entry point imports nothing * MSW code **never loads into memory** If you start the server without the flag, the runtime guard still disables Scenarist: ```bash # Scenarist is inert: createScenarist() returns undefined, /__scenario__ returns 404 NODE_ENV=production node src/server.js ``` Without --conditions=production, MSW must be installed Without the condition, Node.js loads the full adapter, which imports `msw`. If you install `msw` as a dev dependency and prune dev dependencies in production, the server fails at startup with `ERR_MODULE_NOT_FOUND`. Use `--conditions=production`, or keep `msw` installed. ### Bundled Deployments (esbuild, webpack, Vite, rollup) **For teams bundling Express server code**, configure the `production` condition. Without it, the runtime guard still disables Scenarist, but the adapter and MSW code stay in the bundle. Scenarist uses **conditional package.json exports** to provide different entry points: ```json { "exports": { ".": { "production": "./dist/setup/production.js", // Zero dependencies "default": "./dist/index.js" // Full implementation } } } ``` The `"production"` condition is **custom** (not a Node.js built-in). Bundlers must be configured to recognize it: **esbuild:** ```json { "scripts": { "build": "esbuild src/server.ts --bundle --conditions=production --define:process.env.NODE_ENV='\"production\"'" } } ``` **webpack:** webpack.config.js ```js module.exports = { mode: "production", resolve: { conditionNames: ["production", "import", "require"], }, }; ``` **Vite:** vite.config.js ```js export default { resolve: { conditions: ["production"], }, }; ``` **rollup:** ```js import resolve from "@rollup/plugin-node-resolve"; export default { plugins: [ resolve({ exportConditions: ["production"], }), ], }; ``` **Results:** * ✅ Bundle size: 618kb → 298kb (52% reduction) * ✅ Zero MSW code in production bundle **Verification:** ```bash # Build with bundler configuration npm run build:production # Verify MSW code eliminated (succeeds only if dist/ exists and has no matches) test -d dist && { grep -rqE '(__scenarist_shared_msw_server|setupWorker|HttpResponse\.json)' dist/ test $? -eq 1 } ``` **For detailed configuration examples**, see the [Express Adapter README - Production Tree-Shaking](https://github.com/citypaul/scenarist/tree/main/packages/express-adapter#production-tree-shaking). ## Debugging with Logging If mocks aren’t matching as expected, enable logging to see what’s happening: ```typescript import { createScenarist, createConsoleLogger } from "@scenarist/express-adapter"; const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", scenarios, logger: createConsoleLogger({ level: "debug", categories: ["matching", "scenario"], }), }); ``` This shows which mocks are being evaluated and why they match or don’t. See [Logging Reference](/reference/logging/) for all options. ## Next Steps * **Debugging:** Learn about [logging options](/reference/logging/) for troubleshooting * **Production safety:** Learn why [Scenarist is safe for production](/concepts/production-safety/) * **Example app:** See [complete Express example](https://github.com/citypaul/scenarist/tree/main/apps/express-example) with comprehensive test suite * **Architecture:** Learn [how Scenarist works](/concepts/architecture/)