Skip to content

How it works

Scenarist fills the testing gap by enabling HTTP-level integration testing with runtime scenario switching:

  • Tests make real HTTP requests to your backend
  • Your backend code executes normally (middleware, routing, business logic)
  • External API calls are intercepted and return scenario-defined responses
  • Different scenarios run in parallel against the same server instance
  • Each test is isolated via unique test identifiers

① HTTP Request

② External API calls

③ Mocked responses

④ HTTP Response

🧪 Test 2: Payment error

switchScenario('paymentFails')

🟢 Your Real Backend

(Middleware + Routes + Business Logic)

📦 Scenario: paymentFails

💳 Stripe: {status: 'declined'}

🔐 Auth0: {user: 'john@example.com'}

📧 SendGrid: {status: 'sent'}

① HTTP Request

② External API calls

③ Mocked responses

④ HTTP Response

🧪 Test 1: Happy path

switchScenario('allSucceed')

🟢 Your Real Backend

(Middleware + Routes + Business Logic)

📦 Scenario: allSucceed

💳 Stripe: {status: 'succeeded'}

🔐 Auth0: {user: 'john@example.com'}

📧 SendGrid: {status: 'sent'}

The key insight: Each scenario is a complete set of API mocks that defines how every external API behaves for that test. The diagram shows 2 scenarios as examples—you can define as many as you need, and each scenario can mock as many APIs as your application uses.

Understanding the pattern:

Each test switches to a specific scenario, and that scenario controls all external API responses for the duration of that test:

  • Test 1 switches to allSucceed → Stripe succeeds, Auth0 authenticates, SendGrid sends
  • Test 2 switches to paymentFails → Stripe declines, Auth0 authenticates, SendGrid sends

Notice how each scenario defines the complete behavior: in paymentFails, only Stripe fails—Auth0 and SendGrid still succeed. This lets you test exactly the edge case you care about.

Default scenario pattern (recommended):

Define a default scenario with your happy path responses for all external APIs. Then create specialized scenarios that override only what changes:

import type { ScenaristScenarios } from "@scenarist/nextjs-adapter/app";
const scenarios = {
default: {
// Happy path - all APIs succeed
mocks: [
{ url: "https://api.stripe.com/...", response: { status: "succeeded" } },
{
url: "https://api.auth0.com/...",
response: { user: "john@example.com" },
},
{ url: "https://api.sendgrid.com/...", response: { status: "sent" } },
],
},
paymentFails: {
// Only override Stripe - Auth0 and SendGrid automatically fall back to default
mocks: [
{ url: "https://api.stripe.com/...", response: { status: "declined" } },
],
},
} as const satisfies ScenaristScenarios;

When you switch to paymentFails, Scenarist uses that scenario’s mocks (Stripe declines) and automatically falls back to the default scenario for any APIs not defined (Auth0 and SendGrid succeed). This eliminates duplication—you only define what changes.

What this enables:

  • Unlimited scenarios - Premium users, free users, error states, edge cases—as many as you need
  • Unlimited APIs per scenario - Mock Stripe, Auth0, SendGrid, GitHub, Twilio—as many as your app uses
  • Default fallback - Define happy path once, override only what changes in each scenario
  • Test edge cases exhaustively - Can’t make real Stripe decline with a specific error code, but your scenario can
  • Fast parallel testing - All scenarios run simultaneously against the same server

When testing with Scenarist, your backend executes as it would in production:

HTTP Request

Middleware Chain

Route Handler

Business Logic

Database

External API

MSW Intercepts

Scenario Response

Green boxes: Your code executes with production behavior Yellow boxes: External API calls are intercepted and handled by scenario definitions

This example demonstrates HTTP-level testing with Next.js. Each framework has its own adapter that integrates Scenarist into your application.

Step 1: Framework-specific setup (done once per application)

// app/api/[[...route]]/route.ts - Next.js App Router
import { createScenarist } from "@scenarist/nextjs-adapter";
import { scenarios } from "./scenarios";
export const { GET, POST } = createScenarist({
enabled: process.env.NODE_ENV === "test",
scenarios,
});

Step 2: Define scenarios (reusable across tests)

scenarios.ts
import type { ScenaristScenarios } from "@scenarist/nextjs-adapter/app";
export const scenarios = {
premiumUser: {
id: "premiumUser",
name: "Premium User",
mocks: [
{
method: "GET",
url: "https://api.auth-provider.com/session",
response: {
status: 200,
body: { tier: "premium", userId: "user-123" },
},
},
],
},
} as const satisfies ScenaristScenarios;

Step 3: Set up Playwright fixtures (one-time setup)

tests/fixtures.ts
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "./scenarios"; // Import your scenarios
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };

Step 4: Write tests (import from fixtures, not @playwright/test)

tests/premium-features.spec.ts
import { test, expect } from "./fixtures"; // ✅ Import from fixtures, NOT @playwright/test
test("premium users access advanced features", async ({
page,
switchScenario,
}) => {
await switchScenario(page, "premiumUser"); // ✅ Type-safe! Autocomplete works
// Real HTTP request → Next.js route → middleware → business logic
await page.goto("/dashboard");
// External auth API call intercepted, returns mocked premium tier
// Your business logic processes the tier correctly
await expect(page.getByText("Advanced Analytics")).toBeVisible();
});

What’s happening:

  1. Framework adapter integrates Scenarist into your Next.js app
  2. Scenarios define how external APIs behave
  3. Playwright fixtures create type-safe test helpers with scenario autocomplete
  4. Tests import from fixtures (not @playwright/test directly)
  5. Test switches to scenario and makes real HTTP requests
  6. Your backend code executes with production behavior
  7. External API calls return scenario-defined responses

See complete working examples:

Framework-specific guides:

Scenarist creates special /__scenario__ endpoints that only exist when testing is enabled. These ephemeral endpoints enable runtime scenario switching while maintaining production safety.

What are ephemeral endpoints?

  • POST /__scenario__ - Switch the active scenario for a test
  • GET /__scenario__ - Check which scenario is currently active

Why “ephemeral”?

The endpoints only exist when you set enabled: true in your Scenarist configuration:

const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Only active in test environment
scenarios,
});

When enabled: true (test mode):

  • Endpoints accept requests and switch scenarios
  • MSW intercepts external API calls
  • Test ID headers route requests to correct scenarios

When enabled: false (production):

  • Endpoints return 404 (do not exist)
  • Zero overhead - no middleware, no MSW, no scenario infrastructure
  • Your app runs exactly as it would without Scenarist

This ensures scenario switching infrastructure never leaks into production, even if you accidentally deploy with enabled: true.

Learn more about ephemeral endpoints →

Traditional end-to-end tests cannot switch external API behavior at runtime. Testing different scenarios (premium vs free users, error states) typically requires separate deployments, complex data setup, or conditional logic in application code.

Scenarist addresses this through runtime scenario switching using test identifiers:

// Define multiple scenarios
const scenarios = {
premium: {
/* premium tier mocks */
},
free: {
/* free tier mocks */
},
error: {
/* error state mocks */
},
} as const satisfies ScenaristScenarios;
// Tests run concurrently
test("premium features", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// Test with premium scenario
});
test("free features", async ({ page, switchScenario }) => {
await switchScenario(page, "free");
// Test with free scenario - runs simultaneously
});

How Test Isolation Works: Complete Request Flow

Section titled “How Test Isolation Works: Complete Request Flow”

Here’s how two tests run in parallel with different scenarios, showing the complete journey from scenario setup through multiple requests:

Mocked Auth0 APIMocked Stripe APIScenarist(Routes by test-id)Your Backend(One server, handles both tests)Test 2: Free User(test-id: xyz-789)Test 1: Premium User(test-id: abc-123)Mocked Auth0 APIMocked Stripe APIScenarist(Routes by test-id)Your Backend(One server, handles both tests)Test 2: Free User(test-id: xyz-789)Test 1: Premium User(test-id: abc-123)Tests run in parallel, each with different scenarioTest 1: Set up Premium scenarioTest 2: Set up Free scenario (simultaneous!)Test 1: Complete journey uses Premium scenarioTest 2: Complete journey uses Free scenarioBoth tests complete successfullyNo interference despite running simultaneouslyPOST /__scenario__Headers: x-scenarist-test-id: abc-123Body: { scenario: "premium" }✓ Scenario active for abc-123POST /__scenario__Headers: x-scenarist-test-id: xyz-789Body: { scenario: "free" }✓ Scenario active for xyz-789GET /dashboardHeaders: x-scenarist-test-id: abc-123Check user tierRoutes to Premium scenario(test-id: abc-123){ tier: "premium" }Shows premium features ✓POST /checkoutHeaders: x-scenarist-test-id: abc-123Process paymentRoutes to Premium scenario(test-id: abc-123){ status: "success" }Order confirmed ✓GET /dashboardHeaders: x-scenarist-test-id: xyz-789Check user tierRoutes to Free scenario(test-id: xyz-789){ tier: "free" }Shows limited features ✓POST /upgradeHeaders: x-scenarist-test-id: xyz-789Upgrade page ✓

The test isolation mechanism:

  1. Each test gets a unique ID (generated automatically)
  2. Test switches scenario once via POST /__scenario__ with its test ID
  3. All subsequent requests include the test ID in headers (x-scenarist-test-id: abc-123)
  4. Scenarist routes based on test ID - same URL, different responses per test
  5. Scenario persists for the entire test journey (dashboard → checkout → confirmation)
  6. Tests run in parallel - Test 1 and Test 2 execute simultaneously without affecting each other

This enables:

  • Unlimited scenarios - Test premium, free, errors, edge cases all in parallel
  • No interference - Each test isolated by unique test ID
  • One backend server - All tests share same server instance
  • Real HTTP execution - Your middleware, routing, and logic run normally
  • Fast execution - No expensive external API calls

This enables parallel test execution without process coordination or port conflicts.

Scenarist uses hexagonal architecture to maintain framework independence. The core has no web framework dependencies.

Benefits:

  • Scenario definitions work across all frameworks
  • Framework-specific adapters handle integration
  • Switching frameworks doesn’t require rewriting scenarios

Supported frameworks: Express and Next.js (Pages and App Router). Additional adapters planned.

Learn about the architecture →