Parallel Testing
Scenarist enables parallel test execution where multiple tests run simultaneously, each with their own scenario. This is achieved through test ID isolation - every test gets a unique identifier that determines which scenario’s responses it receives.
How Test Isolation Works
Section titled “How Test Isolation Works”Each test gets a unique test ID (automatically generated by the Playwright fixture). This test ID is sent with every request, allowing Scenarist to route each test to its own scenario.
// Two tests running in parallel, each with different scenariostest('premium features', async ({ page, switchScenario }) => { await switchScenario(page, 'premium'); // test-id: abc-123 → premium scenario await page.goto('/dashboard'); await expect(page.getByText('Advanced Analytics')).toBeVisible();});
test('free features', async ({ page, switchScenario }) => { await switchScenario(page, 'free'); // test-id: xyz-789 → free scenario await page.goto('/dashboard'); await expect(page.getByText('Upgrade to Premium')).toBeVisible();});// Both tests run simultaneously without interferenceComplete Request Flow
Section titled “Complete Request Flow”Here’s how two tests run in parallel with different scenarios:
The isolation mechanism:
- Each test gets a unique ID - Generated automatically by the fixture
- Test switches scenario once -
POST /__scenario__with test ID - All subsequent requests include the test ID header
- Scenarist routes by test ID - Same URL, different responses per test
- Scenario persists for the entire test journey
This enables:
- ✅ Unlimited scenarios - Premium, free, error, edge cases all in parallel
- ✅ No interference - Each test isolated by unique test ID
- ✅ One backend server - All tests share same server instance
- ✅ Fast execution - No expensive external API calls
Header Propagation (Critical for Parallel Tests)
Section titled “Header Propagation (Critical for Parallel Tests)”The Problem
Section titled “The Problem”When your server-side code makes internal fetch calls (e.g., Server Components fetching from API routes), headers don’t automatically propagate:
// ❌ BAD - Headers not propagated to internal fetchexport async function Page() { // This fetch doesn't include the test ID header! const response = await fetch('http://localhost:3001/api/products'); const data = await response.json(); return <div>{/* render */}</div>;}Without the test ID header, the internal fetch uses the default scenario instead of the test’s scenario. In parallel tests, this causes interference.
Next.js Solution: Header Propagation Helpers
Section titled “Next.js Solution: Header Propagation Helpers”For Server Components (use getScenaristHeadersFromReadonlyHeaders):
import { headers } from 'next/headers';import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Server Componentsexport default async function Page() { const headersList = await headers(); // Get ReadonlyHeaders from Next.js
const response = await fetch('https://api.stripe.com/v1/products', { headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList), // Include test ID header }, });
const data = await response.json(); return <div>{/* render */}</div>;}For Route Handlers (use getScenaristHeaders):
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Route Handlersexport async function GET(request: Request) { const response = await fetch('https://api.stripe.com/v1/products', { headers: { ...getScenaristHeaders(request), // Include test ID header }, });
const data = await response.json(); return Response.json(data);}What these helpers do:
- Extract test ID from request/headers
- Return
{ 'x-scenarist-test-id': 'generated-uuid' }object - Safe to call when Scenarist is disabled (returns empty object)
Express Solution: Manual Header Forwarding
Section titled “Express Solution: Manual Header Forwarding”Express adapter uses AsyncLocalStorage to automatically track test IDs per request. For internal fetch calls, include the header manually:
import { SCENARIST_TEST_ID_HEADER } from '@scenarist/express-adapter';
app.get('/api/dashboard', async (req, res) => { const testId = req.get(SCENARIST_TEST_ID_HEADER);
const response = await fetch('http://localhost:3001/api/user', { headers: { [SCENARIST_TEST_ID_HEADER]: testId || '', }, });
const data = await response.json(); res.json(data);});Diagnosing Parallel Test Failures
Section titled “Diagnosing Parallel Test Failures”Symptoms of Missing Header Propagation
Section titled “Symptoms of Missing Header Propagation”Tests pass individually, fail in parallel:
# Pass individuallypnpm exec playwright test --workers=1# ✅ All tests pass
# Fail in parallelpnpm exec playwright test --workers=4# ❌ Some tests fail with wrong dataWrong data appearing in tests:
// Test expects premium pricingawait expect(page.getByText('£99.99')).toBeVisible();// ❌ Error: element not found
// But sees standard pricing instead (from default scenario)await expect(page.getByText('£149.99')).toBeVisible();// ✅ This passes - wrong scenario!Flaky results:
- Sometimes premium pricing, sometimes standard
- Different results on different runs
- Race conditions between parallel tests
Debugging Steps
Section titled “Debugging Steps”-
Add logging to see which scenario is active:
console.log('Test ID:', req.get('x-scenarist-test-id'));console.log('Active scenario:', scenarioManager.getActive(testId)); -
Check server logs for test ID headers on internal fetches
-
Verify header helpers are called before every internal fetch
-
Confirm headers object includes the test ID
Fix Checklist
Section titled “Fix Checklist”When parallel tests fail:
- ✅ Next.js: Add header helpers before all internal fetch calls (use
getScenaristHeadersFromReadonlyHeadersin Server Components,getScenaristHeadersin Route Handlers) - ✅ Express: Include test ID header in internal fetch calls
- ✅ Playwright: Verify tests call
switchScenario()before navigation - ✅ Isolation: Ensure each test switches scenarios independently
- ✅ Logging: Add debug logs to confirm headers are present
Parallel Test Patterns
Section titled “Parallel Test Patterns”Pattern 1: Independent Scenario Tests
Section titled “Pattern 1: Independent Scenario Tests”Each test operates with its own scenario, no shared state:
import { test, expect } from './fixtures';
test.describe('User tiers', () => { test('premium users see analytics', async ({ page, switchScenario }) => { await switchScenario(page, 'premium'); await page.goto('/dashboard'); await expect(page.getByText('Analytics')).toBeVisible(); });
test('free users see upgrade prompt', async ({ page, switchScenario }) => { await switchScenario(page, 'free'); await page.goto('/dashboard'); await expect(page.getByText('Upgrade')).toBeVisible(); });
test('enterprise users see admin panel', async ({ page, switchScenario }) => { await switchScenario(page, 'enterprise'); await page.goto('/dashboard'); await expect(page.getByText('Admin Panel')).toBeVisible(); });});// All three tests run in parallelPattern 2: Error Scenario Matrix
Section titled “Pattern 2: Error Scenario Matrix”Test multiple error conditions in parallel:
test.describe('Payment errors', () => { test('handles card declined', async ({ page, switchScenario }) => { await switchScenario(page, 'cardDeclined'); await page.goto('/checkout'); await page.click('button[type="submit"]'); await expect(page.getByText('Card was declined')).toBeVisible(); });
test('handles insufficient funds', async ({ page, switchScenario }) => { await switchScenario(page, 'insufficientFunds'); await page.goto('/checkout'); await page.click('button[type="submit"]'); await expect(page.getByText('Insufficient funds')).toBeVisible(); });
test('handles network timeout', async ({ page, switchScenario }) => { await switchScenario(page, 'stripeTimeout'); await page.goto('/checkout'); await page.click('button[type="submit"]'); await expect(page.getByText('Please try again')).toBeVisible(); });});Pattern 3: Multi-Step Journeys
Section titled “Pattern 3: Multi-Step Journeys”Each test covers a complete user journey with its scenario:
test('complete checkout flow - premium user', async ({ page, switchScenario }) => { await switchScenario(page, 'premium');
// Step 1: Browse products await page.goto('/products'); await expect(page.getByText('Premium Discount: 20%')).toBeVisible();
// Step 2: Add to cart await page.click('button:has-text("Add to Cart")'); await expect(page.getByText('Cart: 1 item')).toBeVisible();
// Step 3: Checkout await page.goto('/checkout'); await expect(page.getByText('Total: £79.99')).toBeVisible(); // Discounted
// Step 4: Payment await page.click('button:has-text("Pay Now")'); await expect(page.getByText('Order Confirmed')).toBeVisible();});
test('complete checkout flow - standard user', async ({ page, switchScenario }) => { await switchScenario(page, 'standard');
// Same journey, different scenario - runs in parallel await page.goto('/products'); await expect(page.getByText('Premium Discount: 20%')).not.toBeVisible();
await page.click('button:has-text("Add to Cart")'); await page.goto('/checkout'); await expect(page.getByText('Total: £99.99')).toBeVisible(); // Full price
await page.click('button:has-text("Pay Now")'); await expect(page.getByText('Order Confirmed')).toBeVisible();});Playwright Configuration
Section titled “Playwright Configuration”For optimal parallel test execution:
import { defineConfig } from '@playwright/test';import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig<ScenaristOptions>({ // Run tests in parallel fullyParallel: true,
// Number of parallel workers workers: process.env.CI ? 4 : undefined, // 4 in CI, auto-detect locally
// Scenarist configuration use: { baseURL: 'http://localhost:3000', scenaristEndpoint: '/api/__scenario__', },});Next Steps
Section titled “Next Steps”- Playwright Integration - Fixture setup and configuration
- Testing Best Practices - Scenario organization patterns
- Verification Guide - Troubleshooting test issues