Simple setup
Just npm install and configure scenarios. No separate server process or network configuration required.
WireMock 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.
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| Aspect | Scenarist | WireMock |
|---|---|---|
| Architecture | In-process (MSW) | Standalone server |
| Language | TypeScript/JavaScript | Language-agnostic (Node.js via wiremock CLI or 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 |
// Mocks run in the same process as your appimport { createScenarist } from '@scenarist/express-adapter';
export const scenarist = createScenarist({ enabled: process.env.NODE_ENV === "test", scenarios: { default: { 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// 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 testTrade-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) but requires running a separate process and configuring your app to route requests to the mock server.
// Each test gets isolated scenarios via test IDtest('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 scenarioawait 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// 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 managementTrade-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.
// Switch scenarios mid-test without restarttest('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 pageawait switchScenario(page, 'payment-success');await page.click('.retry-button');await expect(page.locator('.success')).toBeVisible();});// Changing scenarios requires API calls or restarttest('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.
Scenarist provides dedicated Playwright fixtures that handle test ID generation, scenario switching, and header propagation automatically.
// tests/fixtures.ts - One-time setupimport { 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 testsimport { test, expect } from './fixtures';
test('premium user checkout', async ({ page, switchScenario }) => {// Type-safe scenario ID with autocompleteawait 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');});// tests/checkout.spec.ts - Manual setup in each testimport { 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 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.
Both Scenarist and WireMock support dynamic responses, but with different approaches and trade-offs.
// 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 UIstest('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's built-in Scenarios feature (state machine)// State 1: InitialstubFor(get(urlEqualTo("/job/123/status")) .inScenario("Job Progress") .whenScenarioStateIs(STARTED) .willReturn(aResponse() .withBody("{\"status\": \"pending\"}") ) .willSetStateTo("processing"));
// State 2: ProcessingstubFor(get(urlEqualTo("/job/123/status")) .inScenario("Job Progress") .whenScenarioStateIs("processing") .willReturn(aResponse() .withBody("{\"status\": \"processing\"}") ) .willSetStateTo("complete"));
// State 3: CompletestubFor(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.
// Declarative matching with body, headers, query, regex{ method: 'POST', url: 'https://api.stripe.com/v1/charges', match: { body: { amount: 5000, currency: 'usd', metadata: { orderId: /^order-\d+$/ } // Regex support }, 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{ "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" } }}// Capture state from requests, inject into responsesconst scenarios = { 'cart-flow': { 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 behaviortest('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 Scenarios track state transitions (which state you're in)// but don't capture/inject arbitrary data from requests
// Built-in: State machine transitionsstubFor(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.
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
that break MSW. Scenarist’s adapter includes built-in
globalThis guards—one stable MSW instance regardless of how
Next.js loads modules.
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.
If you’re considering migrating from WireMock:
// 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' } }}You can use WireMock and Scenarist together:
Point your app at WireMock for internal services while Scenarist mocks external APIs.
| 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 | 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.