Skip to content

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 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.

Endpoints are active:

  • POST /__scenario__ accepts scenario switch requests
  • GET /__scenario__ returns active scenario
  • Both endpoints process requests normally

Middleware extracts test IDs:

  • Reads x-scenarist-test-id header 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

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 Allowed

No 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.

Scenarist provides multiple layers of production safety:

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.

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.

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__",
},
});

The middleware only exists when createScenarist() returns an instance:

// Express example
if (scenarist) {
app.use(scenarist.middleware);
}
// When enabled is false, or in production, scenarist is undefined
// - Middleware never installed
// - No header extraction
// - No performance impact

MSW handlers are only registered when you call scenarist.start(), so guard the call:

// Next.js example
if (scenarist) {
scenarist.start(); // Registers MSW handlers
}
// When enabled is false, or in production: handlers never registered

Each test gets a unique test ID that routes requests to the correct scenario:

// Test 1
test("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.

  1. Test starts: No test ID or scenario set
  2. switchScenario() called: Generates unique test ID (UUID)
  3. Scenario activated: Test ID → Scenario mapping stored
  4. Request made: Test ID header included automatically
  5. Scenario selected: Based on test ID from header
  6. Response returned: From active scenario for that test ID
  7. Test ends: The test ID is never reused; its scenario mapping stays in memory until the server restarts

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.

Playwright helpers automatically include the test ID header:

// tests/fixtures.ts - Set up once
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "./scenarios";
export const test = withScenarios(scenarios);
export { expect };
// tests/my-test.spec.ts - Use in tests
import { 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" },
});
});

When no test ID header is present, Scenarist uses a default test ID:

// Request without x-scenarist-test-id header
GET / api / user;
// No x-scenarist-test-id header
// Scenarist uses default test ID: 'default-test'
// Routes to 'default' scenario

Use 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 handlers
beforeAll(() => {
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 scenarios
test("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.

Ephemeral endpoints enable runtime scenario switching without restarting your application:

Terminal window
# Test success case
NODE_ENV=test SCENARIO=success npm start
# Run test
# Kill server
# Test error case
NODE_ENV=test SCENARIO=error npm start
# Run test
# Kill server
# Test edge case
NODE_ENV=test SCENARIO=edge npm start
# Run test
# Kill server

Problem: Each scenario requires full server restart. Slow and painful.

// Start server once
// NODE_ENV=test npm start
// Switch scenarios at runtime
test("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.

Ephemeral endpoints also enable manual testing with different scenarios:

Terminal window
# Start your app in test mode
NODE_ENV=test npm start
# Switch to error scenario via curl
curl -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 scenario
curl -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 scenario

Use cases:

  • Demo different scenarios to stakeholders
  • Debug edge cases locally
  • Validate UI behavior across scenarios
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 definition

Scenarios are stored in-memory (current implementation):

// Simplified internal structure
const 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-abc123
const 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 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-abc123
stateStore.set("test-abc123", { cartItems: ["item-1", "item-2"] });
sequenceStore.set("test-abc123:scenario:mockIndex", { position: 2 });
// Test ID: test-def456
stateStore.set("test-def456", { cartItems: ["item-3"] });
sequenceStore.set("test-def456:scenario:mockIndex", { position: 0 });
// Tests have independent state and sequence positions

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