This example demonstrates how to use Scenarist with an Express application for powerful scenario-based testing with runtime scenario switching.
- ✅ Runtime Scenario Switching - Switch between different API behaviors without restarting your app
- ✅ Test ID Isolation - Run multiple tests concurrently with different scenarios
- ✅ Default Scenario Fallback - Partial scenarios automatically fall back to default for unmocked endpoints
- ✅ Real API Integration - Your actual Express routes call external APIs (mocked by MSW)
- ✅ Complete Integration Tests - Full integration tests with supertest using scenario-based mocking
# Install dependencies
pnpm install
# Run tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Start the server (for manual testing)
pnpm devScenarist includes a powerful logging system to help you understand what's happening during scenario matching, state management, and request handling.
# Run tests with Scenarist logs visible
pnpm test:logs
# Run dev server with logs
pnpm dev:logsWhen logging is enabled, you'll see detailed output for:
- Scenario events: When scenarios are registered, switched, or cleared
- Mock matching: Which mocks were evaluated, their specificity scores, and which one was selected
- State management: State capture and injection for stateful mocks
- Sequences: Position tracking for response sequences
Example output:
09:49:09.713 INF [test-user-login] 🎬 scenario scenario_switched scenarioId="success"
09:49:09.715 DBG [test-user-login] 🎯 matching mock_candidates_found candidateCount=5 url="/api/users" method="GET"
09:49:09.716 INF [test-user-login] 🎯 matching mock_selected mockIndex=1 specificity=5
- Logging Reference - Full logging configuration options
- Log Levels & Categories - Understanding log levels and filtering
This example includes a Bruno API collection for manual testing and exploration. Bruno is an open-source, Git-friendly alternative to Postman that stores collections as files in your repository.
Download Bruno for your platform:
-
macOS: Download from usebruno.com or install via Homebrew:
brew install bruno
-
Windows: Download the installer from usebruno.com
-
Linux: Download the AppImage or use package managers:
# Snap snap install bruno # Flatpak flatpak install flathub com.usebruno.Bruno
-
Start the Express server:
pnpm dev
-
Open Bruno application
-
Click "Open Collection"
-
Navigate to this directory and select the
brunofolder:apps/express-example/bruno -
The collection will load with all available requests organized in folders
The Bruno collection includes:
Scenarios Folder - Control which scenario is active:
- Get Active Scenario
- Set Scenario - Default
- Set Scenario - Success
- Set Scenario - GitHub Not Found
- Set Scenario - Weather Error
- Set Scenario - Stripe Failure
- Set Scenario - Slow Network
- Set Scenario - Mixed Results
API Folder - Test the actual application endpoints:
- GitHub - Get User
- Weather - Get Current
- Payment - Create Charge
Health Check - Verify server is running
- Set a scenario: Run "Set Scenario - Success" to activate the success scenario
- Test endpoints: Run "GitHub - Get User" to see how the API behaves with this scenario
- Switch scenarios: Run "Set Scenario - GitHub Not Found"
- Test again: Run "GitHub - Get User" again - now it returns 404
- Check active: Run "Get Active Scenario" to confirm which scenario is active
The collection uses these environment variables (set in environments/Local.bru):
baseUrl:http://localhost:3000- Server URLtestId:bruno-test- Test ID used in x-scenarist-test-id header
You can create additional environments for different setups (staging, production, etc.).
- All requests include the
x-scenarist-test-idheader automatically using{{testId}} - Scenarios persist across requests, so you only need to set them once
- Use different test IDs to test multiple scenarios simultaneously
- Check the "Docs" tab in each request for detailed information
src/
├── server.ts # Express app with Scenarist setup
├── scenarios.ts # Scenario definitions
└── routes/
├── github.ts # Routes calling GitHub API
├── weather.ts # Routes calling Weather API
└── stripe.ts # Routes calling Stripe API
tests/
├── scenario-switching.test.ts # Tests scenario switching
├── test-id-isolation.test.ts # Tests test ID isolation
├── default-fallback.test.ts # Tests default scenario fallback
└── scenario-persistence.test.ts # Tests scenarios persist across multiple requests
npm install @scenarist/express-adapter
# or
pnpm add @scenarist/express-adapter
# or
yarn add @scenarist/express-adapterNote: You only need to install the Express adapter package. It re-exports all necessary types including ScenaristScenario, ScenaristMock, etc.
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());
const scenarist = createScenarist({
enabled: true,
scenarios,
strictMode: false, // Allow passthrough for unmocked requests
});
// Apply middleware only if scenarist is defined
if (scenarist) {
app.use(scenarist.middleware);
}
// Your routes
app.get("/api/github/user/:username", async (req, res) => {
const { username } = req.params;
const response = await fetch(`https://api.github.com/users/${username}`);
const data = await response.json();
res.status(response.status).json(data);
});
return { app, scenarist };
};Import types from the Express adapter (not from core):
import type { ScenaristScenario } from "@scenarist/express-adapter";
export const successScenario: ScenaristScenario = {
id: "success",
name: "Success Scenario",
description: "All external API calls succeed",
mocks: [
{
method: "GET",
url: "https://api.github.com/users/:username",
response: {
status: 200,
body: {
login: "testuser",
id: 123,
name: "Test User",
// ... more fields
},
},
},
// More mocks...
],
};Scenarios are serializable data (no functions!) that define how external APIs should respond.
Your routes call real external APIs - Scenarist intercepts them:
router.get("/api/github/user/:username", async (req, res) => {
const { username } = req.params;
// This fetch is intercepted by MSW
const response = await fetch(`https://api.github.com/users/${username}`);
const data = await response.json();
res.status(response.status).json(data);
});Tests can switch scenarios dynamically and are isolated by test ID:
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
// Factory function for test setup - no let variables
const createTestSetup = () => {
const { app, scenarist } = createApp();
return { app, scenarist };
};
describe("GitHub API Integration", () => {
const { app, scenarist } = createTestSetup();
beforeAll(() => {
scenarist?.start();
});
afterAll(async () => {
await scenarist?.stop();
});
it("should return user data when using success scenario", async () => {
// Switch to success scenario for this test
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", "test-1")
.send({ scenario: "success" });
// Make request
const response = await request(app)
.get("/api/github/user/testuser")
.set("x-scenarist-test-id", "test-1");
expect(response.status).toBe(200);
expect(response.body.login).toBe("testuser");
});
it("should return 404 when using error scenario", async () => {
// Switch to error scenario for this test
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", "test-2")
.send({ scenario: "github-not-found" });
// Make request
const response = await request(app)
.get("/api/github/user/nonexistent")
.set("x-scenarist-test-id", "test-2");
expect(response.status).toBe(404);
});
});Once a scenario is set for a test ID, it persists across all subsequent requests with that test ID. This simulates real user journeys across multiple pages:
const testId = "user-journey";
// Set scenario once
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", testId)
.send({ scenario: "success" });
// Page 1: User profile
await request(app)
.get("/api/github/user/john")
.set("x-scenarist-test-id", testId);
// => Uses success scenario
// Page 2: Weather dashboard
await request(app)
.get("/api/weather/london")
.set("x-scenarist-test-id", testId);
// => Still uses success scenario
// Page 3: Payment
await request(app)
.post("/api/payment")
.set("x-scenarist-test-id", testId)
.send({ amount: 1000 });
// => Still uses success scenarioThe scenario remains active until explicitly changed or cleared. This enables:
- Realistic user journey testing - Multiple page navigations with consistent backend state
- Complex flow testing - Multi-step processes (browse → add to cart → checkout → confirm)
- Consistent behavior - Same scenario across all requests in a test
# Switch scenario for a specific test ID
POST /__scenario__
Headers: x-scenarist-test-id: my-test
Body: { "scenario": "success" }
# Get current scenario
GET /__scenario__
Headers: x-scenarist-test-id: my-testEach test ID has its own scenario state. Tests run in parallel don't affect each other:
// Test 1 uses success scenario
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", "test-1")
.send({ scenario: "success" });
// Test 2 uses error scenario - completely independent!
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", "test-2")
.send({ scenario: "error" });If a scenario doesn't define a mock for a specific endpoint, it automatically falls back to the default scenario:
// weather-error scenario only defines weather API mocks
await request(app)
.post("/__scenario__")
.set("x-scenarist-test-id", "partial-test")
.send({ scenario: "weather-error" });
// Weather API uses weather-error scenario (returns 500)
await request(app)
.get("/api/weather/tokyo")
.set("x-scenarist-test-id", "partial-test");
// => 500 error
// GitHub API falls back to default scenario (returns success)
await request(app)
.get("/api/github/user/testuser")
.set("x-scenarist-test-id", "partial-test");
// => 200 success with default dataThis example includes several scenarios:
- default - Basic successful responses for all APIs
- success - All APIs return successful responses
- github-not-found - GitHub API returns 404
- weather-error - Weather API returns 500
- stripe-failure - Stripe payment fails (402)
- slow-network - All APIs respond with 1-2 second delays
- mixed-results - Some APIs succeed, others fail
# Run all tests
pnpm test
# Run specific test file
pnpm test scenario-switching.test.ts
# Run tests in watch mode
pnpm test:watch
# Run tests with coverage
pnpm test --coverageAll tests pass, demonstrating:
- ✅ 10 scenario switching tests
- ✅ 4 test ID isolation tests
- ✅ 6 default fallback tests
- ✅ 7 scenario persistence tests (multi-request scenarios)
- ✅ 27 total tests passing
- Add more scenarios for different edge cases
- Add more external API integrations
- Try concurrent test execution:
pnpm test --reporter=verbose - Explore variant support for parameterized scenarios
- Scenarist Documentation - Full documentation site
- Express Getting Started - Step-by-step setup guide
- Logging Reference - Debug your scenarios with logging
- Scenario Patterns - Learn about matching, sequences, and stateful mocks
- Express Adapter Package - Package-level documentation