Comparisons: how Scenarist compares with MSW, WireMock, Nock, Testcontainers, and Playwright route mocking # Tool Comparison > How Scenarist compares to WireMock, Nock, Testcontainers, Playwright mocks, and how it builds on MSW Choosing the right testing tool depends on your architecture, team, and what you’re testing. This guide helps you understand where Scenarist fits and when other tools might be better suited. ## What Scenarist Offers Scenarist is built on [MSW](https://mswjs.io/) and adds a complete scenario management layer designed for modern testing workflows: Simple Architecture Just an HTTP header. No Docker, no separate processes, no network proxies. Install, configure scenarios, test. [Learn more →](/concepts/architecture/) Test ID Isolation Run hundreds of [parallel tests](/testing/parallel-testing/) with different scenarios against a single server. Each test’s header routes to its own scenario. Runtime Switching Change scenarios mid-test without restarts. Perfect for testing retry flows, error recovery, and multi-step user journeys. First-Class Playwright Dedicated [fixtures](/testing/playwright-integration/) with type-safe scenario switching and automatic test ID handling. Each test gets isolated state without manual header management. Response Sequences Ever tested a polling UI that shows pending → processing → complete? Most tools need complex state management. [Scenarist: three lines of config](/scenarios/response-sequences/). Next.js Multi-Process (Solved) Next.js has [documented singleton issues](https://github.com/vercel/next.js/discussions/68572) that break MSW. Scenarist’s adapter includes built-in `globalThis` guards—one stable MSW instance regardless of module loading. ### Framework Adapters Solve Real Problems Scenarist's adapters aren't thin wrappers—they solve framework-specific challenges that would otherwise require significant boilerplate: **Next.js Adapter:** * **[Module duplication protection](https://github.com/vercel/next.js/discussions/68572)** — Next.js dev mode and Turbopack can load modules multiple times, causing `[MSW] Multiple handlers with the same URL` errors and memory leaks. Scenarist's built-in singleton pattern (`global.__scenarist_instance`) handles this automatically. * **Pages + App Router isolation** — Separate global state keys enable gradual migration without scenario cross-contamination. * **Conditional exports** — Zero MSW code in production bundles via automatic tree-shaking. Without these solutions, you'd need to understand Next.js internals and implement the `globalThis` singleton pattern yourself—Scenarist handles it with a simple `export const`. ### The Parallel Testing Advantage Most mock tools require separate instances for parallel test isolation. Scenarist uses header-based routing instead: ```plaintext ┌─────────────────────────────────────────────────────────────────┐ │ Traditional approach: 10 parallel tests = 10 mock servers │ │ │ │ Test 1 → WireMock:8081 Test 6 → WireMock:8086 │ │ Test 2 → WireMock:8082 Test 7 → WireMock:8087 │ │ Test 3 → WireMock:8083 Test 8 → WireMock:8088 │ │ Test 4 → WireMock:8084 Test 9 → WireMock:8089 │ │ Test 5 → WireMock:8085 Test 10 → WireMock:8090 │ ├─────────────────────────────────────────────────────────────────┤ │ Scenarist: 10 parallel tests = 1 server, 10 headers │ │ │ │ Test 1 ─┬─ x-scenarist-test-id: test-1 ─┬─→ scenario-a │ │ Test 2 ─┤ x-scenarist-test-id: test-2 ─┤─→ scenario-b │ │ ... ├─────────► App Server ─────────┤ ... │ │ Test 9 ─┤ x-scenarist-test-id: test-9 ─┤─→ scenario-a │ │ Test 10─┴─ x-scenarist-test-id: test-10─┴─→ scenario-c │ └─────────────────────────────────────────────────────────────────┘ ``` **Result:** No container orchestration, no port allocation, no startup overhead. Tests run faster and CI stays simple. ## Quick Comparison Matrix | Feature | Scenarist | WireMock | Nock | Testcontainers | Playwright Mocks | | ---------------------- | ------------------------- | ------------------------------- | ---------------------------- | -------------------------- | ---------------- | | **Test isolation** | Per-test via header | Per-server instance | Per-test setup | Per-container | Per-page | | **Runtime switching** | Yes (single API call) | Yes (Admin API) | No | No | No | | **Server Components** | Full support | Yes (network proxy) | No (E2E) / Yes (integration) | Yes (with WireMock module) | No | | **Setup complexity** | npm install | JAR/Docker/npm | npm install | Docker required | Built-in | | **Parallel tests** | Built-in (header routing) | Multiple instances or scenarios | Scope/persist management | Per-container | Per-page | | **Test framework** | First-class Playwright | Generic HTTP | Generic | Generic | Built-in | | **Response sequences** | Built-in | Built-in (Scenarios) | Manual | Via mock server | Manual | | **Stateful mocks** | Built-in capture/inject | Built-in state machine | Manual | Via mock server | Manual | | **Language** | TypeScript/JS | Language-agnostic | JavaScript | Language-agnostic | JavaScript | *Note: “Server Components” refers to E2E testing of React Server Components where requests originate on the server.* ## Understanding the Categories Testing tools fall into different categories based on how and where they intercept requests: Network-Level Mocking **Scenarist, Nock, MSW** Intercept HTTP requests within your Node.js process. No external services needed. Fast setup, TypeScript-native. Server-Based Mocking **WireMock** Standalone mock server that receives real network traffic. Language-agnostic but requires separate process management. Container-Based Testing **Testcontainers** Runs real services in Docker containers. Different purpose—testing against real databases and services, not mocking. Browser-Level Mocking **Playwright’s `page.route()`** Intercepts requests in the browser via Playwright’s built-in API. Perfect for SPAs, but cannot intercept server-side requests (Server Components, API routes). ## Built on MSW Scenarist is built on top of [Mock Service Worker (MSW)](https://mswjs.io/)—the same battle-tested interception library used by thousands of projects. We don’t replace MSW; we add a scenario management layer on top. **What MSW provides:** * Request interception at the network level * Works with any HTTP client (fetch, axios, etc.) * Proven reliability and active maintenance **What Scenarist adds:** * **[Test ID isolation](/testing/parallel-testing/)** — Parallel tests with different scenarios * **[Runtime switching](/concepts/how-it-works/#runtime-scenario-switching)** — Change scenarios without restart * **[First-class Playwright support](/testing/playwright-integration/)** — Dedicated fixtures with type-safe scenario switching and automatic test ID isolation * **[Response sequences](/scenarios/response-sequences/)** — Built-in support for polling, retry flows, state machines * **[Stateful mocks](/scenarios/stateful-mocks/)** — Capture values from requests, inject into responses (state isolated per test ID) * **[Advanced matching](/scenarios/request-matching/)** — Body, headers, query params, regex patterns * **Framework adapters** — Express, Next.js integration out of the box If you need low-level control or use MSW for non-testing purposes (API development, storybook), use MSW directly. If you need scenario management for testing, Scenarist handles that layer. [Learn more about Scenarist + MSW →](/comparison/with-msw/) ## When to Use What ### Choose Scenarist when… * Testing **server-side code** (Server Components, API routes, middleware) * Running **parallel tests** that need different external API states * Wanting to **switch scenarios at runtime** without restarts * Using **Playwright** and want first-class fixtures with type-safe scenario switching * Need **response sequences** for polling APIs, retry flows, or state machines * Need **stateful mocks** that capture request data and inject it into responses * Working in **TypeScript/JavaScript** ecosystems * Testing how your app handles various **external API scenarios** (errors, timeouts, edge cases) ### When NOT to use Scenarist Being explicit about when Scenarist isn’t the right choice: * **Pure client-side SPAs** — If all HTTP calls originate in the browser, [Playwright’s built-in mocks](/comparison/vs-playwright-mocks/) may be simpler * **Java/Python/.NET shops** — WireMock’s ecosystem is better suited for non-JavaScript teams * **Need recording/playback** — Use Nock (nockBack) or WireMock if you need to capture real API responses * **Simple unit tests without parallelism** — Nock is lighter-weight if you don’t need scenario management * **Database testing** — Scenarist mocks HTTP only. See [Testing Database Apps](/guides/testing-database-apps/) for database strategies * **Contract testing** — Use WireMock with Spring Cloud Contract or similar frameworks ### Choose WireMock when… * Working in **non-JavaScript environments** (Java, Python, .NET) * Need **recording/playback** of real API interactions * Want a **standalone mock server** independent of your test process * Team is already familiar with WireMock’s ecosystem [Compare: Scenarist vs WireMock →](/comparison/vs-wiremock/) ### Choose Nock when… * Writing **simple unit tests** with per-test mock setup * Don’t need **parallel test isolation** * Prefer **lighter-weight** approach without framework adapters * Already using Nock and don’t need scenario management [Compare: Scenarist vs Nock →](/comparison/vs-nock/) ### Choose Testcontainers when… * Testing against **real databases** (PostgreSQL, MongoDB) * Need **actual service behavior**, not mocks * Testing **infrastructure integration** (Redis, Kafka, Elasticsearch) * Want **production-like** environment in tests Testcontainers and Scenarist solve different problems—they’re often **complementary**. Many teams use both: Testcontainers for databases, Scenarist for external HTTP APIs. [Compare: Scenarist vs Testcontainers →](/comparison/vs-testcontainers/) ### Choose Playwright’s built-in `page.route()` when… * Testing **client-side only** applications (SPAs) * All HTTP calls originate from the **browser** * Don’t have **server-side rendering** or Server Components * Want **zero additional dependencies** for simple browser-side mocking Note: This refers to Playwright’s native [`page.route()`](https://playwright.dev/docs/mock) API for browser-side request interception—not Scenarist’s `@scenarist/playwright-helpers` package, which provides test fixtures for server-side scenario management. [Compare: Scenarist vs Playwright Mocks →](/comparison/vs-playwright-mocks/) ## Detailed Comparisons [Scenarist + MSW](/comparison/with-msw/)How Scenarist builds on MSW and when to use each [vs WireMock](/comparison/vs-wiremock/)In-process vs server-based mocking approaches [vs Nock](/comparison/vs-nock/)Declarative scenarios vs imperative per-test setup [vs Testcontainers](/comparison/vs-testcontainers/)Mocking external APIs vs running real services [vs Playwright Mocks](/comparison/vs-playwright-mocks/)Server-side vs browser-side request interception ## Making the Decision Still not sure? Here’s a decision tree: **1. Are you testing server-side code (Server Components, API routes)?** * Yes → Scenarist, WireMock, or Nock (not Playwright mocks) * No (client-side only) → Playwright mocks may be sufficient **2. Do you need parallel tests with different scenarios?** * Yes → Scenarist (built-in isolation) or WireMock (multiple instances) * No → Any tool works **3. Do you need to switch scenarios during a test?** * Yes → Scenarist (single API call) or WireMock (Admin API) * No → Any tool works **4. Are you using Playwright for E2E testing?** * Yes → Scenarist (first-class fixtures with type-safe scenarios and automatic test ID isolation) * No → Any tool works **5. Do you need response sequences or stateful mocks?** * Yes → Scenarist (built-in, per-test-ID isolation) or WireMock (built-in Scenarios) * No → Any tool works **6. Are you in a non-JavaScript environment?** * Yes → WireMock * No → Scenarist or Nock **7. Does your app use databases alongside external HTTP APIs?** * Yes → See [Testing Database Apps](/guides/testing-database-apps/) for options: * [Repository Pattern](/guides/testing-database-apps/repository-pattern/) for test ID isolation (recommended) * [Testcontainers Hybrid](/guides/testing-database-apps/testcontainers-hybrid/) for real database + mocked APIs * No (HTTP APIs only) → Scenarist handles everything # Scenarist vs Nock > Compare declarative scenario management with imperative per-test mock setup [Nock](https://github.com/nock/nock) is a popular HTTP mocking library for Node.js. Since v14, Nock uses [@mswjs/interceptors](https://github.com/mswjs/interceptors)—the same interception engine as MSW—making it robust and modern. Scenarist takes a different approach—declarative scenario definitions with built-in parallel test isolation. ## What Scenarist Offers Before diving into comparisons, here's what Scenarist brings to the table: * **[Simple Architecture](/concepts/architecture/)** — Just an HTTP header (`x-scenarist-test-id`). No Docker, no separate processes, no complex network configuration * **[Test ID Isolation](/testing/parallel-testing/)** — Run hundreds of parallel tests with different scenarios against one server. Each test's header routes to its own scenario * **Runtime Switching** — Change scenarios mid-test without restarts (retry flows, error recovery) * **[First-Class Playwright](/testing/playwright-integration/)** — Dedicated fixtures with type-safe scenarios and automatic test ID handling * **[Response Sequences](/scenarios/response-sequences/)** — Built-in polling, retry flows, state machines * **[Stateful Mocks](/scenarios/stateful-mocks/)** — Capture request values, inject into responses. State is isolated per test ID, so parallel tests never conflict * **[Advanced Matching](/scenarios/request-matching/)** — Body, headers, query params, regex with specificity-based selection * **[Framework Adapters](/getting-started/why-scenarist/#nextjs-multi-process-handling)** — Not thin wrappers—they solve real problems. For example, the Next.js adapter includes built-in singleton protection for the [module duplication issue](https://github.com/vercel/next.js/discussions/68572) that breaks MSW * **Developer Tools ([Roadmap](/roadmap/))** — Planned browser-based plugin for switching scenarios during development and debugging—making scenario exploration instant and visual ## At a Glance | Aspect | Scenarist | Nock | | ------------------------ | ---------------------------- | ------------------------------- | | **Approach** | Declarative scenarios | Imperative per-test setup | | **Test isolation** | Per-test via test ID | Manual scope management | | **Interception level** | Network (MSW) | @mswjs/interceptors (v14+) | | **Runtime switching** | Yes (API call) | Manual (cleanAll + reconfigure) | | **Browser support** | Node.js only (server-side) | Node.js only | | **Setup** | Framework adapters | Standalone library | | **TypeScript** | Native (scenarios are TS) | Built-in definitions | | **Recording** | Not supported | nockBack fixture recording | | **RSC scenario testing** | ✓ (server-side interception) | ✗ (process isolation issue) | ## Key Differences ### Declarative vs Imperative * Scenarist ```typescript // Declarative - describe what, not how const scenarios = { 'user-premium': { id: 'user-premium', name: 'Premium user', description: 'Active subscription', mocks: [{ method: 'GET', url: 'https://api.stripe.com/v1/customers/cus_123', response: { status: 200, body: { id: 'cus_123', subscriptions: { data: [{ status: 'active' }] } } } }] }, 'user-free': { id: 'user-free', name: 'Free user', description: 'No subscriptions', mocks: [{ method: 'GET', url: 'https://api.stripe.com/v1/customers/cus_123', response: { status: 200, body: { id: 'cus_123', subscriptions: { data: [] } } } }] } } as const satisfies ScenaristScenarios; // Tests select scenarios by name test('premium features visible', async ({ page, switchScenario }) => { await switchScenario(page, 'user-premium'); }); ``` * Nock ```typescript // Imperative - describe how to respond beforeEach(() => { nock.cleanAll(); }); test('premium features visible', async () => { // Set up mock inline for this test nock('https://api.stripe.com') .get('/v1/customers/cus_123') .reply(200, { id: 'cus_123', subscriptions: { data: [{ status: 'active' }] } }); // Test code... }); test('free features visible', async () => { // Different setup for this test nock('https://api.stripe.com') .get('/v1/customers/cus_123') .reply(200, { id: 'cus_123', subscriptions: { data: [] } }); // Test code... }); ``` **Trade-off:** Scenarist’s declarative approach makes scenarios reusable and inspectable—you can see all scenarios in one place. Nock’s imperative approach is more flexible for one-off mocks but can lead to duplication. ### Parallel Test Isolation * Scenarist ```typescript // Built-in test isolation via test ID test.describe.parallel('Payment flows', () => { test('success flow', async ({ page, switchScenario }) => { // x-scenarist-test-id header routes to this scenario await switchScenario(page, 'payment-success'); // Other tests can run simultaneously with different scenarios }); test('failure flow', async ({ page, switchScenario }) => { await switchScenario(page, 'payment-declined'); // Same server, same time, isolated by test ID }); }); ``` * Nock ```typescript // Manual isolation - requires careful scoping test.describe('Payment flows', () => { // Serial execution to avoid conflicts test.describe.serial('success flow', () => { test.beforeEach(() => { nock.cleanAll(); nock('https://api.stripe.com') .post('/v1/charges') .reply(200, { status: 'succeeded' }); }); test('shows success', async () => { /* ... */ }); }); test.describe.serial('failure flow', () => { test.beforeEach(() => { nock.cleanAll(); nock('https://api.stripe.com') .post('/v1/charges') .reply(402, { error: 'declined' }); }); test('shows error', async () => { /* ... */ }); }); }); // Parallel tests with Nock require careful isolation // or separate test processes ``` **Trade-off:** Scenarist was designed specifically for parallel test isolation. Nock can work with parallel tests but requires careful scope management to avoid mock leakage between tests. ### Request Matching * Scenarist ```typescript // Declarative matching patterns { method: 'POST', url: 'https://api.stripe.com/v1/charges', match: { body: { amount: '5000', currency: 'usd' }, headers: { 'idempotency-key': /^[a-f0-9-]+$/ } }, response: { status: 200, body: { id: 'ch_123' } } } ``` * Nock ```typescript // Fluent API for matching nock('https://api.stripe.com') .post('/v1/charges', { amount: 5000, currency: 'usd' }) .matchHeader('idempotency-key', /^[a-f0-9-]+$/) .reply(200, { id: 'ch_123' }); // Or with function matchers nock('https://api.stripe.com') .post('/v1/charges', (body) => body.amount > 0) .reply(200, { id: 'ch_123' }); ``` **Trade-off:** Both offer rich matching capabilities. Nock allows function matchers for maximum flexibility. Scenarist’s declarative patterns are more restrictive but enable inspection and composition. ### Scope and Cleanup * Scenarist ```typescript // Scenarios persist - switch between them test('multi-step flow', async ({ page, switchScenario }) => { // Start with error await switchScenario(page, 'payment-timeout'); await page.click('#submit'); // Switch to success for retry await switchScenario(page, 'payment-success'); await page.click('#retry'); // No cleanup needed - test ID isolation }); ``` * Nock ```typescript // Interceptors are consumed once by default test('multi-step flow', async () => { // First call nock('https://api.stripe.com') .post('/v1/charges') .reply(504, 'timeout'); await page.click('#submit'); // Interceptor consumed - need another nock('https://api.stripe.com') .post('/v1/charges') .reply(200, { status: 'succeeded' }); await page.click('#retry'); // Can use .persist() for multiple calls // nock(...).persist().reply(...) }); ``` **Trade-off:** Nock’s one-time consumption is explicit about expected call counts. Scenarist’s persistent scenarios are simpler for flows where the same endpoint is called multiple times. ### Server Component Testing: The Process Isolation Problem Nock intercepts requests in the **current Node.js process**. In E2E testing with Playwright, your test runs in one process while the Next.js server runs in a **separate process**—Nock in the test process can’t intercept requests from the server process. * E2E Testing (Nock Fails) ```typescript // ❌ E2E Test - FAILS // Test process import nock from 'nock'; nock('https://api.stripe.com') .get('/v1/products') .reply(200, { products: [] }); // Playwright launches browser, browser requests page from Next.js server // Next.js server (SEPARATE PROCESS) fetches from Stripe // Nock never sees it - the request goes to real Stripe await page.goto('/products'); // Fails or uses real API ``` * Integration Testing (Nock Works) ```typescript // ✅ Integration Test - WORKS (same process) import nock from 'nock'; import { renderToString } from 'react-dom/server'; import ProductsPage from './app/products/page'; nock('https://api.stripe.com') .get('/v1/products') .reply(200, { products: [{ id: 'prod_1' }] }); // Rendering happens in the SAME process as the test const html = await renderToString(); expect(html).toContain('prod_1'); // ✓ Works ``` * Scenarist E2E (Works) ```typescript // ✅ E2E Test - WORKS // Scenarist runs inside the Next.js server process // Test ID header routes requests to correct scenario test('shows products', async ({ page, switchScenario }) => { await switchScenario(page, 'products-available'); await page.goto('/products'); // Next.js server's fetch is intercepted by MSW (Scenarist) await expect(page.locator('.product')).toHaveCount(3); }); ``` **Key insight:** Scenarist runs inside your application server (via framework adapters), so it intercepts requests where they originate. Nock runs in your test process and can only intercept requests made from that process. ## When to Choose Scenarist Scenario-based testing with Server Components Scenarist’s server-side interception works across process boundaries. Nock can’t intercept requests from a separate Next.js server process. Parallel test isolation Built-in test ID system enables hundreds of tests with different scenarios running simultaneously. Scenario libraries Define scenarios once, reuse everywhere. Changes in one place update all tests. Runtime switching Change scenarios mid-test without cleanup/setup. Test retry flows, state machines. ## When to Choose Nock Integration tests (same process) When test and server run in the same process, Nock works great. E2E with separate processes—use Scenarist. Fixture recording (nockBack) Record real HTTP interactions to JSON files, replay in tests. Great for contract snapshots. Call counting Built-in assertions on call counts. Verify exactly N requests were made. Existing investment Team already uses Nock. Migration cost outweighs benefits for integration tests. ## Migration Considerations ### From Nock to Scenarist ```typescript // Nock - inline definitions beforeEach(() => { nock('https://api.stripe.com') .get('/v1/customers/cus_123') .reply(200, { id: 'cus_123', name: 'Test Customer' }); nock('https://api.sendgrid.com') .post('/v3/mail/send') .reply(202); }); // Scenarist - centralized scenarios const scenarios = { default: { id: 'default', name: 'Default', description: 'Baseline responses', mocks: [ { method: 'GET', url: 'https://api.stripe.com/v1/customers/cus_123', response: { status: 200, body: { id: 'cus_123', name: 'Test Customer' } } }, { url: 'https://api.sendgrid.com/v3/mail/send', method: 'POST', response: { status: 202 } } ] } } as const satisfies ScenaristScenarios; ``` **Migration benefits:** * Scenarios become visible in one place * Test isolation improves automatically * Runtime switching becomes possible **Migration costs:** * Learn declarative patterns instead of fluent API * Convert inline mocks to scenarios * Set up framework adapter ## Summary | Factor | Scenarist | Nock | | --------------------- | --------------------- | ------------------------- | | RSC scenario testing | ✓ Works (server-side) | ✗ Process isolation issue | | Parallel isolation | ✓ Built-in (test ID) | Manual | | Scenario reuse | ✓ Declarative | Manual extraction | | Runtime switching | ✓ Single API call | Reconfigure handlers | | TypeScript | ✓ Native | ✓ Built-in definitions | | Recording | Not supported | ✓ nockBack | | Function matchers | Declarative patterns | ✓ Full flexibility | | Call count assertions | Not built-in | ✓ Built-in | | Integration tests | ✓ Works | ✓ Works | **Bottom line:** Choose Scenarist for scenario-based testing with Server Components (where Nock can’t reach across processes), parallel test isolation, and runtime switching. Choose Nock for integration tests in the same process, when you need fixture recording (nockBack), or when you need function matchers and call counting. # Scenarist vs Playwright Mocks > Compare server-side request interception with browser-side mocking for modern web applications Playwright includes [built-in request interception](https://playwright.dev/docs/mock) via `page.route()` and HAR file recording. This works great for client-side requests. Scenarist intercepts requests on the server—essential for scenario-based testing of Server Components, API routes, and middleware. ## What Scenarist Offers Before diving into comparisons, here's what Scenarist brings to the table: * **[Simple Architecture](/concepts/architecture/)** — Just an HTTP header (`x-scenarist-test-id`). No Docker, no separate processes, no complex network configuration * **[Test ID Isolation](/testing/parallel-testing/)** — Run hundreds of parallel tests with different scenarios against one server. Each test's header routes to its own scenario * **Runtime Switching** — Change scenarios mid-test without restarts (retry flows, error recovery) * **[First-Class Playwright](/testing/playwright-integration/)** — Dedicated fixtures with type-safe scenarios and automatic test ID handling * **[Response Sequences](/scenarios/response-sequences/)** — Built-in polling, retry flows, state machines * **[Stateful Mocks](/scenarios/stateful-mocks/)** — Capture request values, inject into responses. State is isolated per test ID, so parallel tests never conflict * **[Advanced Matching](/scenarios/request-matching/)** — Body, headers, query params, regex with specificity-based selection * **[Framework Adapters](/getting-started/why-scenarist/#nextjs-multi-process-handling)** — Not thin wrappers—they solve real problems. For example, the Next.js adapter includes built-in singleton protection for the [module duplication issue](https://github.com/vercel/next.js/discussions/68572) that breaks MSW * **Developer Tools ([Roadmap](/roadmap/))** — Planned browser-based plugin for switching scenarios during development and debugging—making scenario exploration instant and visual ## At a Glance | Aspect | Scenarist | Playwright Mocks | | ------------------------ | --------------------------- | ---------------------- | | **Intercepts at** | Server (Node.js) | Browser | | **Server Components** | ✓ Full support | ✗ Cannot intercept | | **API Routes** | ✓ Full support | ✗ Cannot intercept | | **Client-side fetch** | Via your server routes only | ✓ Yes | | **Setup** | Framework adapter | Built into Playwright | | **Test isolation** | Per-test via test ID | Per-page | | **HAR recording** | Not supported | ✓ Built-in | | **Next.js 15 testProxy** | Not needed (built-in) | ✓ Experimental support | ## The Critical Difference: Where Requests Originate ```plaintext ┌─────────────────────────────────────────────────────────────────┐ │ │ │ Browser Server (Node.js) │ │ ──────── ───────────────── │ │ │ │ ┌──────────────┐ ┌──────────────────────┐ │ │ │ Client │ HTTP Request │ Server Component │ │ │ │ Component │ ──────────────► │ │ │ │ │ │ │ const data = await │ │ │ │ onClick: │ │ fetch('https:// │ │ │ │ fetch('/api') │ api.stripe.com') │ │ │ │ │ │ │ │ │ └──────┬───────┘ └──────────┬───────────┘ │ │ │ │ │ │ │ ◄── Playwright │ │ │ │ can intercept │ ◄── Scenarist │ │ │ │ intercepts │ │ ▼ ▼ │ │ ┌──────────────┐ ┌──────────────────────┐ │ │ │ External API │ │ External API │ │ │ │ (from │ │ (from server) │ │ │ │ browser) │ │ │ │ │ └──────────────┘ └──────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` **Playwright’s `page.route()`** intercepts requests that originate in the browser. **Scenarist** intercepts requests that originate on the server (Node.js). For Server Components, API routes, and middleware—the server makes HTTP requests that never touch the browser. Playwright can’t see them. ## Example: Server Component * Scenarist (Works) ```typescript // app/checkout/page.tsx - Server Component export default async function CheckoutPage() { // This fetch runs on the SERVER const products = await fetch('https://api.stripe.com/v1/products'); const data = await products.json(); return ; } // checkout.spec.ts test('displays products', async ({ page, switchScenario }) => { // Scenarist intercepts the server-side fetch await switchScenario(page, 'products-available'); await page.goto('/checkout'); // Server Component received mocked Stripe response await expect(page.locator('.product')).toHaveCount(3); }); ``` * Playwright Mocks (Cannot Work) ```typescript // app/checkout/page.tsx - Server Component export default async function CheckoutPage() { // This fetch runs on the SERVER const products = await fetch('https://api.stripe.com/v1/products'); const data = await products.json(); return ; } // checkout.spec.ts test('displays products', async ({ page }) => { // ❌ This CANNOT intercept server-side fetches await page.route('**/api.stripe.com/**', route => { route.fulfill({ status: 200, body: JSON.stringify({ products: [...] }) }); }); await page.goto('/checkout'); // The Server Component already fetched from real Stripe // Browser route handler never saw the request // Test sees real Stripe data (or fails if no API key) }); ``` ## Playwright’s Additional Features ### HAR File Recording Playwright can record and replay HTTP traffic using HAR (HTTP Archive) files: ```typescript // Record HAR during test await page.routeFromHAR('recording.har', { url: '**/api/**', update: true, // Record mode }); // Replay HAR in subsequent tests await page.routeFromHAR('recording.har', { url: '**/api/**', update: false, // Replay mode }); ``` **Limitation:** HAR only captures browser-level requests. Server-side requests (Server Components, API routes) are never recorded because they don’t pass through the browser. ### Next.js 15 Experimental testProxy Next.js 15 introduced an experimental `testProxy` feature that proxies server-side fetch calls, allowing MSW to intercept them: next.config.js ```javascript module.exports = { experimental: { testProxy: true, }, } ``` **Status:** Experimental with known limitations—doesn’t intercept internal route handler calls (fetches using relative URLs within the same app), and some font loading issues reported. **Why Scenarist is still valuable:** Scenarist provides scenario management, test ID isolation, runtime switching, and Playwright fixtures on top of MSW—features you’d need to build yourself even with testProxy. ## When Each Tool Works ### Playwright Mocks Work For Client-side SPAs React, Vue, Angular apps where all fetches originate in the browser. Client Components Next.js Client Components that fetch data on the client side. Static sites Pre-rendered pages where dynamic data is fetched client-side. Zero-config mocking Built into Playwright—no additional setup required. ### Scenarist Works For Server Components Next.js/React Server Components that fetch data during SSR. API Routes Next.js API routes, Express endpoints that call external APIs. Middleware Authentication middleware, edge functions that make HTTP calls. Mixed architectures Apps with both client and server-side data fetching. ## Code Comparison ### Client-Side Fetch (Both Work) * Playwright Mocks ```typescript // components/UserProfile.tsx - Client Component 'use client'; export function UserProfile() { const [user, setUser] = useState(null); useEffect(() => { // Client-side fetch - browser makes the request fetch('/api/user').then(r => r.json()).then(setUser); }, []); return
{user?.name}
; } // profile.spec.ts test('shows user name', async ({ page }) => { // ✓ Works - intercepts browser request await page.route('**/api/user', route => { route.fulfill({ status: 200, body: JSON.stringify({ name: 'Test User' }) }); }); await page.goto('/profile'); await expect(page.locator('div')).toContainText('Test User'); }); ``` * Scenarist profile.spec.ts ```typescript // Same client component test('shows user name', async ({ page, switchScenario }) => { // ✓ Works - /api/user runs on your server, and Scenarist intercepts // the external API calls it makes (browser requests are not intercepted) await switchScenario(page, 'user-profile'); await page.goto('/profile'); await expect(page.locator('div')).toContainText('Test User'); }); ``` ### Server-Side Fetch (Only Scenarist Works) * Scenarist (Works) ```typescript // app/dashboard/page.tsx - Server Component export default async function Dashboard() { // Server-side fetch - runs in Node.js const analytics = await fetch('https://api.analytics.com/data', { headers: { 'Authorization': `Bearer ${process.env.ANALYTICS_KEY}` } }); return ; } // dashboard.spec.ts test('displays analytics', async ({ page, switchScenario }) => { // ✓ Scenarist intercepts server-side fetch await switchScenario(page, 'analytics-data'); await page.goto('/dashboard'); await expect(page.locator('.chart')).toBeVisible(); }); ``` * Playwright Mocks (Cannot Work) dashboard.spec.ts ```typescript // Same Server Component test('displays analytics', async ({ page }) => { // ❌ Cannot intercept - request never reaches browser await page.route('**/api.analytics.com/**', route => { route.fulfill({ /* ... */ }); }); await page.goto('/dashboard'); // Server already made real request to analytics API // Test fails or shows real data }); ``` ## Using Both Together For apps with both client and server-side fetching, you might use both tools: ```typescript // Mixed architecture test test('complete user flow', async ({ page, switchScenario }) => { // Scenarist handles server-side fetches (Server Components, API routes) await switchScenario(page, 'user-flow-success'); await page.goto('/dashboard'); // Server Component fetches analytics // Playwright route for a specific client-side mock // (e.g., a third-party widget that loads in browser) await page.route('**/widget.thirdparty.com/**', route => { route.fulfill({ status: 200, body: '{}' }); }); await page.click('#load-widget'); await expect(page.locator('.widget')).toBeVisible(); }); ``` Scenarist intercepts requests made by your server, including those triggered by client-side calls to your own API routes. Requests the browser sends directly to third-party hosts never reach your server, so use Playwright mocks for those. ## Decision Guide **Are you testing Server Components, API routes, or middleware?** * Yes → Use Scenarist (Playwright mocks cannot intercept these) **Is your app a pure client-side SPA?** * Yes → Playwright mocks may be sufficient **Do you need parallel test isolation with different scenarios?** * Yes → Use Scenarist (built-in test ID isolation) **Do you want zero additional setup?** * Yes, for simple cases → Playwright mocks are built-in * For comprehensive testing → Scenarist is worth the setup ## Summary | Capability | Scenarist | Playwright Mocks | | ------------------- | ---------------------- | ----------------------------- | | Server Components | ✓ | ✗ (or experimental testProxy) | | API Routes | ✓ | ✗ | | Middleware | ✓ | ✗ | | SSR data fetching | ✓ | ✗ | | Client-side fetch | Via server routes only | ✓ | | Zero setup | Framework adapter | ✓ Built-in | | Parallel isolation | ✓ Per-test-ID | Per-page | | Scenario management | ✓ | Manual | | HAR recording | Not supported | ✓ | | Runtime switching | ✓ | Manual | **Bottom line:** For scenario-based testing of modern server-rendered applications (Next.js App Router, Remix, etc.), Scenarist provides server-side request interception with scenario management and test ID isolation. Playwright mocks are great for pure client-side apps, HAR recording, or supplementing Scenarist for browser-specific needs. Next.js 15’s experimental testProxy may help with server-side interception, but Scenarist still provides the scenario management layer. # Scenarist vs Testcontainers > Mocking external APIs vs running real containerized services - complementary approaches [Testcontainers](https://testcontainers.com/) runs real services in Docker containers for testing—PostgreSQL, Redis, Kafka, and more. Scenarist mocks HTTP APIs. These tools solve **different problems** and are often **complementary** rather than competing. ## What Scenarist Offers Before diving into comparisons, here's what Scenarist brings to the table: * **[Simple Architecture](/concepts/architecture/)** — Just an HTTP header (`x-scenarist-test-id`). No Docker, no separate processes, no complex network configuration * **[Test ID Isolation](/testing/parallel-testing/)** — Run hundreds of parallel tests with different scenarios against one server. Each test's header routes to its own scenario * **Runtime Switching** — Change scenarios mid-test without restarts (retry flows, error recovery) * **[First-Class Playwright](/testing/playwright-integration/)** — Dedicated fixtures with type-safe scenarios and automatic test ID handling * **[Response Sequences](/scenarios/response-sequences/)** — Built-in polling, retry flows, state machines * **[Stateful Mocks](/scenarios/stateful-mocks/)** — Capture request values, inject into responses. State is isolated per test ID, so parallel tests never conflict * **[Advanced Matching](/scenarios/request-matching/)** — Body, headers, query params, regex with specificity-based selection * **[Framework Adapters](/getting-started/why-scenarist/#nextjs-multi-process-handling)** — Not thin wrappers—they solve real problems. For example, the Next.js adapter includes built-in singleton protection for the [module duplication issue](https://github.com/vercel/next.js/discussions/68572) that breaks MSW * **Developer Tools ([Roadmap](/roadmap/))** — Planned browser-based plugin for switching scenarios during development and debugging—making scenario exploration instant and visual ## Different Problems, Different Solutions ```plaintext ┌─────────────────────────────────────────────────────────────────┐ │ Your Application │ │ │ │ ┌─────────────────┐ ┌──────────────────────────┐ │ │ │ │ │ │ │ │ │ Database │ │ External APIs │ │ │ │ PostgreSQL │ │ Stripe, Auth0, │ │ │ │ Redis │ │ SendGrid, Twilio │ │ │ │ MongoDB │ │ │ │ │ │ │ │ │ │ │ └────────┬────────┘ └────────────┬─────────────┘ │ │ │ │ │ │ │ │ │ │ ▼ ▼ │ │ ┌─────────────────┐ ┌──────────────────────────┐ │ │ │ Testcontainers │ │ Scenarist │ │ │ │ │ │ │ │ │ │ Real services │ │ Mocked responses │ │ │ │ in containers │ │ Scenario-based │ │ │ └─────────────────┘ └──────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` | Aspect | Testcontainers | Scenarist | | ------------------ | ------------------------- | ------------------------------ | | **Purpose** | Run real services | Mock HTTP APIs | | **Use case** | Databases, message queues | Third-party APIs | | **What runs** | Actual software in Docker | Your app with mocked responses | | **Fidelity** | Real behavior | Controlled scenarios | | **Startup time** | Seconds to minutes | Instant | | **Resource usage** | Container per service | In-process | ## When Each Tool Applies ### Testcontainers is for… Database testing PostgreSQL, MySQL, MongoDB, Redis. Test real queries, migrations, transactions. Message queues Kafka, RabbitMQ, SQS (LocalStack). Test actual message processing. Infrastructure Elasticsearch, MinIO, Keycloak. Test integrations with real services. Contract validation Verify your code works with the actual database version you’ll deploy. ### Scenarist is for… External HTTP APIs Stripe, Auth0, SendGrid, Twilio. APIs you don’t control and can’t run locally. Error scenarios Test how your app handles API timeouts, rate limits, specific error codes. Parallel testing Run many tests simultaneously with different API states. Edge cases Scenarios you can’t easily create with real services. ## Example: Using Both Together A typical application might use **both** tools: test-setup.ts ```typescript // Testcontainers for your database import { PostgreSqlContainer } from "@testcontainers/postgresql"; const postgres = await new PostgreSqlContainer().withDatabase("testdb").start(); // Your app connects to real PostgreSQL in Docker process.env.DATABASE_URL = postgres.getConnectionUri(); // Scenarist for external APIs // Stripe, SendGrid, etc. are mocked - they don't have containers export const scenarist = createScenarist({ enabled: true, scenarios: { default: { id: "default", name: "Default", description: "Baseline responses", mocks: [ { url: "https://api.stripe.com/v1/charges", method: "POST", response: { status: 200, body: { id: "ch_123" } }, }, { url: "https://api.sendgrid.com/v3/mail/send", method: "POST", response: { status: 202 }, }, ], }, }, }); ``` checkout.spec.ts ```typescript test("creates order and sends confirmation", async ({ page, switchScenario, }) => { // Database operations use real PostgreSQL (Testcontainers) // Stripe payment uses mocked API (Scenarist) // SendGrid email uses mocked API (Scenarist) await switchScenario(page, "payment-success"); await page.goto("/checkout"); await page.fill('[name="card"]', "4242424242424242"); await page.click("#submit"); // Real database write happened // Mocked Stripe returned success // Mocked SendGrid accepted email await expect(page.locator(".success")).toBeVisible(); // Verify database state (real PostgreSQL) const order = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]); expect(order.status).toBe("completed"); }); ``` ## Can Testcontainers Mock HTTP APIs? **Yes, but with more complexity.** Testcontainers has modules for HTTP mock servers: | Module | Package | Status | | ---------- | ---------------------------------------- | ---------------------------------------------------------------- | | MockServer | `@testcontainers/mockserver` | Official | | WireMock | `@wiremock/wiremock-testcontainers-node` | [Official Partner](https://testcontainers.com/modules/wiremock/) | ```typescript // Testcontainers with WireMock for HTTP mocking import { WireMockContainer } from "@wiremock/wiremock-testcontainers-node"; const container = await new WireMockContainer() .withMapping({ request: { method: "GET", urlPath: "/v1/products" }, response: { status: 200, jsonBody: { products: [] } }, }) .start(); // Point your app at the container process.env.STRIPE_API_URL = container.getBaseUrl(); ``` **Trade-offs vs Scenarist:** | Factor | Testcontainers + WireMock | Scenarist | | ----------------- | ----------------------------- | ------------------------ | | Setup | Docker + container management | npm install | | Startup time | Seconds (container spin-up) | Instant (in-process) | | Test isolation | Per-container (heavier) | Per-header (lightweight) | | Runtime switching | Admin API | Single API call | | Network config | Required (proxy/env vars) | None (in-process) | **When HTTP mocking via Testcontainers makes sense:** * You’re already heavily invested in Testcontainers * You want identical mocking approach to your Java/Go services * You need WireMock’s specific features (recording, contract testing) **When Scenarist is simpler:** * You want the lightest possible setup * You’re already in a Node.js/TypeScript environment * You value instant test startup over container isolation ## Why Not Use Scenarist for Everything? Mocking databases is possible but loses fidelity: * Mocked Database (Limited) ```typescript // You could mock database API calls... { url: 'http://localhost:5432/query', match: { body: { sql: /SELECT.*FROM users/ } }, response: { body: { rows: [{ id: 1, email: 'test@example.com' }] } } } // But you lose: // - Real SQL query execution // - Database constraints // - Transaction behavior // - Migration testing // - Performance characteristics ``` * Real Database (Better) ```typescript // Testcontainers gives you the real thing const postgres = await new PostgreSqlContainer().start(); // Real queries against real PostgreSQL const result = await db.query(` SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL `, ['test@example.com']); // Real constraints, real behavior // Your ORM/query builder works exactly like production ``` **Rule of thumb:** * **Use Testcontainers** for services you own or can run locally * **Use Scenarist** for services you don’t control (third-party APIs) ## Comparison Summary | Factor | Testcontainers | Scenarist | | ------------------- | ------------------------- | -------------------------- | | Databases | ✓ Best choice | Not recommended | | Message queues | ✓ Best choice | Not recommended | | HTTP API mocking | ✓ Via WireMock/MockServer | ✓ Built-in | | Setup complexity | Docker + containers | npm install | | Startup time | Seconds (containers) | ✓ Instant | | Resource usage | Higher (containers) | ✓ Minimal | | Fidelity | ✓ Real behavior | Controlled | | Parallel isolation | Per-container | ✓ Per-header (lightweight) | | Runtime switching | Admin API | ✓ Single API call | | Playwright fixtures | Manual setup | ✓ First-class support | ## Hybrid Architecture For comprehensive testing, use both: ```plaintext ┌─────────────────────────────────────────────────────────────────┐ │ Test Suite │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ Your Application │ │ │ │ │ │ │ │ ┌──────────────┐ ┌──────────────────┐ │ │ │ │ │ Database │ │ External APIs │ │ │ │ │ │ Layer │ │ Layer │ │ │ │ │ └──────┬───────┘ └────────┬─────────┘ │ │ │ └───────────┼─────────────────────────────┼───────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌───────────────────┐ ┌────────────────────────────┐ │ │ │ Testcontainers │ │ Scenarist │ │ │ │ │ │ │ │ │ │ • PostgreSQL │ │ • Stripe (mocked) │ │ │ │ • Redis │ │ • Auth0 (mocked) │ │ │ │ • Kafka │ │ • SendGrid (mocked) │ │ │ │ │ │ │ │ │ │ Real services │ │ Controlled scenarios │ │ │ │ Real behavior │ │ Parallel isolation │ │ │ └───────────────────┘ └────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` **This hybrid approach gives you:** * Real database behavior (Testcontainers) * Controlled external API scenarios (Scenarist) * Comprehensive test coverage * Fast parallel execution (Scenarist’s test ID isolation) See our [Testcontainers Hybrid guide](/guides/testing-database-apps/testcontainers-hybrid/) for detailed setup instructions. ## Bottom Line **Testcontainers and Scenarist solve different problems.** Use Testcontainers for services you can run locally (databases, queues). Use Scenarist for services you can’t control (third-party APIs). For many applications, you’ll use both together. # Scenarist vs WireMock > Compare in-process network mocking with server-based mock servers for API testing [WireMock](https://wiremock.org/) is a mature, widely-adopted mock server for simulating HTTP APIs. It runs as a standalone Java process and can be used from any language. Scenarist takes a different approach—intercepting requests within your Node.js process using MSW. ## What Scenarist Offers Before diving into comparisons, here's what Scenarist brings to the table: * **[Simple Architecture](/concepts/architecture/)** — Just an HTTP header (`x-scenarist-test-id`). No Docker, no separate processes, no complex network configuration * **[Test ID Isolation](/testing/parallel-testing/)** — Run hundreds of parallel tests with different scenarios against one server. Each test's header routes to its own scenario * **Runtime Switching** — Change scenarios mid-test without restarts (retry flows, error recovery) * **[First-Class Playwright](/testing/playwright-integration/)** — Dedicated fixtures with type-safe scenarios and automatic test ID handling * **[Response Sequences](/scenarios/response-sequences/)** — Built-in polling, retry flows, state machines * **[Stateful Mocks](/scenarios/stateful-mocks/)** — Capture request values, inject into responses. State is isolated per test ID, so parallel tests never conflict * **[Advanced Matching](/scenarios/request-matching/)** — Body, headers, query params, regex with specificity-based selection * **[Framework Adapters](/getting-started/why-scenarist/#nextjs-multi-process-handling)** — Not thin wrappers—they solve real problems. For example, the Next.js adapter includes built-in singleton protection for the [module duplication issue](https://github.com/vercel/next.js/discussions/68572) that breaks MSW * **Developer Tools ([Roadmap](/roadmap/))** — Planned browser-based plugin for switching scenarios during development and debugging—making scenario exploration instant and visual ## At a Glance | Aspect | Scenarist | WireMock | | ------------------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Architecture** | In-process (MSW) | Standalone server | | **Language** | TypeScript/JavaScript | Language-agnostic (Node.js via [wiremock](https://www.npmjs.com/package/wiremock) CLI or [wiremock-captain](https://www.npmjs.com/package/wiremock-captain) client) | | **Test isolation** | Per-test via header | Per-server instance or Scenarios | | **Runtime switching** | Yes (single API call) | Yes (Admin API) | | **Setup** | npm install | JAR, Docker, or npm + network configuration | | **Test framework integration** | First-class Playwright fixtures | Generic HTTP API | | **Response sequences** | Built-in (polling, state machines) | Built-in (Scenarios feature) | | **Stateful mocks** | Built-in capture/inject per test ID | Built-in state machine | | **Recording** | Not supported | Built-in record/playback | | **Templating** | Template strings | Handlebars templating | ## Key Differences ### Architecture: In-Process vs Standalone * Scenarist ```typescript // Mocks run in the same process as your app import { createScenarist } from '@scenarist/express-adapter'; export const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", scenarios: { default: { id: "default", name: "Default", description: "Baseline responses", mocks: [ { url: "https://api.stripe.com/v1/charges", method: "POST", response: { status: 200, body: { id: "ch_123" } }, }, ], }, }, }); // No external process to manage // No network latency between your app and mocks // Debugging in the same process ``` * WireMock ```java // Standalone server - must be started separately // docker run -p 8080:8080 wiremock/wiremock // Then configure via API or JSON files { "request": { "method": "POST", "url": "/v1/charges" }, "response": { "status": 200, "jsonBody": { "id": "ch_123" } } } // Your app calls WireMock instead of real API // STRIPE_API_URL=http://localhost:8080 npm test ``` **Trade-off:** Scenarist’s in-process approach means simpler setup—just npm install and configure. No separate server process, no network configuration to route your app’s requests. WireMock’s server approach works with any language (via JAR, Docker, or [npm package](https://www.npmjs.com/package/wiremock)) but requires running a separate process and configuring your app to route requests to the mock server. ### Test Isolation * Scenarist ```typescript // Each test gets isolated scenarios via test ID test('payment success', async ({ page, switchScenario }) => { // This test sees 'payment-success' responses await switchScenario(page, 'payment-success'); await page.goto('/checkout'); await expect(page.locator('.success')).toBeVisible(); }); test('payment declined', async ({ page, switchScenario }) => { // Same app, same time, different scenario await switchScenario(page, 'payment-declined'); await page.goto('/checkout'); await expect(page.locator('.error')).toBeVisible(); }); // Both tests run in parallel against ONE server // Test ID header routes to correct scenario ``` * WireMock ```typescript // Each test typically needs its own WireMock instance // Or careful state management between tests beforeEach(async () => { // Reset all stubs await fetch('http://localhost:8080/__admin/mappings/reset', { method: 'POST' }); // Configure for this specific test await fetch('http://localhost:8080/__admin/mappings', { method: 'POST', body: JSON.stringify({ request: { method: 'POST', url: '/v1/charges' }, response: { status: 200, jsonBody: { id: 'ch_123' } } }) }); }); // Parallel tests require multiple WireMock instances // Or sophisticated scenario management ``` **Trade-off:** Scenarist’s test ID system was designed specifically for parallel test isolation. WireMock can achieve similar results but requires more infrastructure (multiple instances) or careful state management. ### Runtime Scenario Switching * Scenarist ```typescript // Switch scenarios mid-test without restart test('retry after failure', async ({ page, switchScenario }) => { // Start with failure await switchScenario(page, 'payment-timeout'); await page.goto('/checkout'); await page.click('[data-testid="submit"]'); await expect(page.locator('.retry-button')).toBeVisible(); // Switch to success - same test, same page await switchScenario(page, 'payment-success'); await page.click('.retry-button'); await expect(page.locator('.success')).toBeVisible(); }); ``` * WireMock ```typescript // Changing scenarios requires API calls or restart test('retry after failure', async ({ page }) => { // Set up failure scenario await fetch('http://localhost:8080/__admin/mappings', { method: 'POST', body: JSON.stringify({ request: { method: 'POST', url: '/v1/charges' }, response: { status: 504, body: 'Gateway Timeout' } }) }); await page.goto('/checkout'); await page.click('[data-testid="submit"]'); await expect(page.locator('.retry-button')).toBeVisible(); // Delete and recreate mapping for success await fetch('http://localhost:8080/__admin/mappings/reset', { method: 'POST' }); await fetch('http://localhost:8080/__admin/mappings', { method: 'POST', body: JSON.stringify({ request: { method: 'POST', url: '/v1/charges' }, response: { status: 200, jsonBody: { id: 'ch_123' } } }) }); await page.click('.retry-button'); }); ``` **Trade-off:** Both tools support runtime switching. Scenarist uses a single API call that automatically routes by test ID. WireMock’s Admin API is powerful but requires managing stub state and can be more complex in parallel test environments where you need to coordinate which test sees which stubs. ### First-Class Playwright Integration Scenarist provides dedicated [Playwright fixtures](/testing/playwright-integration/) that handle test ID generation, scenario switching, and header propagation automatically. * Scenarist ```typescript // tests/fixtures.ts - One-time setup import { withScenarios, expect } from '@scenarist/playwright-helpers'; import { scenarios } from '../lib/scenarios'; export const test = withScenarios(scenarios); export { expect }; // tests/checkout.spec.ts - Clean, type-safe tests import { test, expect } from './fixtures'; test('premium user checkout', async ({ page, switchScenario }) => { // Type-safe scenario ID with autocomplete await switchScenario(page, 'premium-user'); await page.goto('/checkout'); await expect(page.locator('.premium-discount')).toBeVisible(); }); test('payment declined', async ({ page, switchScenario }) => { await switchScenario(page, 'payment-declined'); await page.goto('/checkout'); await page.click('[data-testid="submit"]'); await expect(page.locator('.error-message')).toContainText('declined'); }); ``` * WireMock ```typescript // tests/checkout.spec.ts - Manual setup in each test import { test, expect } from '@playwright/test'; test('premium user checkout', async ({ page }) => { // Generate unique ID manually const testId = `test-${Date.now()}-${Math.random()}`; // Configure WireMock via HTTP await fetch('http://localhost:8080/__admin/mappings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ request: { method: 'GET', url: '/api/user' }, response: { status: 200, jsonBody: { tier: 'premium' } } }) }); // Set headers manually await page.setExtraHTTPHeaders({ 'x-test-id': testId }); await page.goto('/checkout'); await expect(page.locator('.premium-discount')).toBeVisible(); // Clean up for parallel safety await fetch('http://localhost:8080/__admin/mappings/reset', { method: 'POST' }); }); ``` **Trade-off:** Scenarist’s [Playwright helpers](/testing/playwright-integration/) provide automatic test ID isolation—each test gets its own scenario state without manual header management. WireMock requires manual HTTP calls and careful state management. Future Scenarist releases plan to add similar first-class support for Cypress. ### Dynamic Response Features Both Scenarist and WireMock support dynamic responses, but with different approaches and trade-offs. #### Response Sequences (Polling, State Machines) * Scenarist ```typescript // Built-in sequence support { method: 'GET', url: 'https://api.example.com/job/:id/status', sequence: { responses: [ { status: 200, body: { status: 'pending' } }, { status: 200, body: { status: 'processing' } }, { status: 200, body: { status: 'complete', result: 'success' } } ], repeat: 'last' // Stay at final response } } // Perfect for testing polling UIs test('shows job progress', async ({ page, switchScenario }) => { await switchScenario(page, 'job-processing'); await page.goto('/jobs/123'); await expect(page.locator('.status')).toContainText('pending'); await page.click('[data-testid="refresh"]'); await expect(page.locator('.status')).toContainText('processing'); await page.click('[data-testid="refresh"]'); await expect(page.locator('.status')).toContainText('complete'); }); ``` * WireMock ```java // WireMock's built-in Scenarios feature (state machine) // State 1: Initial stubFor(get(urlEqualTo("/job/123/status")) .inScenario("Job Progress") .whenScenarioStateIs(STARTED) .willReturn(aResponse() .withBody("{\"status\": \"pending\"}") ) .willSetStateTo("processing")); // State 2: Processing stubFor(get(urlEqualTo("/job/123/status")) .inScenario("Job Progress") .whenScenarioStateIs("processing") .willReturn(aResponse() .withBody("{\"status\": \"processing\"}") ) .willSetStateTo("complete")); // State 3: Complete stubFor(get(urlEqualTo("/job/123/status")) .inScenario("Job Progress") .whenScenarioStateIs("complete") .willReturn(aResponse() .withBody("{\"status\": \"complete\", \"result\": \"success\"}") )); ``` **Trade-off:** Both tools have built-in sequence/state machine support. Scenarist’s approach is declarative and isolated per test ID. WireMock’s Scenarios are powerful state machines but the state is global to the WireMock instance—parallel tests need separate instances or careful coordination. #### Advanced Request Matching * Scenarist ```typescript // Declarative matching with body, headers, query, regex { method: 'POST', url: 'https://api.stripe.com/v1/charges', match: { body: { amount: '5000', currency: 'usd', orderId: /^order-\d+$/ // Regex support (top-level body fields) }, headers: { 'idempotency-key': /.+/ // Must be present } }, response: { status: 200, body: { id: 'ch_premium_123' } } } // Specificity-based selection - most specific match wins // No need to carefully order your mocks ``` * WireMock ```json { "request": { "method": "POST", "url": "/v1/charges", "bodyPatterns": [ { "matchesJsonPath": "$.amount" }, { "matchesJsonPath": "$.currency" }, { "matchesJsonPath": "$.metadata.orderId", "matches": "^order-\\d+$" } ], "headers": { "idempotency-key": { "matches": ".+" } } }, "response": { "status": 200, "jsonBody": { "id": "ch_premium_123" } } } ``` #### Stateful Mocks (Capture & Inject) * Scenarist ```typescript // Capture state from requests, inject into responses const scenarios = { 'cart-flow': { id: 'cart-flow', name: 'Cart flow', description: 'Cart accumulates items', mocks: [ // Capture item when added to cart { method: 'POST', url: '/api/cart/items', captureState: { 'cartItems[]': 'body.item' // Append to array }, response: { status: 201, body: { success: true } } }, // Inject captured state into response { method: 'GET', url: '/api/cart', response: { status: 200, body: { items: '{{state.cartItems}}', count: '{{state.cartItems.length}}' } } } ] } }; // Test multi-step flows with stateful behavior test('cart accumulates items', async ({ page, switchScenario }) => { await switchScenario(page, 'cart-flow'); await page.goto('/products'); await page.click('[data-product="widget"]'); await page.click('[data-product="gadget"]'); await page.goto('/cart'); await expect(page.locator('.cart-count')).toContainText('2'); }); ``` * WireMock ```java // WireMock Scenarios track state transitions (which state you're in) // but don't capture/inject arbitrary data from requests // Built-in: State machine transitions stubFor(post("/api/cart/items") .inScenario("Cart") .whenScenarioStateIs(STARTED) .willReturn(aResponse().withStatus(201)) .willSetStateTo("has-items")); stubFor(get("/api/cart") .inScenario("Cart") .whenScenarioStateIs("has-items") .willReturn(aResponse() .withBody("{\"items\": [...]}"))); // Static response // For data capture/injection: Custom extension or Handlebars templating // Handlebars can access request data: // {{request.body}} or {{jsonPath request.body '$.item'}} ``` **Trade-off:** WireMock’s Scenarios handle state transitions (tracking which state you’re in). Scenarist’s state capture goes further—capturing arbitrary data from requests and injecting it into subsequent responses—with per-test-ID isolation for parallel tests. WireMock can achieve data capture via Handlebars templating but state is global to the instance. ## When to Choose Scenarist Simple setup Just npm install and configure scenarios. No separate server process or network configuration required. TypeScript-first development Scenarios are TypeScript objects with full type safety. IDE autocomplete, compile-time errors, refactoring support. Parallel test isolation Built-in test ID system lets hundreds of tests run simultaneously with different scenarios—no separate instances needed. First-class Playwright support Dedicated fixtures with automatic test ID handling. Each test gets its own scenario state with type-safe switching. Per-test-ID state isolation Response sequences and stateful mocks are isolated per test ID—parallel tests never share state. Next.js multi-process handling Next.js has [documented singleton issues](https://github.com/vercel/next.js/discussions/68572) that break MSW. Scenarist’s adapter includes built-in `globalThis` guards—one stable MSW instance regardless of how Next.js loads modules. ## When to Choose WireMock Polyglot environment Your team uses multiple languages (Java, Python, .NET). WireMock works with any HTTP client. Record and playback Need to capture real API interactions and replay them. WireMock’s recording is battle-tested. Existing WireMock investment Team already knows WireMock, has existing stub libraries, or uses WireMock Cloud. Contract testing Using WireMock for contract testing with Spring Cloud Contract or similar frameworks. ## Migration Considerations ### From WireMock to Scenarist If you’re considering migrating from WireMock: 1. **Stub definitions translate directly** — WireMock JSON mappings map cleanly to Scenarist scenarios 2. **Test isolation improves** — No more managing multiple WireMock instances for parallel tests 3. **Recording doesn’t migrate** — You’ll lose record/playback capabilities ```typescript // WireMock JSON { "request": { "method": "POST", "url": "/v1/charges", "bodyPatterns": [{ "matchesJsonPath": "$.amount" }] }, "response": { "status": 200, "jsonBody": { "id": "ch_123", "status": "succeeded" } } } // Equivalent Scenarist scenario { method: 'POST', url: 'https://api.stripe.com/v1/charges', match: { body: { amount: /.*/ } }, response: { status: 200, body: { id: 'ch_123', status: 'succeeded' } } } ``` ### Using Both Together You can use WireMock and Scenarist together: * **WireMock** for services your team doesn’t own (microservices from other teams) * **Scenarist** for third-party APIs you need to test scenarios for (Stripe, Auth0) Point your app at WireMock for internal services while Scenarist mocks external APIs. ## Summary | Factor | Scenarist | WireMock | | ----------------------- | ---------------------------------------------------------------------------------- | ------------------------------- | | Setup simplicity | ✓ npm install | JAR/Docker/npm + network config | | TypeScript integration | ✓ Native | Via wiremock-captain client | | Parallel test isolation | ✓ Header-based | Multiple instances | | Runtime switching | ✓ Single API call | Admin API | | Playwright integration | ✓ First-class fixtures | Manual HTTP calls | | Response sequences | ✓ Built-in | ✓ Built-in (Scenarios) | | Stateful mocks | ✓ Per-test-ID isolation | ✓ Global state machine | | Next.js multi-process | ✓ [Built-in singleton guards](https://github.com/vercel/next.js/discussions/68572) | Manual workarounds needed | | Language support | JavaScript/TypeScript | ✓ Any language | | Record/playback | Not supported | ✓ Built-in | | Ecosystem maturity | Newer | ✓ Battle-tested | | Contract testing | Not supported | ✓ Spring Cloud Contract | **Bottom line:** For Node.js projects, Scenarist keeps you in a single ecosystem—TypeScript scenarios, npm dependencies, Playwright fixtures, no context-switching to Java or Docker. Choose Scenarist when you value in-process simplicity, header-based parallel test isolation, and first-class Playwright support. Choose WireMock when you’re in a polyglot environment, need recording, contract testing, or when your team already has WireMock expertise. Both tools are capable—the choice is primarily about staying in your ecosystem vs. language flexibility. # Scenarist + MSW > How Scenarist builds on Mock Service Worker and when to use each Scenarist is built on top of [Mock Service Worker (MSW)](https://mswjs.io/)—we don’t compete with MSW, we extend it. Understanding this relationship helps you decide when to use MSW directly vs when Scenarist’s scenario layer adds value. ## What Scenarist Offers Before diving into comparisons, here's what Scenarist brings to the table: * **[Simple Architecture](/concepts/architecture/)** — Just an HTTP header (`x-scenarist-test-id`). No Docker, no separate processes, no complex network configuration * **[Test ID Isolation](/testing/parallel-testing/)** — Run hundreds of parallel tests with different scenarios against one server. Each test's header routes to its own scenario * **Runtime Switching** — Change scenarios mid-test without restarts (retry flows, error recovery) * **[First-Class Playwright](/testing/playwright-integration/)** — Dedicated fixtures with type-safe scenarios and automatic test ID handling * **[Response Sequences](/scenarios/response-sequences/)** — Built-in polling, retry flows, state machines * **[Stateful Mocks](/scenarios/stateful-mocks/)** — Capture request values, inject into responses. State is isolated per test ID, so parallel tests never conflict * **[Advanced Matching](/scenarios/request-matching/)** — Body, headers, query params, regex with specificity-based selection * **[Framework Adapters](/getting-started/why-scenarist/#nextjs-multi-process-handling)** — Not thin wrappers—they solve real problems. For example, the Next.js adapter includes built-in singleton protection for the [module duplication issue](https://github.com/vercel/next.js/discussions/68572) that breaks MSW * **Developer Tools ([Roadmap](/roadmap/))** — Planned browser-based plugin for switching scenarios during development and debugging—making scenario exploration instant and visual ## The Relationship ```plaintext ┌─────────────────────────────────────────────┐ │ Your Test Suite │ │ ┌───────────────────────────────────────┐ │ │ │ Scenarist │ │ │ │ • Scenario management │ │ │ │ • Test ID isolation │ │ │ │ • Runtime switching │ │ │ │ • Framework adapters │ │ │ │ ┌─────────────────────────────────┐ │ │ │ │ │ MSW │ │ │ │ │ │ • Request interception │ │ │ │ │ │ • Network-level mocking │ │ │ │ │ │ • Handler matching │ │ │ │ │ └─────────────────────────────────┘ │ │ │ └───────────────────────────────────────┘ │ └─────────────────────────────────────────────┘ ``` **MSW provides the foundation:** * Intercepts HTTP/HTTPS requests at the network level * Works with any HTTP client (fetch, axios, got, etc.) * Proven, battle-tested, actively maintained **Scenarist adds the testing layer:** * Scenario management (group mocks by test case, switch at runtime) * Complete test ID isolation (scenarios, sequences, and captured state—all per test ID) * Declarative scenario definitions (inspectable, composable, type-safe) * Framework adapters (Express, Next.js integration out of the box) * [First-class Playwright fixtures](/testing/playwright-integration/) (automatic test ID handling) *Note: MSW v2.2.0+ added `server.boundary()` for isolating handler registrations in concurrent tests. Scenarist’s test ID system provides broader isolation—including sequence positions and captured state—and works with any test framework.* ## When to Use MSW Directly Storybook/Development Mocking APIs during development or Storybook stories. No test isolation needed. Single-Test Mocking Simple tests where each test defines its own handlers inline. No scenario sharing. API Design/Prototyping Building frontend before backend exists. Interactive development, not testing. Maximum Control Need low-level MSW features like request timing, custom resolvers, or WebSocket mocking. (Scenarist focuses on HTTP request/response patterns.) ### Example: MSW for Development ```typescript // src/mocks/handlers.ts - MSW for development import { http, HttpResponse } from 'msw'; export const handlers = [ http.get('/api/user', () => { return HttpResponse.json({ id: 'user-123', name: 'Development User', email: 'dev@example.com' }); }), http.post('/api/login', async ({ request }) => { const body = await request.json(); if (body.email === 'test@example.com') { return HttpResponse.json({ token: 'mock-jwt-token' }); } return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 }); }) ]; // Use in browser with setupWorker for Storybook // Use in Node with setupServer for development server ``` **This is a great use of MSW directly.** No test isolation needed, no scenario switching—just consistent mock data for development. ## When to Use Scenarist Parallel Testing Multiple tests running simultaneously need different API states. Test ID isolation is essential. Scenario Libraries Reusable scenarios across test suites. “payment-success”, “payment-declined”, “auth-timeout” used everywhere. Runtime Switching Tests that change scenarios mid-execution. Retry flows, state transitions, multi-step user journeys. Framework Integration Testing Next.js Server Components, Express middleware, or other server-side code with scenarios. ### Example: Scenarist for Parallel Tests ```typescript // scenarios/payment.ts - Scenarist for testing const scenarios = { default: { id: 'default', name: 'Default', description: 'Baseline responses', mocks: [{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { id: 'ch_123' } } }] }, 'payment-success': { id: 'payment-success', name: 'Payment success', description: 'Charge succeeds', mocks: [{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { id: 'ch_456', status: 'succeeded' } } }] }, 'payment-declined': { id: 'payment-declined', name: 'Payment declined', description: 'Card declined', mocks: [{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 402, body: { error: { code: 'card_declined' } } } }] }, 'payment-timeout': { id: 'payment-timeout', name: 'Payment timeout', description: 'Gateway times out', mocks: [{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { status: 504, delay: 30000 } }] } } as const satisfies ScenaristScenarios; // payment.spec.ts - Tests run in parallel test('shows success message', async ({ page, switchScenario }) => { await switchScenario(page, 'payment-success'); // Test isolation via x-scenarist-test-id header }); test('shows decline message', async ({ page, switchScenario }) => { await switchScenario(page, 'payment-declined'); // Different scenario, same time, same server }); test('shows timeout message', async ({ page, switchScenario }) => { await switchScenario(page, 'payment-timeout'); // Third scenario, all running in parallel }); ``` **This is where Scenarist shines.** Multiple tests, different scenarios, running simultaneously against one server instance. ## The Scenario Management Problem MSW is excellent at intercepting requests. But as your test suite grows, you face challenges: ### Challenge 1: Parallel Test Isolation MSW v2.2.0+ introduced `server.boundary()` to help with parallel test isolation by scoping handler registrations. However, Scenarist takes a different approach with broader isolation. * MSW server.boundary() ```typescript // MSW v2.2.0+ - server.boundary() isolates handler registrations import { server } from './mocks/server'; test.concurrent('Test A', server.boundary(async () => { server.use(http.post('/api/payment', () => HttpResponse.json({ status: 'succeeded' }) )); // Handler only visible within this boundary })); test.concurrent('Test B', server.boundary(async () => { server.use(http.post('/api/payment', () => HttpResponse.json({ status: 'failed' }) )); // Different handler, isolated from Test A })); // ✅ Handler registration is isolated // ⚠️ Other state (sequences, captured data) is NOT isolated ``` * Scenarist ```typescript // Scenarist - complete isolation via test ID test('Test A - expects success', async ({ page, switchScenario }) => { await switchScenario(page, 'payment-success'); // x-scenarist-test-id: test-a → payment-success scenario // Sequences, state capture, everything isolated }); test('Test B - expects failure', async ({ page, switchScenario }) => { await switchScenario(page, 'payment-declined'); // x-scenarist-test-id: test-b → payment-declined scenario // Complete isolation including sequences and captured state }); ``` **Key difference:** `server.boundary()` isolates handler *registrations* (which `server.use()` calls are visible). Scenarist isolates *everything*—the active scenario, sequence positions, and captured state—all keyed by test ID. ### Challenge 2: Scenario Reuse * MSW Only payment.test.ts ```typescript // Problem: Duplicating handler setup across tests beforeEach(() => { server.use( http.post('/api/payment', () => HttpResponse.json({ status: 'succeeded' })), http.get('/api/user', () => HttpResponse.json({ tier: 'premium' })) ); }); // checkout.test.ts - same setup duplicated beforeEach(() => { server.use( http.post('/api/payment', () => HttpResponse.json({ status: 'succeeded' })), http.get('/api/user', () => HttpResponse.json({ tier: 'premium' })) ); }); // What if payment API response format changes? // Update everywhere! ``` * With Scenarist scenarios/index.ts ```typescript // Solution: Centralized scenario definitions export const scenarios = { 'premium-checkout': { id: 'premium-checkout', name: 'Premium checkout', description: 'Premium user pays successfully', mocks: [ { method: 'POST', url: '/api/payment', response: { status: 200, body: { status: 'succeeded' } } }, { method: 'GET', url: '/api/user', response: { status: 200, body: { tier: 'premium' } } } ] } } as const satisfies ScenaristScenarios; // All tests reference the same scenario // payment.test.ts await switchScenario(page, 'premium-checkout'); // checkout.test.ts await switchScenario(page, 'premium-checkout'); // Change in one place, tests stay green ``` ### Challenge 3: Runtime Switching * MSW Only ```typescript // Problem: Changing handlers mid-test test('retry after failure', async () => { // Set up failure server.use(http.post('/api/payment', () => HttpResponse.error())); await page.click('#submit'); await expect(page.locator('.retry')).toBeVisible(); // Need to change to success... server.resetHandlers(); // Affects other parallel tests! server.use(http.post('/api/payment', () => HttpResponse.json({ status: 'ok' }))); await page.click('.retry'); }); ``` * With Scenarist ```typescript // Solution: Runtime switching per test ID test('retry after failure', async ({ page, switchScenario }) => { await switchScenario(page, 'payment-timeout'); await page.click('#submit'); await expect(page.locator('.retry')).toBeVisible(); // Switch scenario for THIS test only await switchScenario(page, 'payment-success'); await page.click('.retry'); await expect(page.locator('.success')).toBeVisible(); }); ``` ## Using Both Together You can use MSW directly for some use cases while using Scenarist for testing: ```typescript // src/mocks/browser.ts - MSW for Storybook import { setupWorker } from 'msw/browser'; import { handlers } from './handlers'; export const worker = setupWorker(...handlers); // scenarios/index.ts - Scenarist for tests export const scenarios = { default: { mocks: [...] }, 'edge-case': { mocks: [...] } } as const satisfies ScenaristScenarios; ``` **They don’t conflict.** MSW handles development/Storybook. Scenarist handles your test suite with scenario management. ## Summary | Use Case | MSW Directly | Scenarist | | --------------------------------------------------- | ------------------- | ----------- | | Development mocking | ✓ | | | Storybook | ✓ | | | API prototyping | ✓ | | | Simple single-test mocks | ✓ | ✓ | | Handler isolation (concurrent tests) | ✓ (server.boundary) | ✓ (test ID) | | Complete state isolation (sequences, captured data) | | ✓ | | Scenario libraries | | ✓ | | Runtime scenario switching | | ✓ | | Framework adapters | | ✓ | | Playwright fixtures | | ✓ | **Bottom line:** MSW is the foundation for all request interception. MSW v2.2.0+ added `server.boundary()` for handler isolation in concurrent tests. Use MSW directly for development mocking or simple test scenarios. Use Scenarist when you need scenario management—complete per-test-ID isolation (including sequences and state), runtime switching, and reusable scenario libraries with first-class Playwright support.