Ephemeral Endpoints
Scenarist creates ephemeral endpoints that only exist when Scenarist is enabled in development and test. When enabled is false, and in production, they are never registered, so they add 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. When it is false, createScenarist() returns undefined in every adapter.
import { createScenarist } from "@scenarist/express-adapter";
const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", // Only in test environment scenarios,});Production has its own guards that apply regardless of enabled: the adapter’s production export condition makes createScenarist() return undefined without loading any test code, and the Express adapter also returns undefined when NODE_ENV is production. See Production Safety.
When enabled: true (Development and Test)
Section titled “When enabled: true (Development and Test)”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, and in Production
Section titled “When enabled: false, and in Production”createScenarist() returns undefined.
Endpoints are not registered. Requests to them get whatever your app returns for an unknown route:
// Express: POST /__scenario__ → 404 Not Found (no middleware installed)// Next.js: undefined handlers (App Router) or your fallback handler (Pages Router) → 405 Method Not AllowedNo middleware is installed (createScenarist() returned undefined, so there is no scenarist.middleware):
- 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. Production Entry Point
Section titled “1. Production Entry Point”The production export condition swaps in a stub whose createScenarist() returns undefined and imports nothing. Make sure your production build resolves it; the Production Safety guide shows how to verify this for each framework.
The Express adapter also returns undefined when NODE_ENV is production, even with enabled: true. This protects unbundled Express apps started without --conditions=production; the adapter and msw are still loaded in that case, but nothing is mounted or intercepted.
2. Configuration Check
Section titled “2. Configuration Check”const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", // Express: false in production; in Next.js use enabled: true scenarios,});Best practice: Derive enabled from the environment so it is never true in production.
3. Endpoint Availability
Section titled “3. Endpoint Availability”As an extra precaution, the endpoints can be moved to non-standard paths:
const scenarist = createScenarist({ enabled: true, scenarios, endpoints: { setScenario: "/__internal_test_scenario_switch__", // Obscure path getScenario: "/__internal_test_scenario_status__", },});4. Middleware Safety
Section titled “4. Middleware Safety”The middleware only exists when createScenarist() returns an instance:
// Express exampleif (scenarist) { app.use(scenarist.middleware);}
// When enabled is false, or in production, scenarist is undefined// - Middleware never installed// - No header extraction// - No performance impact5. MSW Registration
Section titled “5. MSW Registration”MSW handlers are only registered when you call scenarist.start(), so guard the call:
// Next.js exampleif (scenarist) { scenarist.start(); // Registers MSW handlers}// When enabled is false, or 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: The test ID is never reused; its scenario mapping stays in memory until the server restarts
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.
Change the fallback with defaultTestId. To treat a missing header as an error instead, set defaultTestId: '' and choose an onMissingTestId behavior:
const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true scenarios, defaultTestId: "", // No fallback errorBehaviors: { onMissingTestId: "throw" }, // Respond 500 with MISSING_TEST_ID});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 ({ page, switchScenario }) => { await switchScenario(page, "admin-user"); // Test ID: test-1 → Scenario: admin-user});
test("test 2: guest view", async ({ page, 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 ({ page, switchScenario }) => { await switchScenario(page, "success"); // Instant scenario switch, no restart});
test("error case", async ({ page, switchScenario }) => { await switchScenario(page, "error"); // Instant scenario switch, no restart});
test("edge case", async ({ page, 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({ // createScenarist() returns undefined when false enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true
// 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