Express Example App
Overview
Section titled “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
What It Demonstrates
Section titled “What It Demonstrates”This example app showcases all major Scenarist features with Express:
Core Features
Section titled “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
Section titled “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
Section titled “Installation”Prerequisites
Section titled “Prerequisites”- Node.js 22+
- pnpm 11+
Clone and Install
Section titled “Clone and Install”# Clone the repositorygit clone https://github.com/citypaul/scenarist.gitcd scenarist
# Install dependenciespnpm install
# Navigate to Express examplecd apps/express-exampleRunning the Example
Section titled “Running the Example”Development Mode
Section titled “Development Mode”# Start the Express serverpnpm devServer runs on http://localhost:3000.
Run Tests
Section titled “Run Tests”# Run all testspnpm test
# Run specific test filepnpm test scenario-switching
# Run with coveragepnpm test --coverageKey Files
Section titled “Key Files”Scenarist Setup
Section titled “Scenarist Setup”src/app.ts - Express app with Scenarist integration (simplified; view on GitHub)
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
import { createApp } from "./app.js";
const { app, scenarist } = createApp();scenarist?.start();app.listen(process.env.PORT || 3000);Scenario Definitions
Section titled “Scenario Definitions”src/scenarios.ts - All scenario definitions (view on GitHub)
Key scenarios:
default - Standard responses for all tests
github-not-found - GitHub user lookup returns 404
{ 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
{ 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
{ 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
Section titled “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
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
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
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
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
Section titled “Architecture”How It Works
Section titled “How It Works”- Middleware Registration - Scenarist middleware added to Express app
- Test ID Extraction - Middleware extracts
x-scenarist-test-idheader from requests - Scenario Activation - Test calls
POST /__scenario__to set active scenario - Request Handling - Express routes execute normally
- External API Interception - MSW intercepts external API calls
- Scenario Response - Returns response defined in scenario
Middleware Flow
Section titled “Middleware Flow”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 ClientFile Structure
Section titled “File Structure”Directoryapps/express-example/
Directorysrc/
- app.ts Express app with Scenarist
- server.ts Entry point
- scenarios.ts Scenario definitions
Directoryroutes/ Express routes
- …
Directorytests/
- scenario-switching.test.ts
- test-id-isolation.test.ts
- dynamic-sequences.test.ts
- dynamic-matching.test.ts
- stateful-scenarios.test.ts
- test-helpers.ts
- package.json
Common Patterns
Section titled “Common Patterns”Testing Middleware
Section titled “Testing Middleware”// Middleware that fetches user from external APIapp.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 scenariosdescribe("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
Section titled “Testing with Request Matching”import type { ScenaristScenarios } from "@scenarist/express-adapter";
// Different responses based on request bodyconst 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
Section titled “Testing Default Fallback”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
Section titled “Next Steps”- Express Getting Started → - Integrate Scenarist into your Express app
- Request Matching → - Learn about request content matching
- Sequences → - Learn about response sequences
- Stateful Mocks → - Learn about state capture and injection