Ephemeral Endpoints
Scenarist creates ephemeral endpoints that only exist when testing is enabled. In production, these endpoints return 404 and have zero overhead. This ensures scenario switching infrastructure never leaks into production.
The enabled Flag
Section titled “The enabled Flag”The enabled flag controls whether Scenarist’s testing infrastructure is active:
import { createScenarist } from "@scenarist/express-adapter";
const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", // Only in test environment scenarios,});When enabled: true (Test Mode)
Section titled “When enabled: true (Test Mode)”Endpoints are active:
POST /__scenario__accepts scenario switch requestsGET /__scenario__returns active scenario- Both endpoints process requests normally
Middleware extracts test IDs:
- Reads
x-scenarist-test-idheader from requests - Routes requests to correct scenario
- Maintains test isolation
MSW is registered:
- Handlers created from scenario definitions
- External API calls intercepted
- Responses returned based on active scenario
When enabled: false (Production Mode)
Section titled “When enabled: false (Production Mode)”Endpoints return 404:
// Request: POST /__scenario__// Response: 404 Not FoundMiddleware no-ops:
- Extracts no headers
- Adds no overhead
- Passes requests through unchanged
MSW is not registered:
- No handlers created
- No interception occurs
- External APIs called normally
Result: Zero production overhead. The testing infrastructure simply doesn’t exist.
Production Safety Guarantees
Section titled “Production Safety Guarantees”Scenarist provides multiple layers of production safety:
1. Configuration Check
Section titled “1. Configuration Check”const scenarist = createScenarist({ enabled: process.env.NODE_ENV !== "production", // Explicit check scenarios,});Best practice: Use environment variables to ensure enabled is never true in production.
2. Endpoint Availability
Section titled “2. Endpoint Availability”Even if enabled accidentally left as true, endpoints can be configured to non-standard paths:
const scenarist = createScenarist({ enabled: true, scenarios, endpoints: { setScenario: "/__internal_test_scenario_switch__", // Obscure path getScenario: "/__internal_test_scenario_status__", },});3. Middleware Safety
Section titled “3. Middleware Safety”The middleware only activates when enabled: true:
// Express exampleapp.use(scenarist.middleware());
// When enabled: false// - Middleware installed but inactive// - No header extraction// - No performance impact4. MSW Registration
Section titled “4. MSW Registration”MSW handlers only registered when enabled: true:
// Next.js exampleif (scenarist.config.enabled) { scenarist.start(); // Registers MSW handlers}// In production: handlers never registeredTest ID Isolation
Section titled “Test ID Isolation”Each test gets a unique test ID that routes requests to the correct scenario:
How Test IDs Work
Section titled “How Test IDs Work”// Test 1test("handles success", async ({ page, switchScenario }) => { const testId = await switchScenario(page, "success"); // testId = 'test-abc123' (auto-generated UUID)
await page.goto("/api/payment"); // Request includes header: x-scenarist-test-id: test-abc123 // Scenarist routes to 'success' scenario for this test ID});
// Test 2 (runs in parallel)test("handles error", async ({ page, switchScenario }) => { const testId = await switchScenario(page, "error"); // testId = 'test-def456' (different UUID)
await page.goto("/api/payment"); // Request includes header: x-scenarist-test-id: test-def456 // Scenarist routes to 'error' scenario for this test ID});Tests run in parallel without interference because each has its own test ID and scenario.
Test ID Lifecycle
Section titled “Test ID Lifecycle”- Test starts: No test ID or scenario set
switchScenario()called: Generates unique test ID (UUID)- Scenario activated: Test ID → Scenario mapping stored
- Request made: Test ID header included automatically
- Scenario selected: Based on test ID from header
- Response returned: From active scenario for that test ID
- Test ends: Test ID and scenario cleaned up
Test ID Header
Section titled “Test ID Header”The test ID header is standardized to 'x-scenarist-test-id'. Use the SCENARIST_TEST_ID_HEADER constant from your adapter package:
import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";// SCENARIST_TEST_ID_HEADER === 'x-scenarist-test-id'All requests must include this header for scenario routing to work.
Automatic Test ID Propagation
Section titled “Automatic Test ID Propagation”Playwright helpers automatically include the test ID header:
// tests/fixtures.ts - Set up onceimport { withScenarios, expect } from "@scenarist/playwright-helpers";import { scenarios } from "./scenarios";
export const test = withScenarios(scenarios);export { expect };
// tests/my-test.spec.ts - Use in testsimport { test, expect } from "./fixtures"; // ✅ Import from fixtures
test("my test", async ({ page, switchScenario }) => { await switchScenario(page, "success"); // ✅ Type-safe scenario IDs // Helper automatically: // 1. Generates unique test ID // 2. Calls POST /__scenario__ with test ID header // 3. Sets page.setExtraHTTPHeaders({ 'x-scenarist-test-id': testId })
await page.goto("/profile"); // All navigation requests include x-scenarist-test-id header
await page.request.post("/api/data", { data: { foo: "bar" } }); // API requests need explicit test ID (Playwright limitation): const testId = await switchScenario(page, "success"); await page.request.post("/api/data", { headers: { "x-scenarist-test-id": testId }, // Manual header for page.request data: { foo: "bar" }, });});Default Test ID
Section titled “Default Test ID”When no test ID header is present, Scenarist uses a default test ID:
// Request without x-scenarist-test-id headerGET / api / user;// No x-scenarist-test-id header
// Scenarist uses default test ID: 'default-test'// Routes to 'default' scenarioUse case: Manual testing during development. Open browser, navigate to app, no test ID needed.
How Ephemeral Endpoints Enable Parallel Tests
Section titled “How Ephemeral Endpoints Enable Parallel Tests”Traditional E2E testing forces sequential execution because of shared global state:
// ❌ Traditional approach - shared MSW handlersbeforeAll(() => { server.use(http.get("/api/user", () => HttpResponse.json({ role: "admin" })));});
test("test 1: admin view", () => { // Uses global handler: admin});
test("test 2: guest view", () => { // PROBLEM: Still sees admin handler! // Must run sequentially or reset handlers between tests});Scenarist enables parallel execution via test ID isolation:
// ✅ Scenarist approach - isolated scenariostest("test 1: admin view", async ({ switchScenario }) => { await switchScenario(page, "admin-user"); // Test ID: test-1 → Scenario: admin-user});
test("test 2: guest view", async ({ switchScenario }) => { await switchScenario(page, "guest-user"); // Test ID: test-2 → Scenario: guest-user // Runs in parallel with test 1, no interference});10x faster test suites because tests run in parallel instead of sequentially.
Runtime Scenario Switching
Section titled “Runtime Scenario Switching”Ephemeral endpoints enable runtime scenario switching without restarting your application:
Traditional Approach (Slow)
Section titled “Traditional Approach (Slow)”# Test success caseNODE_ENV=test SCENARIO=success npm start# Run test# Kill server
# Test error caseNODE_ENV=test SCENARIO=error npm start# Run test# Kill server
# Test edge caseNODE_ENV=test SCENARIO=edge npm start# Run test# Kill serverProblem: Each scenario requires full server restart. Slow and painful.
Scenarist Approach (Fast)
Section titled “Scenarist Approach (Fast)”// Start server once// NODE_ENV=test npm start
// Switch scenarios at runtimetest("success case", async ({ switchScenario }) => { await switchScenario(page, "success"); // Instant scenario switch, no restart});
test("error case", async ({ switchScenario }) => { await switchScenario(page, "error"); // Instant scenario switch, no restart});
test("edge case", async ({ switchScenario }) => { await switchScenario(page, "edge"); // Instant scenario switch, no restart});Benefit: Server runs continuously. Tests switch scenarios instantly via HTTP requests to /__scenario__ endpoint.
Manual Testing with Scenarios
Section titled “Manual Testing with Scenarios”Ephemeral endpoints also enable manual testing with different scenarios:
# Start your app in test modeNODE_ENV=test npm start
# Switch to error scenario via curlcurl -X POST http://localhost:3000/__scenario__ \ -H "Content-Type: application/json" \ -H "x-scenarist-test-id: manual-testing" \ -d '{"scenario": "payment-error"}'
# Open browser and manually test error scenario# All requests with x-scenarist-test-id: manual-testing see error scenario
# Switch to success scenariocurl -X POST http://localhost:3000/__scenario__ \ -H "Content-Type: application/json" \ -H "x-scenarist-test-id: manual-testing" \ -d '{"scenario": "payment-success"}'
# Manually test success scenarioUse cases:
- Demo different scenarios to stakeholders
- Debug edge cases locally
- Validate UI behavior across scenarios
Architecture: How It Works
Section titled “Architecture: How It Works”Request Flow
Section titled “Request Flow”1. Test calls switchScenario('payment-error') ↓2. Helper generates test ID: 'test-abc123' ↓3. Helper calls: POST /__scenario__ Headers: { x-scenarist-test-id: 'test-abc123' } Body: { scenario: 'payment-error' } ↓4. Endpoint stores: test-abc123 → payment-error ↓5. Test makes request: GET /api/payment Headers: { x-scenarist-test-id: 'test-abc123' } ↓6. Middleware extracts test ID from header ↓7. ScenarioManager looks up active scenario for test ID ↓8. MSW handler finds matching mock in scenario ↓9. Response returned from mock definitionScenario Storage
Section titled “Scenario Storage”Scenarios are stored in-memory (current implementation):
// Simplified internal structureconst scenarioStore = new Map<string, ActiveScenario>();
// After switchScenario('test-abc123', 'payment-error')scenarioStore.set("test-abc123", { scenarioId: "payment-error",});
// On request with x-scenarist-test-id: test-abc123const activeScenario = scenarioStore.get("test-abc123");// { scenarioId: 'payment-error' }Why in-memory? This is the right choice for scenario-based testing - scenarios only need to persist for the duration of a test run. The test ID isolation ensures parallel tests don’t interfere with each other.
State and Sequence Isolation
Section titled “State and Sequence Isolation”State capture and sequence positions are also isolated by test ID:
// Internal structure (simplified)const stateStore = new Map<string, Record<string, unknown>>();const sequenceStore = new Map<string, SequencePosition>();
// Test ID: test-abc123stateStore.set("test-abc123", { cartItems: ["item-1", "item-2"] });sequenceStore.set("test-abc123:scenario:mockIndex", { position: 2 });
// Test ID: test-def456stateStore.set("test-def456", { cartItems: ["item-3"] });sequenceStore.set("test-def456:scenario:mockIndex", { position: 0 });
// Tests have independent state and sequence positionsConfiguration Reference
Section titled “Configuration Reference”Complete configuration options related to ephemeral endpoints:
const scenarist = createScenarist({ // Enable/disable testing infrastructure enabled: process.env.NODE_ENV === "test",
// Scenario definitions scenarios: { default: defaultScenario, // ... other scenarios },
// Endpoint paths (configurable) endpoints: { setScenario: "/__scenario__", // POST: switch scenario getScenario: "/__scenario__", // GET: get active scenario }, // Note: Test ID header is standardized to 'x-scenarist-test-id' // Use SCENARIST_TEST_ID_HEADER constant from your adapter package});Next Steps
Section titled “Next Steps”- Writing Scenarios → - Learn the complete scenario structure
- Default Scenarios → - Understand override behavior
- Endpoint APIs → - Reference for GET/POST /scenario