Skip to content

Express Example App

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

This example app showcases all major Scenarist features with Express:

  • 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
  • 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
  • Node.js 22+
  • pnpm 11+
Terminal window
# 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
Terminal window
# Start the Express server
pnpm dev

Server runs on http://localhost:3000.

Terminal window
# Run all tests
pnpm test
# Run specific test file
pnpm test scenario-switching
# Run with coverage
pnpm test --coverage

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);

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"
}
}
}
]
}

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"]);
});
});
  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
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
  • 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
// 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");
});
});
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;
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
});
});