Storybook/Development
Mocking APIs during development or Storybook stories. No test isolation needed.
Scenarist is built on top of Mock Service Worker (MSW)—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.
Before diving into comparisons, here's what Scenarist brings to the table:
x-scenarist-test-id). No Docker, no separate processes, no complex network configuration┌─────────────────────────────────────────────┐│ 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:
Scenarist adds the testing layer:
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.
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.)
// src/mocks/handlers.ts - MSW for developmentimport { 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 serverThis is a great use of MSW directly. No test isolation needed, no scenario switching—just consistent mock data for development.
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.
// scenarios/payment.ts - Scenarist for testingconst scenarios = { default: { mocks: [{ url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { id: 'ch_123' } } }] }, 'payment-success': { mocks: [{ url: 'https://api.stripe.com/v1/charges', response: { status: 200, body: { id: 'ch_456', status: 'succeeded' } } }] }, 'payment-declined': { mocks: [{ url: 'https://api.stripe.com/v1/charges', response: { status: 402, body: { error: { code: 'card_declined' } } } }] }, 'payment-timeout': { mocks: [{ url: 'https://api.stripe.com/v1/charges', response: { status: 504, delay: 30000 } }] }} as const satisfies ScenaristScenarios;
// payment.spec.ts - Tests run in paralleltest('shows success message', async ({ page, switchScenario }) => { await switchScenario(page, 'payment-success'); // Test isolation via x-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.
MSW is excellent at intercepting requests. But as your test suite grows, you face challenges:
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 v2.2.0+ - server.boundary() isolates handler registrationsimport { 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 - complete isolation via test IDtest('Test A - expects success', async ({ switchScenario }) => { await switchScenario(page, 'payment-success'); // x-test-id: test-a → payment-success scenario // Sequences, state capture, everything isolated});
test('Test B - expects failure', async ({ switchScenario }) => { await switchScenario(page, 'payment-declined'); // x-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.
// Problem: Duplicating handler setup across testsbeforeEach(() => { server.use( http.post('/api/payment', () => HttpResponse.json({ status: 'succeeded' })), http.get('/api/user', () => HttpResponse.json({ tier: 'premium' })) );});
// checkout.test.ts - same setup duplicatedbeforeEach(() => { 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!// Solution: Centralized scenario definitionsexport const scenarios = { 'premium-checkout': { mocks: [ { url: '/api/payment', response: { body: { status: 'succeeded' } } }, { url: '/api/user', response: { body: { tier: 'premium' } } } ] }} as const satisfies ScenaristScenarios;
// All tests reference the same scenario// payment.test.tsawait switchScenario(page, 'premium-checkout');
// checkout.test.tsawait switchScenario(page, 'premium-checkout');
// Change in one place, tests stay green// Problem: Changing handlers mid-testtest('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');});// Solution: Runtime switching per test IDtest('retry after failure', async ({ 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();});You can use MSW directly for some use cases while using Scenarist for testing:
// src/mocks/browser.ts - MSW for Storybookimport { setupWorker } from 'msw/browser';import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
// scenarios/index.ts - Scenarist for testsexport 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.
| 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.