Testing and reference: Playwright integration, parallel testing, best practices, database apps, endpoint and error reference, logging, and verification
# Testing Best Practices
> Practical patterns for organizing scenarios, structuring tests, and avoiding common pitfalls
This guide covers practical patterns that make Scenarist tests maintainable as your test suite grows. For the underlying philosophy, see [Testing Philosophy](/concepts/philosophy/). ## Scenario Organization ### Group by Business Domain, Not by API Organize scenarios around **what users experience**, not which APIs you’re mocking:
```plaintext
lib/scenarios/
├── checkout/
│ ├── success.ts # Complete checkout flow succeeds
│ ├── payment-declined.ts # Card declined at payment step
│ └── inventory-error.ts # Item goes out of stock
├── user-tiers/
│ ├── free-user.ts # Limited features
│ ├── premium-user.ts # All features unlocked
│ └── enterprise-user.ts # Admin + team features
└── errors/
├── api-timeout.ts # External APIs are slow
├── rate-limited.ts # API returns 429
└── server-error.ts # Generic 500 responses
```
**Why this matters:** * Scenarios describe **user journeys**, not technical details * Easy to find the right scenario when writing a test * New team members understand the test intent immediately ### The Default Scenario Pattern Your `default` scenario should represent the **happy path baseline**—everything works: lib/scenarios/default.ts
```typescript
import type { ScenaristScenario, ScenaristScenarios } from '@scenarist/core';
const defaultScenario: ScenaristScenario = {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed with valid responses',
mocks: [
// Stripe: Payment succeeds
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_default', status: 'succeeded', amount: 5000 },
},
},
// Auth0: Standard authenticated user
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_default', email: 'user@example.com', tier: 'standard' },
},
},
// SendGrid: Email sent successfully
{
method: 'POST',
url: 'https://api.sendgrid.com/v3/mail/send',
response: { status: 202, body: { message_id: 'msg_default' } },
},
],
};
export const scenarios = {
default: defaultScenario,
// Other scenarios inherit from default automatically
} as const satisfies ScenaristScenarios;
```
**Key insight:** Specialized scenarios only need to define **what changes**. Unmocked endpoints automatically fall back to the default scenario. ### Minimal Override Scenarios Don’t duplicate mocks. Override only what changes:
```typescript
// ✅ GOOD - Override only Auth0, others fall back to default
const premiumUserScenario: ScenaristScenario = {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user with all features',
mocks: [
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_premium', email: 'premium@example.com', tier: 'premium' },
},
},
// Stripe and SendGrid automatically use default scenario
],
};
// ❌ BAD - Duplicates all mocks even when unchanged
const premiumUserScenarioBad: ScenaristScenario = {
id: 'premiumUser',
mocks: [
{ method: 'GET', url: 'https://api.auth0.com/userinfo', response: { /* ... */ } },
{ method: 'POST', url: 'https://api.stripe.com/v1/charges', response: { /* same as default */ } },
{ method: 'POST', url: 'https://api.sendgrid.com/v3/mail/send', response: { /* same as default */ } },
],
};
```
**Benefits of minimal overrides:** * Less code to maintain * Clear about what the scenario actually tests * Default scenario changes propagate automatically ## Feature Selection Guide ### When to Use Request Matching Use **match criteria** when response depends on request content:
```typescript
// Different prices based on user tier header
{
method: 'GET',
url: '/api/pricing',
match: { headers: { 'x-user-tier': 'premium' } },
response: { status: 200, body: { price: 79.99 } },
}
// Filter products by query parameter
{
method: 'GET',
url: '/api/products',
match: { query: { category: 'electronics' } },
response: { status: 200, body: { products: [/* electronics */] } },
}
```
### When to Use Sequences Use **sequences** for APIs that return different responses on subsequent calls:
```typescript
// Polling API: pending → processing → complete
{
method: 'GET',
url: '/api/job/:id/status',
sequence: {
responses: [
{ status: 200, body: { status: 'pending' } },
{ status: 200, body: { status: 'processing' } },
{ status: 200, body: { status: 'complete', result: { /* ... */ } } },
],
repeat: 'last', // Stay at "complete" for all subsequent calls
},
}
```
**Common sequence patterns:** | Pattern | `repeat` mode | Use case | | ------------------ | ------------- | ----------------------------------- | | Progress indicator | `'last'` | Show final state forever | | Retry logic | `'cycle'` | Keep failing then succeeding | | Rate limiting | `'none'` | First N requests succeed, then fail | ### When to Use Stateful Mocks Use **state capture** when responses depend on previous requests. State is isolated per test ID, so parallel tests each maintain their own state without conflicts:
```typescript
// Capture item added to cart
{
method: 'POST',
url: '/api/cart/items',
captureState: { 'cartItems[]': 'body.item' },
response: { status: 200, body: { success: true } },
}
// Return captured items in cart
{
method: 'GET',
url: '/api/cart',
response: {
status: 200,
body: {
items: '{{state.cartItems}}',
itemCount: '{{state.cartItems.length}}',
},
},
}
```
State Resets on Scenario Switch All captured state is cleared when switching scenarios. This ensures tests are isolated—each test starts fresh. ## Test Structure Patterns ### Factory Functions for Test Data Use factory functions instead of `let` and `beforeEach`: * ✅ Good - Factory Function tests/fixtures.ts
```typescript
const createTestContext = () => ({
testId: `test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
baseURL: 'http://localhost:3000',
});
// tests/checkout.spec.ts
test('processes payment successfully', async ({ page, switchScenario }) => {
const ctx = createTestContext();
await switchScenario(page, 'default');
await page.goto(`${ctx.baseURL}/checkout`);
// ... test code
});
```
* ❌ Bad - Shared State
```typescript
// Shared mutable state leads to test pollution
let testId: string;
beforeEach(() => {
testId = `test-${Date.now()}`;
});
test('processes payment', async ({ page }) => {
// testId could be modified by parallel tests
});
```
### One Scenario Switch Per Test Switch scenarios at the beginning of each test, not multiple times:
```typescript
// ✅ GOOD - Single scenario for entire test journey
test('premium checkout flow', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await page.click('[data-testid="add-to-cart"]');
await page.goto('/checkout');
await page.click('[data-testid="submit-payment"]');
await expect(page.getByText('Order Confirmed')).toBeVisible();
});
// ❌ AVOID - Multiple scenario switches mid-test
test('confusing flow', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/products');
await switchScenario(page, 'paymentError'); // Why switch here?
await page.goto('/checkout');
// What is this test actually verifying?
});
```
**Exception:** Testing scenario transitions explicitly (e.g., “user upgrades mid-session”) may warrant multiple switches, but make the intent clear. ### Descriptive Test Names Test names should describe the **user experience**, not the scenario:
```typescript
// ✅ GOOD - Describes what user sees
test('premium users see 20% discount on checkout', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
// ...
});
test('declined card shows clear error message', async ({ page, switchScenario }) => {
await switchScenario(page, 'paymentDeclined');
// ...
});
// ❌ BAD - Describes implementation
test('premiumUser scenario works', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
// ...
});
```
## Anti-Patterns to Avoid ### Over-Mocking Don’t mock every API in every scenario:
```typescript
// ❌ BAD - Mocking APIs not relevant to this test
const checkoutErrorScenario = {
id: 'checkoutError',
mocks: [
{ method: 'POST', url: '/api/charges', response: { status: 402 } }, // Relevant
{ method: 'GET', url: '/api/user', response: { /* ... */ } }, // Not needed
{ method: 'GET', url: '/api/products', response: { /* ... */ } }, // Not needed
{ method: 'POST', url: '/api/analytics', response: { /* ... */ } }, // Not needed
],
};
// ✅ GOOD - Only mock what the test cares about
const checkoutErrorScenario = {
id: 'checkoutError',
mocks: [
{ method: 'POST', url: '/api/charges', response: { status: 402 } },
// Other APIs use default scenario
],
};
```
### Testing Implementation Details Don’t verify internal behavior—verify user outcomes:
```typescript
// ❌ BAD - Testing that Stripe was called
test('calls Stripe API', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/checkout');
await page.click('button[type="submit"]');
// How would you even verify this without coupling to implementation?
});
// ✅ GOOD - Testing what user sees
test('displays success message after payment', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/checkout');
await page.click('button[type="submit"]');
await expect(page.getByText('Payment successful')).toBeVisible();
});
```
### Scenarios with Hidden Logic Avoid creating scenarios that require understanding hidden conditional logic:
```typescript
// ❌ BAD - What does this scenario do? Need to read source.
const scenario = {
id: 'complex',
mocks: [{
method: 'GET',
url: '/api/data',
match: {
headers: { 'x-flag-a': 'true' },
query: { mode: { contains: 'special' } },
body: { nested: { field: { startsWith: 'prefix' } } },
},
response: { status: 200, body: { /* ... */ } },
}],
};
// ✅ GOOD - Name explains what it tests
const scenario = {
id: 'specialModeWithFeatureFlagA',
name: 'Special Mode with Feature Flag A Enabled',
description: 'Tests the special mode behavior when feature flag A is active',
mocks: [{
method: 'GET',
url: '/api/data',
match: {
headers: { 'x-feature-flag': 'flagA' },
query: { mode: 'special' },
},
response: { status: 200, body: { /* ... */ } },
}],
};
```
## Scaling Your Test Suite ### Scenario Composition For complex tests, compose smaller scenarios:
```typescript
// Base scenarios
const authMocks = {
authenticated: { method: 'GET', url: '/api/user', response: { status: 200, body: { /* ... */ } } },
unauthenticated: { method: 'GET', url: '/api/user', response: { status: 401 } },
};
const paymentMocks = {
success: { method: 'POST', url: '/api/charges', response: { status: 200, body: { /* ... */ } } },
declined: { method: 'POST', url: '/api/charges', response: { status: 402 } },
};
// Compose into full scenarios
const scenarios = {
default: {
id: 'default',
mocks: [authMocks.authenticated, paymentMocks.success],
},
paymentDeclined: {
id: 'paymentDeclined',
mocks: [authMocks.authenticated, paymentMocks.declined],
},
unauthenticatedCheckout: {
id: 'unauthenticatedCheckout',
mocks: [authMocks.unauthenticated, paymentMocks.success],
},
} as const satisfies ScenaristScenarios;
```
### Documentation Document non-obvious scenarios:
```typescript
const rateLimitedScenario: ScenaristScenario = {
id: 'rateLimited',
name: 'API Rate Limited',
description: `
Simulates hitting API rate limits after 3 successful requests.
Use for testing retry logic and rate limit handling UI.
Sequence: 200 → 200 → 200 → 429 (repeats 429)
`,
mocks: [{
method: 'POST',
url: '/api/data',
sequence: {
responses: [
{ status: 200, body: { success: true } },
{ status: 200, body: { success: true } },
{ status: 200, body: { success: true } },
{ status: 429, body: { error: 'Rate limited', retryAfter: 60 } },
],
repeat: 'last',
},
}],
};
```
## Debugging with Logging When tests fail unexpectedly, enable Scenarist’s logging to see what’s happening:
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
}),
});
```
**Common debugging scenarios:** | Problem | What to Check | Log Category | | ---------------------- | ------------------------- | ------------------------ | | Wrong mock selected | Specificity scores | `matching` (debug level) | | Mock not matching | Match criteria evaluation | `matching` (debug level) | | State not captured | State capture events | `state` (debug level) | | Scenario not switching | Scenario switch events | `scenario` (info level) | ## Next Steps * [Logging Reference](/reference/logging/) - Full logging configuration and API * [Parallel Testing](/testing/parallel-testing/) - Run tests concurrently with isolated scenarios * [Playwright Integration](/testing/playwright-integration/) - Set up type-safe fixtures * [Testing Philosophy](/concepts/philosophy/) - Core principles behind Scenarist
# Parallel Testing
> Run concurrent tests with different backend states using test ID isolation
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 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.
```typescript
// Two tests running in parallel, each with different scenarios
test('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 interference
```
### Complete Request Flow Here’s how two tests run in parallel with different scenarios: **The isolation mechanism:** 1. **Each test gets a unique ID** - Generated automatically by the fixture 2. **Test switches scenario once** - `POST /__scenario__` with test ID 3. **All subsequent requests** include the test ID header 4. **Scenarist routes by test ID** - Same URL, different responses per test 5. **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) Most Common Parallel Test Issue When tests fail in parallel but pass sequentially, the root cause is almost always **test ID headers not being propagated** through server-side fetch calls. ### The Problem When your server-side code makes internal fetch calls (e.g., Server Components fetching from API routes), headers don’t automatically propagate:
```typescript
// ❌ BAD - Headers not propagated to internal fetch
export 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 {/* render */}
;
}
```
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 **For Server Components** (use `getScenaristHeadersFromReadonlyHeaders`):
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Server Components
export 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
},
cache: 'no-store',
});
const data = await response.json();
return {/* render */}
;
}
```
**For Route Handlers** (use `getScenaristHeaders`):
```typescript
import { getScenaristHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Route Handlers
export async function GET(request: Request) {
const response = await fetch('https://api.stripe.com/v1/products', {
headers: {
...getScenaristHeaders(request), // Include test ID header
},
cache: 'no-store',
});
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 in production builds, where no Scenarist instance exists (returns empty object) ### Express Solution: Manual Header Forwarding Express adapter uses AsyncLocalStorage to automatically track test IDs per request. For internal fetch calls, include the header manually:
```typescript
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 ### Symptoms of Missing Header Propagation **Tests pass individually, fail in parallel:**
```bash
# Pass individually
pnpm exec playwright test --workers=1
# ✅ All tests pass
# Fail in parallel
pnpm exec playwright test --workers=4
# ❌ Some tests fail with wrong data
```
**Wrong data appearing in tests:**
```typescript
// Test expects premium pricing
await 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 1. **Add logging** to see which scenario is active:
```typescript
const testId = req.get('x-scenarist-test-id') ?? 'default-test';
console.log('Test ID:', testId);
console.log('Active scenario:', scenarist.getActiveScenario(testId));
```
2. **Check server logs** for test ID headers on internal fetches 3. **Verify header helpers** are called before every internal fetch 4. **Confirm headers object** includes the test ID ### Fix Checklist When parallel tests fail: 1. ✅ **Next.js:** Add header helpers before all internal fetch calls (use `getScenaristHeadersFromReadonlyHeaders` in Server Components, `getScenaristHeaders` in Route Handlers) 2. ✅ **Express:** Include test ID header in internal fetch calls 3. ✅ **Playwright:** Verify tests call `switchScenario()` before navigation 4. ✅ **Isolation:** Ensure each test switches scenarios independently 5. ✅ **Logging:** Add debug logs to confirm headers are present ## Parallel Test Patterns ### Pattern 1: Independent Scenario Tests Each test operates with its own scenario, no shared state:
```typescript
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 parallel
```
### Pattern 2: Error Scenario Matrix Test multiple error conditions in parallel:
```typescript
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 Each test covers a complete user journey with its scenario:
```typescript
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 For optimal parallel test execution: playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig({
// 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 * [Playwright Integration](/testing/playwright-integration/) - Fixture setup and configuration * [Testing Best Practices](/testing/best-practices/) - Scenario organization patterns * [Verification Guide](/reference/verification/) - Troubleshooting test issues
# Playwright Integration
> Set up type-safe Playwright tests with Scenarist fixtures and helpers
Scenarist provides Playwright helpers that enable type-safe scenario switching with autocomplete, automatic test ID management, and clean test organization. ## Quick Start ### 1. Install Playwright Helpers
```bash
npm install --save-dev @scenarist/playwright-helpers
# or
pnpm add -D @scenarist/playwright-helpers
```
### 2. Create Fixtures File Create a `tests/fixtures.ts` file that exports a typed `test` object: tests/fixtures.ts
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios'; // Your scenario definitions
// Create type-safe test object with scenario IDs
export const test = withScenarios(scenarios);
export { expect };
```
### 3. Use in Tests **Import from fixtures:** tests/my-feature.spec.ts
```typescript
import { test, expect } from './fixtures'; // Import from your fixtures file
test('premium users see advanced features', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // Type-safe! Autocomplete works
await page.goto('/dashboard');
await expect(page.getByText('Advanced Analytics')).toBeVisible();
});
```
**Do not import directly from @playwright/test:**
```typescript
// DON'T DO THIS
import { test, expect } from '@playwright/test'; // No Scenarist fixtures!
test('my test', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser'); // Error: switchScenario doesn't exist
});
```
## Why Use Fixtures? The fixtures pattern provides several benefits: ### Type-Safe Scenario IDs TypeScript knows which scenario IDs exist and provides autocomplete:
```typescript
await switchScenario(page, 'premiumUser'); // Autocomplete suggests: 'default', 'premiumUser', 'error', etc.
await switchScenario(page, 'typo'); // TypeScript error: 'typo' not in scenarios
```
### Automatic Test ID Management Each test gets a guaranteed unique test ID to prevent state collisions during parallel execution. You don’t need to manage test IDs yourself - the fixture handles it automatically. For more details on parallel test execution and test isolation, see [Parallel Testing](/testing/parallel-testing/). ### Centralized Configuration Configure the scenario endpoint once in your fixtures file or `playwright.config.ts`: playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__', // Default, can be customized
},
});
```
The default matches the Next.js scenario routes. Express serves the endpoint at `/__scenario__`, so Express projects set `scenaristEndpoint: '/__scenario__'`. ### Clean Test Organization All tests import from the same fixtures file, ensuring consistency:
```plaintext
tests/
fixtures.ts # Single source of truth
auth.spec.ts # import { test, expect } from './fixtures';
checkout.spec.ts # import { test, expect } from './fixtures';
dashboard.spec.ts # import { test, expect } from './fixtures';
```
## Complete Example **1. Define Scenarios:** lib/scenarios.ts
```typescript
import type { ScenaristScenarios } from '@scenarist/express-adapter';
export const scenarios = {
// Default scenario with complete happy path
default: {
id: 'default',
name: 'Happy Path',
description: 'All external APIs succeed with valid responses',
mocks: [
// Stripe: Successful payment
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: { id: 'ch_123', status: 'succeeded', amount: 5000 },
},
},
// Auth0: Authenticated standard user
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_123', email: 'john@example.com', tier: 'standard' },
},
},
],
},
// Specialized scenario: Override Auth0 for premium user
premiumUser: {
id: 'premiumUser',
name: 'Premium User',
description: 'Premium tier user, everything else succeeds',
mocks: [
{
method: 'GET',
url: 'https://api.auth0.com/userinfo',
response: {
status: 200,
body: { sub: 'user_456', email: 'premium@example.com', tier: 'premium' },
},
},
],
},
// Specialized scenario: Stripe payment failure
paymentFails: {
id: 'paymentFails',
name: 'Payment Declined',
description: 'Stripe declines payment, everything else succeeds',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 402,
body: { error: { code: 'card_declined', message: 'Card was declined' } },
},
},
],
},
} as const satisfies ScenaristScenarios;
```
**2. Create Fixtures:** tests/fixtures.ts
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
export const test = withScenarios(scenarios);
export { expect };
```
**3. Write Tests:** tests/auth.spec.ts
```typescript
import { test, expect } from './fixtures';
test('premium users access advanced features', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser');
await page.goto('/dashboard');
await expect(page.getByText('Advanced Analytics')).toBeVisible();
});
test('standard users see upgrade prompt', async ({ page, switchScenario }) => {
await switchScenario(page, 'default');
await page.goto('/dashboard');
await expect(page.getByText('Upgrade to Premium')).toBeVisible();
});
```
## Composing with Custom Fixtures If you already have custom Playwright fixtures, extend the Scenarist test object: tests/fixtures.ts
```typescript
import type { Page } from '@playwright/test';
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from '../lib/scenarios';
type CustomFixtures = {
authenticatedPage: Page;
apiToken: string;
};
export const test = withScenarios(scenarios).extend({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await use(page);
},
apiToken: async ({}, use) => {
const token = await generateTestToken();
await use(token);
},
});
export { expect };
```
Then use custom fixtures in tests:
```typescript
import { test, expect } from './fixtures';
test('authenticated user sees dashboard', async ({ authenticatedPage, switchScenario }) => {
await switchScenario(authenticatedPage, 'premiumUser');
await expect(authenticatedPage.getByText('Welcome Back')).toBeVisible();
});
```
## Per-Test Configuration Overrides Override endpoint or baseURL for specific tests:
```typescript
import { test, expect } from './fixtures';
test('staging environment test', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser', {
baseURL: 'https://staging.example.com',
endpoint: '/api/custom-endpoint',
});
await page.goto('/dashboard');
// Test runs against staging with custom endpoint
});
```
## Cross-Origin API Servers When your API server runs on a different host or port than your frontend, use an **absolute URL** for `scenaristEndpoint`. This is common in architectures where: * Frontend and API are separate services on different ports * You’re testing against a staging or production API * Your test infrastructure uses a dedicated mock server ### Configuration playwright.config.ts
```typescript
import { defineConfig } from '@playwright/test';
import type { ScenaristOptions } from '@scenarist/playwright-helpers';
// Frontend: http://localhost:3000
// API Server: http://localhost:9090
export default defineConfig({
use: {
baseURL: 'http://localhost:3000', // For Playwright navigation (page.goto)
scenaristEndpoint: 'http://localhost:9090/__scenario__', // Absolute URL to API
},
});
```
### How It Works | Endpoint Type | Example | Behavior | | ------------- | ------------------------------------ | ------------------------------------------------------------------- | | Relative path | `/api/__scenario__` | Prepended with `baseURL` → `http://localhost:3000/api/__scenario__` | | Absolute URL | `http://localhost:9090/__scenario__` | Used directly (ignores `baseURL`) | ### Per-Test Cross-Origin Override You can also override for specific tests:
```typescript
import { test, expect } from './fixtures';
test('test against separate API server', async ({ page, switchScenario }) => {
await switchScenario(page, 'premiumUser', {
endpoint: 'http://api.staging.example.com/__scenario__', // Absolute URL
});
await page.goto('/dashboard');
});
```
## Debugging State When tests fail, you often need to inspect the current test state to understand what went wrong. Scenarist provides debug fixtures for this. ### `debugState(page)` Fetch the current test state from the debug endpoint:
```typescript
import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'checkout');
await page.goto('/cart');
// Add item to cart
await page.click('#add-item');
// Debug: Check what state was set
const state = await debugState(page);
console.log('After add item:', state);
// → { 'cart.items': 1, 'cart.total': 29.99 }
expect(state['cart.items']).toBe(1);
});
```
### `waitForDebugState(page, condition, options?)` Wait for state to meet a condition (useful for async workflows):
```typescript
import { test, expect } from './fixtures';
test('async approval flow', async ({ page, switchScenario, waitForDebugState }) => {
await switchScenario(page, 'approvalFlow');
await page.goto('/dashboard');
// Trigger async approval
await page.click('#submit-for-approval');
// Wait for backend state to indicate approval completed
const state = await waitForDebugState(
page,
(s) => s['approval.status'] === 'approved',
{ timeout: 10000, interval: 100 }
);
expect(state['approval.status']).toBe('approved');
});
```
**Options:** * `timeout?: number` - Maximum wait time in milliseconds (default: 5000) * `interval?: number` - Polling interval in milliseconds (default: 100) ### Configuration Configure the state endpoint in playwright.config.ts:
```typescript
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
scenaristEndpoint: '/api/__scenario__',
scenaristStateEndpoint: '/__scenarist__/state', // Default value (Express)
},
});
```
Next.js apps serve the state route under `/api/`, so set `scenaristStateEndpoint: '/api/__scenarist__/state'` there, as both Next.js example apps do. ## API Reference ### `withScenarios(scenarios)` Creates a typed Playwright test object with Scenarist fixtures. **Parameters:** * `scenarios` - Scenarios object (must satisfy `ScenaristScenarios` type) **Returns:** * Extended Playwright test object with `switchScenario` fixture **Example:**
```typescript
const test = withScenarios(scenarios);
```
### `switchScenario(page, scenarioId, options?)` Switch to a scenario for the current test. **Parameters:** * `page: Page` - Playwright Page object * `scenarioId: string` - ID of scenario to activate (type-safe based on your scenarios) * `options?: { endpoint?: string; baseURL?: string }` - Optional overrides **Returns:** * `Promise` - The test ID (for explicit `page.request` calls) **Example:**
```typescript
test('my test', async ({ page, switchScenario }) => {
const testId = await switchScenario(page, 'premiumUser');
// testId can be used for explicit page.request calls
});
```
### `expect` Re-exported from `@playwright/test` for convenience. Use the same `expect` you’re familiar with.
```typescript
import { test, expect } from './fixtures';
test('my test', async ({ page }) => {
await expect(page.getByText('Hello')).toBeVisible();
});
```
## Next Steps * [Parallel Testing](/testing/parallel-testing/) - Test isolation and concurrent test execution * [Testing Best Practices](/testing/best-practices/) - Patterns for organizing tests and scenarios * [Writing Scenarios](/scenarios/overview/) - Learn how to define scenarios * [Endpoint APIs](/reference/api-endpoints/) - Complete endpoint reference including debug state
# Testing Apps with Database Access
> How to test applications that use direct database access alongside Scenarist
## The Pattern: Shared Identity for Parallel Test Isolation When testing applications that access multiple data sources (HTTP APIs + databases), the key insight is simple: **Use a single identifier to partition ALL data sources.**
```plaintext
┌─────────────────────────────────────────────────────────┐
│ Test Runner │
├─────────────────────────────────────────────────────────┤
│ Test A (x-scenarist-test-id: abc-123) │
│ │ │
│ ├─→ HTTP API ──→ Scenarist ──→ Mocks for "abc-123" │
│ │ │
│ └─→ Database ──→ Your Code ──→ Data for "abc-123" │
├─────────────────────────────────────────────────────────┤
│ Test B (x-scenarist-test-id: xyz-789) │
│ │ │
│ ├─→ HTTP API ──→ Scenarist ──→ Mocks for "xyz-789" │
│ │ │
│ └─→ Database ──→ Your Code ──→ Data for "xyz-789" │
└─────────────────────────────────────────────────────────┘
Both tests run in PARALLEL with completely isolated state.
```
This is the same pattern used in distributed systems for request tracing, multi-tenancy, and session management. The test ID is just another form of correlation ID. *** ## What Scenarist Provides vs. What You Implement | Responsibility | Tool | What It Does | | ------------------------ | ------------- | ------------------------------------------------------ | | HTTP API mocking | **Scenarist** | Intercepts fetch calls, returns mocks based on test ID | | Database state isolation | **Your code** | Partitions database queries/data based on test ID | Scenarist scripts * **Third-party HTTP APIs**: Stripe, Auth0, SendGrid * **Other services your server calls** over HTTP * **Any outgoing HTTP request** from your server code Scenarist leaves alone * **Database queries**: Prisma, Drizzle, raw SQL * **Your own API routes and Server Actions**: they’re your code, so let them run for real * **File system operations**: `fs.readFile` and similar *** ## Implementing Database Isolation There are multiple valid approaches to partition database state by test ID. Each has different trade-offs: ### Approach A: Repository Pattern with In-Memory Store Abstract database access behind interfaces, inject test implementations that partition data by test ID. **Best for:** Fast parallel tests, clean architecture, any ORM **Trade-offs:** Requires refactoring database access; doesn’t test real SQL [Detailed Guide →](./repository-pattern) ### Approach B: Test Fixtures with Direct Seeding Seed database directly in test setup, using test ID to isolate data.
```typescript
test('premium user sees discounts', async ({ page }) => {
const testId = generateTestId();
// Seed directly (if tests have DB access)
await db.users.create({
testId,
tier: 'premium',
// ...
});
// Or seed via HTTP endpoint
await page.request.post('/test/seed', {
headers: { 'x-scenarist-test-id': testId },
data: { scenarioId: 'premiumUser' }
});
await page.goto('/products');
// ...
});
```
**Best for:** When tests have direct database access (e.g., same process) **Trade-offs:** Tests coupled to database schema; requires cleanup logic ### Approach C: Row-Level Security or Test ID Columns Add `test_id` column to tables, filter all queries automatically.
```sql
-- PostgreSQL RLS policy
CREATE POLICY test_isolation ON users
USING (test_id = current_setting('app.test_id'));
```
**Best for:** When you can’t change application code **Trade-offs:** Schema changes in production; RLS overhead ### Approach D: Database Snapshots/Migrations Pre-seed database with known state, reset between tests. **Best for:** Testing specific SQL behavior; integration tests **Trade-offs:** Slower; harder to parallelize *** ## Our Recommendation: Repository Pattern For teams that want **scalable parallel testing** of real server-side code, we recommend the repository pattern because: 1. **Same isolation model as Scenarist** — Test ID partitions both HTTP and database 2. **Fast execution** — In-memory stores are orders of magnitude faster than real databases 3. **Clean architecture** — Benefits extend beyond testing (infrastructure flexibility, SOLID principles) 4. **ORM agnostic** — Works with Prisma, Drizzle, TypeORM, raw SQL [Full Repository Pattern Guide →](./repository-pattern) The Pattern vs. The Implementation The **pattern** is “use test ID to partition database state.” The **repository pattern** is one way to implement that pattern. It’s the approach we show in our examples because it provides the cleanest architecture, but it’s not the only way. If your constraints don’t allow refactoring to repositories, consider the other approaches above. *** ## Why Database Testing Is Different Scenarist intercepts HTTP requests via MSW because every HTTP library eventually calls the same underlying APIs. **Databases have no equivalent universal interception point.** Each ORM and driver is a different API surface: * Prisma: `prisma.user.findMany()` * Drizzle: `db.select().from(users)` * TypeORM: `userRepository.find()` * Raw SQL: `pg.query('SELECT * FROM users')`
```typescript
// ❌ Scenarist CANNOT mock this - no HTTP request
export async function fetchProducts() {
return await db.products.findMany(); // Direct database call
}
```
That’s why **you implement** database isolation, using whatever approach fits your architecture. The examples show one way; adapt it to your needs. *** ## Quick Decision Guide | Your Situation | Approach | | ------------------------------------------------- | --------------------------------------------------------------------------- | | App only calls external HTTP APIs (no database) | Scenarist alone; no database strategy needed | | Want scalable parallel tests + clean architecture | [Repository Pattern](./repository-pattern) | | Tests have direct database access | Test fixtures with direct seeding | | Can’t change application code | Row-level security or test ID columns | | Need to test actual SQL queries, constraints | [Testcontainers](./testcontainers-hybrid) | | Small test suite, prototyping | [Sequential Execution](./parallelism-options#option-1-sequential-execution) | *** ## Next Steps * [Repository Pattern Guide](./repository-pattern) — Full implementation walkthrough * [Testcontainers Hybrid](./testcontainers-hybrid) — Testing real database behavior * [All Parallelism Options](./parallelism-options) — Detailed comparison of approaches
# Database Testing Parallelism Options
> Compare strategies for parallel database testing—trade-offs between code changes, schema changes, complexity, and speed
## The Core Problem Databases have no equivalent to HTTP’s test ID header. Without isolation, parallel tests corrupt each other’s data. [We recommend the repository pattern](/guides/testing-database-apps/) for most teams. This page compares all options. ## Options Comparison | Approach | Parallelism | No Code Changes | No Schema Changes | ORM Agnostic | Complexity | | ------------------------------ | ----------- | --------------- | ----------------- | ------------ | ---------- | | Sequential execution | ❌ | ✅ | ✅ | ✅ | Low | | ⭐ Repository pattern | ✅ | ❌ | ✅ | ✅ | Medium | | Sharding (container per shard) | ✅ | ✅ | ✅ | ✅ | Medium | | PostgreSQL RLS | ✅ | ✅ | ❌ | ❌ | High | | Schema per test | ✅ | ✅ | ✅ | ✅ | Very High | **No option gives you everything.** Choose based on your priorities. ## Option 1: Sequential Execution **Accept the limitation.** Run database tests sequentially, HTTP tests in parallel. playwright.config.ts
```typescript
export default defineConfig({
projects: [
{
name: 'database-tests',
testMatch: '**/db-*.spec.ts',
fullyParallel: false, // Sequential
},
{
name: 'api-tests',
testMatch: '**/api-*.spec.ts',
fullyParallel: true, // Parallel (Scenarist handles isolation)
},
],
});
```
**Best for:** * Quick prototypes where test speed doesn’t matter yet * Teams who know their test suite will remain small * Temporary solution while planning migration to repository pattern **Trade-offs:** * ✅ No code changes * ✅ No schema changes * ✅ Works with any ORM * ❌ Doesn’t scale—CI time grows linearly with test count * ❌ Limits your ability to run comprehensive test suites ## Option 2: Repository Pattern (Recommended) Abstract database access behind interfaces and inject test implementations.
```typescript
// Define repository interface
interface UserRepository {
findById(id: string): Promise;
create(user: User): Promise;
findByEmail(email: string): Promise;
}
// Production implementation
class PrismaUserRepository implements UserRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
async create(user: User) {
return this.prisma.user.create({ data: user });
}
async findByEmail(email: string) {
return this.prisma.user.findFirst({ where: { email } });
}
}
// Test implementation with test ID isolation
class InMemoryUserRepository implements UserRepository {
private store = new Map>();
constructor(private getTestId: () => string) {}
private getTestStore() {
const testId = this.getTestId();
if (!this.store.has(testId)) {
this.store.set(testId, new Map());
}
return this.store.get(testId)!;
}
async findById(id: string) {
return this.getTestStore().get(id) ?? null;
}
async create(user: User) {
this.getTestStore().set(user.id, user);
return user;
}
async findByEmail(email: string) {
for (const user of this.getTestStore().values()) {
if (user.email === email) return user;
}
return null;
}
}
```
```typescript
// Inject based on environment
const userRepository = process.env.NODE_ENV === 'test'
? new InMemoryUserRepository(() => getTestIdFromHeader())
: new PrismaUserRepository(prisma);
```
**Best for:** * Teams wanting true test-level parallelism * Growing codebases that need scalable testing * Teams valuing clean architecture and infrastructure flexibility **Trade-offs:** * ✅ True parallelism with test ID isolation * ✅ No schema changes * ✅ ORM agnostic (swap implementations freely) * ✅ In-memory tests are fast * ✅ Infrastructure flexibility (change databases/ORMs later) * Requires abstracting database access behind interfaces * Separate tests needed for real database behavior [**Learn more about the Repository Pattern →**](./repository-pattern) ## Option 3: Sharding (Container per Shard) Run multiple PostgreSQL containers, one per Playwright shard. This provides parallelism at the shard level (not test level)—tests within each shard still run sequentially against their container. playwright.config.ts
```typescript
export default defineConfig({
workers: 4, // 4 parallel workers
});
// globalSetup.ts
export default async function globalSetup() {
const workerId = process.env.TEST_WORKER_INDEX || '0';
// Each worker gets its own container
const container = await new PostgreSqlContainer()
.withDatabase(`test_${workerId}`)
.start();
process.env.DATABASE_URL = container.getConnectionUrl();
}
```
Run with sharding:
```bash
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
```
**Best for:** * CI/CD with good parallelization support * Teams with container orchestration experience * Teams who want parallelism without code changes **Trade-offs:** * ✅ Shard-level parallelism * ✅ No code or schema changes * ❌ Not test-level parallelism (tests within shard still sequential) * ❌ Resource-intensive (CPU, memory, disk) * ❌ Complex CI/CD setup * ❌ Slower container startup (multiplied by shard count) ## Option 4: PostgreSQL Row-Level Security (RLS) Use PostgreSQL’s built-in RLS to isolate by test ID without application code changes.
```sql
-- Add test_id to tables
ALTER TABLE users ADD COLUMN test_id TEXT;
-- Create policy
CREATE POLICY test_isolation ON users
USING (test_id = current_setting('app.test_id', true));
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
```
```typescript
// Set session variable per request
app.use(async (req, res, next) => {
const testId = req.headers['x-scenarist-test-id'];
await prisma.$executeRaw`SELECT set_config('app.test_id', ${testId}, true)`;
next();
});
```
**Best for:** * PostgreSQL users only * Teams comfortable with database-level security * Applications where RLS is already used **Trade-offs:** * ✅ True parallelism * ✅ No application code changes (after setup) * ✅ Database handles isolation * ❌ PostgreSQL only (not portable) * ❌ Schema changes required * ❌ Complex to debug * ❌ Performance overhead ## Option 5: Schema Per Test Create a database schema for each test, complete isolation.
```typescript
// Before each test
const testId = generateTestId();
await prisma.$executeRaw`CREATE SCHEMA test_${testId}`;
await prisma.$executeRaw`SET search_path TO test_${testId}`;
// Run migrations in new schema
execSync('npx prisma migrate deploy');
// After test
await prisma.$executeRaw`DROP SCHEMA test_${testId} CASCADE`;
```
**Best for:** * Complete isolation requirements * Teams with existing schema management tooling * Debugging complex test failures **Trade-offs:** * ✅ Complete isolation * ✅ No code changes * ✅ Works with any ORM * ❌ Very slow (migrations per test) * ❌ Complex connection management * ❌ High resource usage ## Decision Guide ### Recommended: Repository Pattern For most teams that want scalable, parallel testing of real server-side code through browser-based tests, the repository pattern is the best approach. It provides true test-level parallelism with the same test ID isolation model as Scenarist, and the architectural benefits (clean separation, infrastructure flexibility) extend well beyond testing. ### When to Choose Sequential Execution If you’re building a quick prototype, or you’re certain your application and test suite will remain small. Be aware that this approach doesn’t scale—you’ll need to migrate later if your test suite grows. ### When to Choose Sharding If CI/CD time is critical but you don’t want to change your application code. Requires good infrastructure and Docker orchestration. Note that tests within each shard still run sequentially. ### When to Choose PostgreSQL RLS If you need row-level isolation (e.g., testing multi-tenant applications) and you’re already using PostgreSQL. Accept the schema changes and complexity. ### When to Choose Schema Per Test Rarely. Only if you need complete isolation and can tolerate very slow test execution. ## Recommended Strategy A scalable approach for teams building comprehensive test suites: 1. **Use Scenarist for HTTP mocking** - External APIs (Stripe, Auth0, SendGrid) 2. **Use the repository pattern for database access** - True test-level parallelism with test ID isolation 3. **Test repository implementations with Testcontainers** - Verify real database behavior (queries, constraints, transactions) This gives you the best of both worlds: fast parallel tests for most scenarios, plus real database validation where it matters. ## Next Steps * [Repository Pattern Guide](./repository-pattern) - Test-level parallelism with clean architecture * [Testcontainers Hybrid Guide](./testcontainers-hybrid) - Test real database behavior * [Testcontainers Documentation](https://testcontainers.com/) - Learn container management * [PostgreSQL RLS Documentation](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) - Understand RLS
# Repository Pattern for Parallel Database Testing
> Use the repository pattern to achieve true test parallelism with test ID isolation—no schema changes, any ORM, fast in-memory execution
## Two Concepts Working Together It’s important to distinguish between two separate concepts: ### 1. The Repository Pattern (Architectural) The [repository pattern](https://martinfowler.com/eaaCatalog/repository.html) is a well-established architectural practice that abstracts database access behind interfaces. It’s not specific to testing—it provides [significant benefits](#beyond-testing-why-the-repository-pattern-is-good-practice) including infrastructure flexibility, better separation of concerns, and alignment with SOLID principles. ### 2. Test ID Isolation (Testing Technique) Test ID isolation partitions data by a unique identifier per test, enabling parallel execution without interference. This is the same technique Scenarist uses for HTTP mocking. **This guide combines both:** We use the repository pattern as the mechanism to implement test ID isolation for databases. *** ## Why Use the Repository Pattern for Test Isolation? The repository pattern is particularly well-suited for test ID isolation because: 1. **Interface injection** — Swap production (real DB) for test (in-memory with partitioning) implementations 2. **Clean boundaries** — Database access is already abstracted, making partitioning natural 3. **No schema changes** — Isolation happens in application code, not database 4. **Fast execution** — In-memory stores are orders of magnitude faster than real databases ## How It Works The repository pattern separates *what* your code does from *how* data is persisted. Your business logic depends on an interface (the “what”), while concrete implementations handle the database details (the “how”). **The key insight:** You can swap implementations based on environment: * **Production:** Real implementation that executes actual database queries (Prisma, Drizzle, TypeORM) * **Tests:** In-memory implementation that stores data in JavaScript Maps, partitioned by test ID This gives you the same isolation model as Scenarist’s HTTP mocking: | Scenarist (HTTP) | Repository Pattern (Database) | | -------------------------------------------- | -------------------------------------------- | | `x-scenarist-test-id` header identifies test | `x-scenarist-test-id` header identifies test | | MSW returns scenario-specific responses | Repository returns test-specific data | | Each test gets isolated mock state | Each test gets isolated data store | **The flow:** 1. Test sends request with `x-scenarist-test-id: abc-123` 2. Middleware extracts test ID and stores it in `AsyncLocalStorage` 3. Repository retrieves test ID and uses it as partition key 4. Data operations only affect that test’s partition 5. Parallel tests have completely isolated data stores Because the interface is the same, your business logic doesn’t know or care which implementation is running—it just calls `userRepository.findById()`. The test isolation happens transparently. ## Working Example: Next.js App Router ## Example Implementation (Express) Here’s a complete working example showing how test ID flows from Playwright through Express to isolated in-memory repositories: ### 1. Repository Interface src/repositories/user-repository.ts
```typescript
export type User = {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
};
export type CreateUserInput = {
email: string;
name: string;
};
export interface UserRepository {
findById(id: string): Promise;
findByEmail(email: string): Promise;
findAll(): Promise;
create(data: CreateUserInput): Promise;
}
```
### 2. Production Implementation (Prisma) src/repositories/prisma-user-repository.ts
```typescript
import { PrismaClient } from '@prisma/client';
import type { UserRepository, User, CreateUserInput } from './user-repository';
export class PrismaUserRepository implements UserRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: string): Promise {
return this.prisma.user.findUnique({ where: { id } });
}
async findByEmail(email: string): Promise {
return this.prisma.user.findFirst({ where: { email } });
}
async findAll(): Promise {
return this.prisma.user.findMany();
}
async create(data: CreateUserInput): Promise {
return this.prisma.user.create({ data });
}
}
```
### 3. Test Implementation with Test ID Isolation This is where the magic happens. The test implementation partitions data by test ID: src/repositories/in-memory-user-repository.ts
```typescript
import type { UserRepository, User, CreateUserInput } from './user-repository';
export class InMemoryUserRepository implements UserRepository {
// Map>
private store = new Map>();
private idCounter = new Map();
constructor(private getTestId: () => string) {}
private getTestStore(): Map {
const testId = this.getTestId();
if (!this.store.has(testId)) {
this.store.set(testId, new Map());
this.idCounter.set(testId, 0);
}
return this.store.get(testId)!;
}
private generateId(): string {
const testId = this.getTestId();
const counter = (this.idCounter.get(testId) ?? 0) + 1;
this.idCounter.set(testId, counter);
return `user-${counter}`;
}
async findById(id: string): Promise {
return this.getTestStore().get(id) ?? null;
}
async findByEmail(email: string): Promise {
for (const user of this.getTestStore().values()) {
if (user.email === email) return user;
}
return null;
}
async findAll(): Promise {
return Array.from(this.getTestStore().values());
}
async create(data: CreateUserInput): Promise {
const user: User = {
id: this.generateId(),
...data,
createdAt: new Date(),
updatedAt: new Date(),
};
this.getTestStore().set(user.id, user);
return user;
}
}
```
### 4. Dependency Injection Container src/container.ts
```typescript
import { AsyncLocalStorage } from 'node:async_hooks';
import { PrismaClient } from '@prisma/client';
import type { UserRepository } from './repositories/user-repository';
import { PrismaUserRepository } from './repositories/prisma-user-repository';
import { InMemoryUserRepository } from './repositories/in-memory-user-repository';
// AsyncLocalStorage carries test ID through async request lifecycle
const testIdStorage = new AsyncLocalStorage();
export const getTestId = (): string => {
return testIdStorage.getStore() ?? 'default-test';
};
export const runWithTestId = (testId: string, fn: () => T): T => {
return testIdStorage.run(testId, fn);
};
// Create repositories based on environment
const prisma = new PrismaClient();
export const createRepositories = (): { userRepository: UserRepository } => {
const isTest = process.env.NODE_ENV === 'test';
const userRepository: UserRepository = isTest
? new InMemoryUserRepository(getTestId)
: new PrismaUserRepository(prisma);
return { userRepository };
};
```
### 5. Express Middleware to Extract Test ID src/middleware/test-id-middleware.ts
```typescript
import type { Request, Response, NextFunction } from 'express';
import { runWithTestId } from '../container';
export const testIdMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
const testId = (req.headers['x-scenarist-test-id'] as string) ?? 'default-test';
runWithTestId(testId, () => {
next();
});
};
```
### 6. Express App Setup src/app.ts
```typescript
import express from 'express';
import { testIdMiddleware } from './middleware/test-id-middleware';
import { createRepositories } from './container';
const app = express();
const { userRepository } = createRepositories();
app.use(express.json());
app.use(testIdMiddleware);
app.get('/users', async (req, res) => {
const users = await userRepository.findAll();
res.json({ users });
});
app.post('/users', async (req, res) => {
const { email, name } = req.body;
const existing = await userRepository.findByEmail(email);
if (existing) {
return res.status(400).json({ error: 'Email already registered' });
}
const user = await userRepository.create({ email, name });
res.status(201).json({ user });
});
export { app };
```
### 7. Playwright Tests tests/user-registration.spec.ts
```typescript
import { test, expect } from './fixtures'; // withScenarios(scenarios)
test.describe('User Registration', () => {
test('should register new user', async ({ page, switchScenario }) => {
// Scenarist mocks external email API
await switchScenario(page, 'emailSuccess');
// Repository pattern isolates database by test ID
// (test ID automatically flows from x-scenarist-test-id header set by Scenarist)
await page.goto('/register');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="name"]', 'Test User');
await page.click('button[type="submit"]');
await expect(page.getByText('Welcome, Test User')).toBeVisible();
});
test('should show error for duplicate email', async ({ page, switchScenario }) => {
await switchScenario(page, 'emailSuccess');
// First registration
await page.goto('/register');
await page.fill('[name="email"]', 'duplicate@example.com');
await page.fill('[name="name"]', 'First User');
await page.click('button[type="submit"]');
// Second registration with same email
await page.goto('/register');
await page.fill('[name="email"]', 'duplicate@example.com');
await page.fill('[name="name"]', 'Second User');
await page.click('button[type="submit"]');
await expect(page.getByText('Email already registered')).toBeVisible();
});
});
// These tests run in PARALLEL with full isolation:
// - Test A (x-scenarist-test-id: abc-123) → store['abc-123']
// - Test B (x-scenarist-test-id: def-456) → store['def-456']
// - Same email in different tests → no conflict
```
## How the Test ID Flows 1. **Playwright** sends request with `x-scenarist-test-id` header (set automatically by Scenarist) 2. **Express middleware** extracts the test ID and stores it in `AsyncLocalStorage` 3. **Repository** calls `getTestId()` to retrieve the test ID from `AsyncLocalStorage` 4. **Data partitioning** ensures each test ID maps to its own isolated data store This is the same pattern Scenarist uses internally—`AsyncLocalStorage` carries context through the async request lifecycle. ## Why This Approach Excels ### True Parallelism with Full Isolation Each test gets its own isolated data store, keyed by test ID. Tests run concurrently without interference—the same isolation model as Scenarist’s HTTP mocking. ### Fast Execution In-memory repositories are orders of magnitude faster than real databases: * No network round-trips * No disk I/O * No connection pool overhead * No query parsing/planning ### Infrastructure Flexibility The repository pattern decouples your application from specific persistence technologies. Today you’re using PostgreSQL with Prisma—tomorrow you might migrate to: * A different database (MySQL, MongoDB) * A different ORM (Drizzle, TypeORM) * A different architecture (microservices, event sourcing) Your business logic remains unchanged because it depends on interfaces, not implementations. ### ORM Agnostic The interface is your contract. Swap between Prisma, Drizzle, TypeORM, Knex, or raw SQL without changing tests:
```typescript
// All these implement the same interface
const prismaRepo = new PrismaUserRepository(prisma);
const drizzleRepo = new DrizzleUserRepository(db);
const knexRepo = new KnexUserRepository(knex);
const testRepo = new InMemoryUserRepository(getTestId);
```
### No Schema Changes Unlike PostgreSQL RLS or test ID columns, the repository pattern requires no database schema modifications. Your production database remains clean. ## Trade-offs to Consider ### Requires Abstracting All Database Access Every database call must go through a repository interface. For existing codebases, this can be significant refactoring:
```typescript
// Before: Direct ORM calls scattered throughout code
const user = await prisma.user.findUnique({ where: { id } });
// After: All access through repositories
const user = await userRepository.findById(id);
```
### Two Implementations to Maintain You must maintain both production and test implementations. When the interface changes, both must be updated:
```typescript
// Add new method to interface
interface UserRepository {
// ... existing methods
findByRole(role: string): Promise; // NEW
}
// Must implement in BOTH:
// - PrismaUserRepository
// - InMemoryUserRepository
```
### Doesn’t Test Real Database Behavior The in-memory implementation doesn’t execute actual SQL. Potential issues you might miss: * Query performance problems * Database constraints (unique, foreign keys) * Transaction isolation issues * ORM-specific edge cases Mitigation Strategy **Test the repository implementation itself in isolation.** Create a separate test suite that runs your production repository (e.g., `PrismaUserRepository`) against a real database using [Testcontainers](./testcontainers-hybrid): tests/repositories/prisma-user-repository.test.ts
```typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
import { PrismaUserRepository } from '../../src/repositories/prisma-user-repository';
const createTestContext = async () => {
const container = await new PostgreSqlContainer().start();
const prisma = new PrismaClient({
datasources: { db: { url: container.getConnectionUri() } }
});
await prisma.$executeRaw`CREATE TABLE users (...)`; // Run migrations
const repository = new PrismaUserRepository(prisma);
return {
container,
prisma,
repository,
cleanup: async () => {
await prisma.$disconnect();
await container.stop();
},
};
};
describe('PrismaUserRepository', () => {
it('should enforce unique email constraint', async () => {
const { repository, cleanup } = await createTestContext();
try {
await repository.create({ email: 'test@example.com', name: 'User 1' });
await expect(repository.create({ email: 'test@example.com', name: 'User 2' }))
.rejects.toThrow(/unique constraint/i);
} finally {
await cleanup();
}
});
});
```
This gives you: * **Fast parallel tests** for business logic (in-memory repositories) * **Confidence in SQL correctness** (repository implementation tests against real database) * **Clear separation** between “does my business logic work?” and “does my SQL work?” ## When to Choose This Approach **The repository pattern is worth adopting if you value:** * **Test parallelism** - Run hundreds of database tests concurrently * **Infrastructure flexibility** - Ability to change databases, ORMs, or architectures without rewriting business logic * **Fast feedback loops** - In-memory tests complete in milliseconds * **Clean architecture** - Better separation of concerns between domain logic and persistence * **Long-term maintainability** - Codebase that’s easier to understand, test, and evolve **The investment required:** * **Existing codebases:** Refactoring direct ORM calls to use repositories takes time. Start with new features and gradually migrate existing code. * **Two implementations:** You maintain production and test repositories. TypeScript ensures they stay in sync. * **Learning curve:** If your team is new to dependency injection, there’s initial learning investment. **Consider simpler alternatives if:** * You have a small test suite where sequential execution is fast enough * You need to test specific database behavior (query performance, constraints, transactions) * The refactoring cost outweighs the parallelism benefit for your current project timeline ## Beyond Testing: Why the Repository Pattern is Good Practice The repository pattern is widely adopted because it follows fundamental software design principles: ### Separation of Concerns Your business logic focuses on *what* to do, not *how* to persist data. The `UserService` doesn’t know or care whether data comes from PostgreSQL, MongoDB, or an API—it just calls `userRepository.findById()`. ### Dependency Inversion High-level business logic depends on abstractions (interfaces), not concrete implementations. This is the “D” in SOLID principles. Your domain code depends on `UserRepository` (interface), not `PrismaUserRepository` (implementation). ### Single Responsibility Each class has one job: * `PrismaUserRepository` → translates domain operations to Prisma queries * `UserService` → implements business rules * `UserController` → handles HTTP requests ### Open/Closed Principle Add new persistence strategies without modifying existing code. Need to cache frequently accessed data? Create a `CachedUserRepository` that wraps the real one. Need to log all database access? Create a `LoggingUserRepository` decorator. ### Type Safety TypeScript interfaces ensure your production and test implementations have matching signatures. If you add a method to the interface, both implementations must implement it. ## Further Reading * [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) - Martin Fowler’s original definition * [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) - Alistair Cockburn’s ports and adapters * [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) - Uncle Bob’s architectural principles * [Testing Without Mocks](https://www.jamesshore.com/v2/projects/testing-without-mocks/testing-without-mocks) - James Shore on “Nullable Infrastructure” * [Domain-Driven Design Reference](https://www.domainlanguage.com/ddd/reference/) - Eric Evans on repositories in DDD ## Next Steps * [Parallelism Options Overview](./parallelism-options) - Compare all approaches * [Testcontainers Hybrid](./testcontainers-hybrid) - Test real database behavior when needed * [Scenarist Getting Started](/getting-started/installation/) - Set up HTTP mocking
# Testcontainers + Scenarist Hybrid
> Test database-heavy Next.js apps without code changes using Testcontainers and Scenarist together
This guide shows how to test Next.js apps that use direct database access by combining [Testcontainers](https://testcontainers.com/) for real database testing with Scenarist for mocking external APIs. **No code changes required.** ## What is Testcontainers? [Testcontainers](https://testcontainers.com/) is a library that provides lightweight, throwaway Docker containers for testing. It spins up real database instances (PostgreSQL, MySQL, MongoDB) as Docker containers during your tests, pre-configured with your schema and seed data. Each test suite gets a fresh database instance that’s automatically destroyed after tests complete. This enables testing against actual database queries, migrations, transactions, and constraints without maintaining a shared test database or complex cleanup scripts. **Learn more:** [Testcontainers Documentation](https://testcontainers.com/getting-started/) ## Overview The hybrid approach uses two complementary tools: * **Testcontainers:** Spins up real Docker database containers with seeded scenarios * **Scenarist:** Mocks external API dependencies (Stripe, Auth0, SendGrid) Together, you get: * ✅ Real database queries and migrations tested * ✅ External APIs mocked for different scenarios * ✅ No code changes to your application * ✅ Realistic integration testing **Best for:** Teams that cannot/won’t refactor to add API routes ## Architecture
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// Server Component (unchanged - no API routes needed!)
export default async function CheckoutPage() {
// Database call - testcontainer provides real PostgreSQL with seeded data
const user = await db.user.findUnique({ where: { id: userId } });
// External API - Scenarist intercepts and mocks
const headersList = await headers();
const response = await fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: getScenaristHeadersFromReadonlyHeaders(headersList),
body: JSON.stringify({ amount: user.cartTotal }),
});
if (!response.ok) {
return ;
}
const payment = await response.json();
return ;
}
```
**What each tool handles:** * **Testcontainers:** User data, products, cart items (real PostgreSQL) * **Scenarist:** Stripe payment responses, Auth0 tokens, SendGrid emails (mocked) ## Installation Install Testcontainers for your database:
```bash
# For PostgreSQL
npm install -D @testcontainers/postgresql
# For MySQL
npm install -D @testcontainers/mysql
# For MongoDB
npm install -D @testcontainers/mongodb
```
Scenarist is already installed from [Getting Started](/getting-started/installation/). ## Step-by-Step Implementation ### Step 1: Create Database Seeding Functions Create functions to seed test data into database containers: tests/helpers/seed-database.ts
```typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
export async function seedPremiumUser(container: StartedPostgreSqlContainer) {
const connectionString = container.getConnectionUrl();
const prisma = new PrismaClient({
datasources: { db: { url: connectionString } },
});
await prisma.user.create({
data: {
id: 'user-premium-123',
email: 'premium@example.com',
tier: 'premium',
cartTotal: 500,
},
});
await prisma.$disconnect();
}
export async function seedStandardUser(container: StartedPostgreSqlContainer) {
const connectionString = container.getConnectionUrl();
const prisma = new PrismaClient({
datasources: { db: { url: connectionString } },
});
await prisma.user.create({
data: {
id: 'user-standard-456',
email: 'standard@example.com',
tier: 'standard',
cartTotal: 200,
},
});
await prisma.$disconnect();
}
```
### Step 2: Set Up Container in Tests Create a container setup function and use it in tests: tests/helpers/setup-container.ts
```typescript
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { execSync } from 'child_process';
export async function createTestContainer(): Promise {
// Start PostgreSQL container
const container = await new PostgreSqlContainer('postgres:16').start();
// Set DATABASE_URL for Next.js app
process.env.DATABASE_URL = container.getConnectionUrl();
// Run migrations
execSync('npx prisma migrate deploy', { env: process.env });
return container;
}
// tests/checkout.spec.ts
import { test, expect } from './fixtures'; // withScenarios(scenarios)
import { createTestContainer } from './helpers/setup-container';
import { seedPremiumUser, seedStandardUser } from './helpers/seed-database';
test.describe('Checkout flow', () => {
const setup = test.beforeAll(async () => {
const container = await createTestContainer();
return { container };
});
test.afterAll(async () => {
const { container } = await setup;
await container.stop();
});
// Tests use container from setup
});
```
### Step 3: Write Tests with Database + API Mocking Combine database seeding with Scenarist scenario switching:
```typescript
test.describe('Checkout flow', () => {
const setup = test.beforeAll(async () => {
const container = await createTestContainer();
return { container };
});
test.afterAll(async () => {
const { container } = await setup;
await container.stop();
});
test('premium user with successful payment', async ({ page, switchScenario }) => {
const { container } = await setup;
// Testcontainer: Seed premium user in real database
await seedPremiumUser(container);
// Scenarist: Mock Stripe success
await switchScenario(page, 'stripeSuccess');
await page.goto('/checkout');
// Test sees:
// - Premium user from real database (tier='premium', cartTotal=500)
// - Successful payment from Scenarist mock
await expect(page.getByText('Payment successful')).toBeVisible();
await expect(page.getByText('£500.00')).toBeVisible();
});
test('standard user with declined payment', async ({ page, switchScenario }) => {
const { container } = await setup;
// Testcontainer: Seed standard user in real database
await seedStandardUser(container);
// Scenarist: Mock Stripe decline
await switchScenario(page, 'stripeDeclined');
await page.goto('/checkout');
// Test sees:
// - Standard user from real database (tier='standard', cartTotal=200)
// - Declined payment from Scenarist mock
await expect(page.getByText('Payment declined')).toBeVisible();
await expect(page.getByText('£200.00')).toBeVisible();
});
});
```
### Step 4: Define Scenarist Scenarios Mock external APIs (Stripe, Auth0, SendGrid): lib/scenarios.ts
```typescript
import type { ScenaristScenario } from '@scenarist/nextjs-adapter/app';
export const stripeSuccessScenario: ScenaristScenario = {
id: 'stripeSuccess',
name: 'Stripe Payment Success',
description: 'Stripe charges succeed',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
id: 'ch_123',
status: 'succeeded',
amount: 50000, // $500.00
},
},
},
],
};
export const stripeDeclinedScenario: ScenaristScenario = {
id: 'stripeDeclined',
name: 'Stripe Payment Declined',
description: 'Stripe declines the card',
mocks: [
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 402,
body: {
error: {
code: 'card_declined',
message: 'Your card was declined',
},
},
},
},
],
};
```
## Complete Example Here’s a full test showing database setup, seeding, and API mocking using functional patterns:
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';
import { scenarios } from '../lib/scenarios';
// Test ID header is set automatically; scenaristEndpoint defaults to '/api/__scenario__'
const test = withScenarios(scenarios);
test.describe('Checkout flow with database', () => {
const setup = test.beforeAll(async () => {
// Start database container
const container = await new PostgreSqlContainer('postgres:16')
.withDatabase('testdb')
.withUsername('testuser')
.withPassword('testpass')
.start();
// Configure Next.js to use container database
process.env.DATABASE_URL = container.getConnectionUrl();
// Initialize Prisma client
const prisma = new PrismaClient({
datasources: { db: { url: process.env.DATABASE_URL } },
});
// Run migrations
execSync('npx prisma migrate deploy', { env: process.env });
return { container, prisma };
});
test.afterAll(async () => {
const { prisma, container } = await setup;
await prisma.$disconnect();
await container.stop();
});
test.beforeEach(async () => {
const { prisma } = await setup;
// Clean database before each test
await prisma.user.deleteMany();
await prisma.order.deleteMany();
});
test('premium user can checkout with valid payment', async ({ page, switchScenario }) => {
const { prisma } = await setup;
// Seed database: premium user
await prisma.user.create({
data: {
id: 'user-123',
email: 'premium@example.com',
tier: 'premium',
cart: {
create: {
items: [
{ productId: 'prod-1', quantity: 2, price: 150 },
{ productId: 'prod-2', quantity: 1, price: 200 },
],
},
},
},
});
// Mock external API: Stripe success
await switchScenario(page, 'stripeSuccess');
// Test flow
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
// Fill payment form
await page.getByLabel('Card Number').fill('4242424242424242');
await page.getByLabel('Expiry').fill('12/25');
await page.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Pay £500' }).click();
// Verify success
await expect(page.getByText('Payment successful')).toBeVisible();
await expect(page.getByText('Order #')).toBeVisible();
// Verify database updated
const order = await prisma.order.findFirst({
where: { userId: 'user-123' },
});
expect(order?.status).toBe('completed');
});
});
```
## When to Use This Approach **✅ Use when:** * You want to test actual database queries and migrations * You have external API dependencies to mock * You cannot/don’t want to add API routes * Container startup overhead is acceptable for your workflow * You need realistic integration testing **❌ Don’t use when:** * You don’t have Docker available (CI/CD constraint) * Test speed is critical (hundreds of tests) * Parallel test execution is important * Your app only uses external HTTP APIs (use Scenarist directly) ## Trade-offs ### Advantages **✅ No code changes required** * Server Components call database directly (as designed) * No API route layer needed * Production code unchanged **✅ Test real database behavior** * Actual SQL queries executed * Database constraints validated * Migrations tested * Transactions work correctly **✅ Realistic integration testing** * Database + external APIs together * Closest to production environment * Catches integration bugs ### Disadvantages **⚠️ Slower tests** * Container startup overhead * Database seeding per test * Best suited for focused test suites rather than hundreds of tests **⚠️ Docker required** * CI/CD must support Docker * Developers need Docker installed * More complex local setup **⚠️ Database seeding complexity** * Must maintain seed data scripts * Schema changes break seeds * Cleanup between tests required **⚠️ Sequential test execution required** * Database state is shared (unlike HTTP mocks which are stateless) * Scenarist isolates via test ID in HTTP headers, but databases have no equivalent mechanism * Parallel tests would corrupt each other’s data without application code changes * For parallelism, you’d need multiple containers (resource-intensive) or database-level isolation (schemas per test) ## Performance Optimization Tips **Reuse containers across tests:**
```typescript
test.beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16').start();
});
test.beforeEach(async () => {
// Only clean data, don't restart container
await prisma.user.deleteMany();
});
test.afterAll(async () => {
// Stop container once at end
await container.stop();
});
```
**Use database transactions for cleanup:**
```typescript
test.beforeEach(async () => {
await prisma.$transaction([
prisma.order.deleteMany(),
prisma.user.deleteMany(),
]);
});
```
**Cache container image:**
```bash
# Pull image once before running tests
docker pull postgres:16
```
## Next Steps * [Testcontainers Documentation](https://testcontainers.com/) - Learn more about Testcontainers * [Next.js Testing Overview](../) - Back to database testing overview * [Next.js App Router Getting Started](/frameworks/nextjs-app-router/getting-started/) - Set up Scenarist for App Router
# Endpoint APIs
> Complete reference for scenario switching, status, and debug state endpoints
Scenarist provides HTTP endpoints for scenario management and debugging: * `POST /__scenario__` — Switch the active scenario for a test * `GET /__scenario__` — Check which scenario is active * `GET /__scenarist__/state` — Retrieve test state for debugging These endpoints are framework-agnostic and work consistently across Express, Next.js, and all supported frameworks. ## POST /**scenario** - Switch Scenario Switch the active scenario for a test ID. ### Request **Method:** `POST` **URL:** `/__scenario__` (configurable via `endpoints.setScenario`) **Headers:**
```plaintext
Content-Type: application/json
x-scenarist-test-id:
```
**Body:**
```typescript
{
scenario: string; // Required: scenario ID to activate
}
```
### Responses **Success (200):**
```typescript
{
success: true;
testId: string; // The test ID used for routing
scenarioId: string; // The activated scenario ID
}
```
**Example:**
```json
{
"success": true,
"testId": "test-abc123",
"scenarioId": "payment-error"
}
```
**Validation Error (400):**
```typescript
{
error: string; // Error message
details?: unknown; // Validation error details (Zod errors)
}
```
**Example:**
```json
{
"error": "Invalid request body",
"details": [
{
"expected": "string",
"code": "invalid_type",
"path": ["scenario"],
"message": "Invalid input: expected string, received undefined"
}
]
}
```
**Scenario Not Found (400):**
```typescript
{
error: string; // "Scenario 'xyz' not found. Did you forget to register it?"
}
```
**Internal Server Error (500):**
```typescript
{
error: string; // "Internal server error"
}
```
### Example Usage **With [Playwright helpers](/testing/playwright-integration/):**
```typescript
import { test } from './fixtures'; // withScenarios(scenarios)
test('my test', async ({ page, switchScenario }) => {
// Helper handles POST request automatically
await switchScenario(page, 'payment-error');
// Sends POST to scenaristEndpoint (default '/api/__scenario__') with auto-generated test ID
});
```
**Manual (curl):**
```bash
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-H "x-scenarist-test-id: test-123" \
-d '{"scenario": "payment-error"}'
```
**Manual (fetch):**
```typescript
const response = await fetch('http://localhost:3000/__scenario__', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-scenarist-test-id': 'test-123',
},
body: JSON.stringify({ scenario: 'payment-error' }),
});
const result = await response.json();
// { success: true, testId: 'test-123', scenarioId: 'payment-error' }
```
**With supertest:**
```typescript
import request from 'supertest';
import app from './app';
const response = await request(app)
.post('/__scenario__')
.set('x-scenarist-test-id', 'test-123')
.send({ scenario: 'payment-error' });
expect(response.status).toBe(200);
expect(response.body.scenarioId).toBe('payment-error');
```
## GET /**scenario** - Get Active Scenario Retrieve the currently active scenario for a test ID. ### Request **Method:** `GET` **URL:** `/__scenario__` (configurable via `endpoints.getScenario`) **Headers:**
```plaintext
x-scenarist-test-id:
```
**No body required.** ### Responses **Success (200):**
```typescript
{
testId: string; // The test ID
scenarioId: string; // The active scenario ID
scenarioName?: string; // The scenario's human-readable name (if found)
}
```
**Example:**
```json
{
"testId": "test-abc123",
"scenarioId": "payment-error",
"scenarioName": "Payment Error Scenarios"
}
```
**No Active Scenario (404):**
```typescript
{
error: string; // "No active scenario for this test ID"
testId: string; // The test ID that was queried
}
```
**Example:**
```json
{
"error": "No active scenario for this test ID",
"testId": "test-xyz789"
}
```
### Example Usage **With fetch:**
```typescript
const response = await fetch('http://localhost:3000/__scenario__', {
headers: {
'x-scenarist-test-id': 'test-123',
},
});
const result = await response.json();
// { testId: 'test-123', scenarioId: 'payment-error', scenarioName: '...' }
```
**With curl:**
```bash
curl http://localhost:3000/__scenario__ \
-H "x-scenarist-test-id: test-123"
```
**With supertest:**
```typescript
const response = await request(app)
.get('/__scenario__')
.set('x-scenarist-test-id', 'test-123');
expect(response.status).toBe(200);
expect(response.body.scenarioId).toBe('payment-error');
```
## GET /**scenarist**/state - Debug State Retrieve the current test state for debugging. This endpoint exposes state set by `afterResponse.setState()` in your mocks, useful for debugging async workflows and verifying state transitions. ### Request **Method:** `GET` **URL:** `/__scenarist__/state` (configurable via `endpoints.getState`) **Headers:**
```plaintext
x-scenarist-test-id:
```
**No body required.** ### Response **Success (200):**
```typescript
{
testId: string; // The test ID
state: Record; // Current state object (empty {} if no state)
}
```
**Example:**
```json
{
"testId": "test-abc123",
"state": {
"cart.items": 2,
"cart.total": 59.98,
"user.tier": "premium"
}
}
```
### Example Usage **With [Playwright helpers](/testing/playwright-integration/#debugging-state) (recommended):**
```typescript
import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState }) => {
await switchScenario(page, 'checkout');
await page.goto('/cart');
await page.click('#add-item');
// Fetch current state
const state = await debugState(page);
console.log('Cart state:', state);
// → { 'cart.items': 1, 'cart.total': 29.99 }
expect(state['cart.items']).toBe(1);
});
```
**Manual (fetch):**
```typescript
const response = await fetch('http://localhost:3000/__scenarist__/state', {
headers: {
'x-scenarist-test-id': 'test-123',
},
});
const { state } = await response.json();
// { 'cart.items': 1, 'cart.total': 29.99 }
```
**With curl:**
```bash
curl http://localhost:3000/__scenarist__/state \
-H "x-scenarist-test-id: test-123"
```
### When to Use The debug state endpoint is useful for: * **Debugging test failures**: See what state was set by mocks during a test run * **Async workflows**: Wait for state to reach a condition before asserting (use `waitForDebugState`) * **State-aware mocking verification**: Confirm that `afterResponse.setState()` is capturing expected values ## Test ID Extraction All endpoints extract the test ID from the request header:
```typescript
// The header name is standardized to 'x-scenarist-test-id'
// Use SCENARIST_TEST_ID_HEADER constant from your adapter package
import { SCENARIST_TEST_ID_HEADER } from '@scenarist/express-adapter';
```
**Test ID behavior:** * **Header present:** Uses value from header (e.g., `'test-abc123'`) * **Header missing:** Uses default test ID (e.g., `'default-test'`) * **Empty string:** App Router treats it as missing and uses the default; Express and Pages Router use the empty string as-is **Example:**
```typescript
// Request with header
Headers: { 'x-scenarist-test-id': 'test-123' }
// Uses test ID: 'test-123'
// Request without header
// Uses test ID: 'default-test'
```
## Request Validation The `POST /__scenario__` endpoint validates the request body using Zod:
```typescript
import { z } from 'zod';
const ScenarioRequestSchema = z.object({
scenario: z.string().min(1), // Required, non-empty string
});
```
**Valid requests:**
```json
{ "scenario": "payment-error" }
```
**Invalid requests:**
```json
{} // Missing 'scenario'
{ "scenario": "" } // Empty string
{ "scenario": 123 } // Wrong type
```
## Framework-Specific Implementations While the API is consistent across frameworks, the implementation differs: ### Express
```typescript
// Express endpoint handler
export const handleSetScenario = (manager: ScenarioManager, config: ScenaristConfig) => {
return (req: Request, res: Response): void => {
const { scenario } = ScenarioRequestSchema.parse(req.body);
const context = new ExpressRequestContext(req, config);
const testId = context.getTestId();
const result = manager.switchScenario(testId, scenario);
if (!result.success) {
res.status(400).json({ error: result.error.message });
return;
}
res.status(200).json({
success: true,
testId,
scenarioId: scenario,
});
};
};
```
### Next.js App Router
```typescript
// Next.js App Router endpoint handler
export const POST = async (req: Request): Promise => {
const body = await req.json();
const context = new AppRequestContext(req, config);
const result = await handlePostLogic(body, context, manager);
if (!result.success) {
return Response.json(
{ error: result.error, details: result.details },
{ status: result.status }
);
}
return Response.json(
{
success: true,
testId: result.testId,
scenarioId: result.scenarioId,
},
{ status: 200 }
);
};
```
### Next.js Pages Router
```typescript
// Next.js Pages Router endpoint handler
export default async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const { scenario } = ScenarioRequestSchema.parse(req.body);
const context = new PagesRequestContext(req, config);
const testId = context.getTestId();
const result = manager.switchScenario(testId, scenario);
if (!result.success) {
return res.status(400).json({ error: result.error.message });
}
return res.status(200).json({
success: true,
testId,
scenarioId: scenario,
});
}
// GET handler...
};
```
## Configuration Endpoint paths are configurable:
```typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
// Customize endpoint paths
endpoints: {
setScenario: '/__scenario__', // POST endpoint (default)
getScenario: '/__scenario__', // GET endpoint (default)
// Or customize:
// setScenario: '/__test/switch',
// getScenario: '/__test/status',
},
});
```
**Why customize endpoint paths?** * Security: Make the endpoints harder to find if a production build ever includes them * Compatibility: Avoid conflicts with existing routes * Convention: Match your team’s naming standards **Note:** The test ID header is standardized to `'x-scenarist-test-id'` and is not configurable. Use the `SCENARIST_TEST_ID_HEADER` constant from your adapter package. ## Scenario Manager Coordination Both endpoints delegate to `ScenarioManager`:
```typescript
// POST /__scenario__ flow
const result = manager.switchScenario(testId, scenarioId);
// 1. Looks up scenario definition by ID
// 2. Returns error if not found
// 3. Stores active scenario for test ID
// 4. Resets sequences and state for test ID
// 5. Returns success
// GET /__scenario__ flow
const activeScenario = manager.getActiveScenario(testId);
// 1. Retrieves active scenario for test ID
// 2. Returns undefined if none set
const scenarioDefinition = manager.getScenarioById(activeScenario.scenarioId);
// 3. Looks up full definition for metadata
```
## State and Sequence Reset When switching scenarios, state and sequences are reset:
```typescript
manager.switchScenario(testId, scenarioId);
// Internally calls:
// - sequenceTracker.reset(testId) // All sequence positions cleared
// - stateManager.reset(testId) // All captured state cleared
```
**Why reset?** * Clean slate for new scenario * Prevents state bleeding between scenarios * Ensures idempotent test execution **Example:**
```typescript
// Test 1
await switchScenario(page, 'shopping-cart');
// Add items, state captured: { cartItems: ['item-1', 'item-2'] }
// Switch to error scenario
await switchScenario(page, 'payment-error');
// State reset: { } (empty)
// Sequences reset: all positions → 0
```
## Security Considerations **Production Safety:** * When `enabled: false`, `createScenarist()` returns `undefined`, so no endpoints are registered (Express responds 404) * When the `production` export condition is resolved, the adapter’s `createScenarist()` resolves to a stub that returns `undefined`. Next.js production builds resolve it automatically; Express needs a bundler option such as esbuild’s `--conditions=production` or `node --conditions=production`. See [Production Safety](/concepts/production-safety/) * Express also returns `undefined` when `NODE_ENV` is `production`, even with `enabled: true`, so unbundled apps started without the condition do not mount the endpoints * For defense in depth, also consider: * Custom endpoint paths (non-standard) * Network-level blocking (firewall) * Environment-based enablement (never `true` in production) **Test Isolation:** * Each test ID is isolated * No cross-test interference * Test IDs should be unique (use UUIDs) **Header Validation:** * Test ID read from the `x-scenarist-test-id` header * Missing header → uses default test ID * Empty header → uses default test ID (App Router); used as-is (Express, Pages Router) ## Error Handling All endpoints return structured errors:
```typescript
// Validation error
{
error: 'Invalid request body',
details: [/* Zod validation errors */]
}
// Scenario not found
{
error: "Scenario 'xyz' not found. Did you forget to register it?"
}
// No active scenario
{
error: 'No active scenario for this test ID',
testId: 'test-123'
}
// Internal error
{
error: 'Internal server error'
}
```
**Best practice:** Always check response status before parsing body. ## Complete Example Full workflow with both endpoints:
```typescript
import { withScenarios, expect } from '@scenarist/playwright-helpers';
import { scenarios } from './scenarios';
export const testWithScenarios = withScenarios(scenarios);
testWithScenarios('payment flow', async ({ page, switchScenario }) => {
// 1. Switch to payment success scenario
const testId = await switchScenario(page, 'payment-success');
// POST /__scenario__ called with:
// { scenario: 'payment-success' }
// Response: { success: true, testId: 'test-abc123', scenarioId: 'payment-success' }
// 2. Verify active scenario (optional)
const response = await page.request.get('http://localhost:3000/__scenario__', {
headers: { 'x-scenarist-test-id': testId },
});
const activeScenario = await response.json();
// { testId: 'test-abc123', scenarioId: 'payment-success', scenarioName: '...' }
// 3. Test with success scenario
await page.goto('/checkout');
await page.click('button:has-text("Pay")');
await expect(page.getByText('Payment successful')).toBeVisible();
// 4. Switch to error scenario
await switchScenario(page, 'payment-error');
// POST /__scenario__ called with:
// { scenario: 'payment-error' }
// Sequences and state reset
// 5. Test with error scenario
await page.goto('/checkout');
await page.click('button:has-text("Pay")');
await expect(page.getByText('Payment failed')).toBeVisible();
});
```
## Next Steps * [Writing Scenarios →](/scenarios/overview/) - Learn the complete scenario structure * [Default Scenarios →](/scenarios/default-scenarios/) - Understand override behavior * [Ephemeral Endpoints →](/reference/ephemeral-endpoints/) - Understand test-only activation
# Ephemeral Endpoints
> Test-only activation, production safety, and test ID isolation in Scenarist
Scenarist creates **ephemeral endpoints** that only exist when Scenarist is enabled in development and test. When `enabled` is `false`, and in production, they are never registered, so they add zero overhead. This ensures scenario switching infrastructure never leaks into production. ## The `enabled` Flag The `enabled` flag controls whether Scenarist’s testing infrastructure is active. When it is `false`, `createScenarist()` returns `undefined` in every adapter.
```typescript
import { createScenarist } from "@scenarist/express-adapter";
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Only in test environment
scenarios,
});
```
Production has its own guards that apply regardless of `enabled`: the adapter’s `production` export condition makes `createScenarist()` return `undefined` without loading any test code, and the Express adapter also returns `undefined` when `NODE_ENV` is `production`. See [Production Safety](/concepts/production-safety/). Make sure enabled is true where your tests run `enabled` is evaluated in the process that serves requests. For Express, `process.env.NODE_ENV === "test"` works when your test runner sets it (Vitest and Jest do). In Next.js, use `enabled: true`: Next.js replaces `process.env.NODE_ENV` with `'development'` or `'production'` at build time, so a `"test"` check is never true, and production builds are excluded by the `production` export condition. ### When `enabled: true` (Development and Test) **Endpoints are active:** * `POST /__scenario__` accepts scenario switch requests * `GET /__scenario__` returns active scenario * Both endpoints process requests normally **Middleware extracts test IDs:** * Reads `x-scenarist-test-id` header from requests * Routes requests to correct scenario * Maintains test isolation **MSW is registered:** * Handlers created from scenario definitions * External API calls intercepted * Responses returned based on active scenario ### When `enabled: false`, and in Production `createScenarist()` returns `undefined`. **Endpoints are not registered.** Requests to them get whatever your app returns for an unknown route:
```typescript
// Express: POST /__scenario__ → 404 Not Found (no middleware installed)
// Next.js: undefined handlers (App Router) or your fallback handler (Pages Router) → 405 Method Not Allowed
```
**No middleware is installed** (`createScenarist()` returned `undefined`, so there is no `scenarist.middleware`): * Extracts no headers * Adds no overhead * Passes requests through unchanged **MSW is not registered:** * No handlers created * No interception occurs * External APIs called normally **Result: Zero production overhead.** The testing infrastructure simply doesn’t exist. ## Production Safety Guarantees Scenarist provides multiple layers of production safety: ### 1. Production Entry Point The `production` export condition swaps in a stub whose `createScenarist()` returns `undefined` and imports nothing. Make sure your production build resolves it; the [Production Safety guide](/concepts/production-safety/) shows how to verify this for each framework. The Express adapter also returns `undefined` when `NODE_ENV` is `production`, even with `enabled: true`. This protects unbundled Express apps started without `--conditions=production`; the adapter and `msw` are still loaded in that case, but nothing is mounted or intercepted. ### 2. Configuration Check
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Express: false in production; in Next.js use enabled: true
scenarios,
});
```
**Best practice:** Derive `enabled` from the environment so it is never `true` in production. ### 3. Endpoint Availability As an extra precaution, the endpoints can be moved to non-standard paths:
```typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
endpoints: {
setScenario: "/__internal_test_scenario_switch__", // Obscure path
getScenario: "/__internal_test_scenario_status__",
},
});
```
### 4. Middleware Safety The middleware only exists when `createScenarist()` returns an instance:
```typescript
// Express example
if (scenarist) {
app.use(scenarist.middleware);
}
// When enabled is false, or in production, scenarist is undefined
// - Middleware never installed
// - No header extraction
// - No performance impact
```
### 5. MSW Registration MSW handlers are only registered when you call `scenarist.start()`, so guard the call:
```typescript
// Next.js example
if (scenarist) {
scenarist.start(); // Registers MSW handlers
}
// When enabled is false, or in production: handlers never registered
```
## Test ID Isolation Each test gets a unique test ID that routes requests to the correct scenario: ### How Test IDs Work
```typescript
// Test 1
test("handles success", async ({ page, switchScenario }) => {
const testId = await switchScenario(page, "success");
// testId = 'test-abc123' (auto-generated UUID)
await page.goto("/api/payment");
// Request includes header: x-scenarist-test-id: test-abc123
// Scenarist routes to 'success' scenario for this test ID
});
// Test 2 (runs in parallel)
test("handles error", async ({ page, switchScenario }) => {
const testId = await switchScenario(page, "error");
// testId = 'test-def456' (different UUID)
await page.goto("/api/payment");
// Request includes header: x-scenarist-test-id: test-def456
// Scenarist routes to 'error' scenario for this test ID
});
```
**Tests run in parallel without interference** because each has its own test ID and scenario. ### Test ID Lifecycle 1. **Test starts**: No test ID or scenario set 2. **`switchScenario()` called**: Generates unique test ID (UUID) 3. **Scenario activated**: Test ID → Scenario mapping stored 4. **Request made**: Test ID header included automatically 5. **Scenario selected**: Based on test ID from header 6. **Response returned**: From active scenario for that test ID 7. **Test ends**: The test ID is never reused; its scenario mapping stays in memory until the server restarts ### Test ID Header The test ID header is standardized to `'x-scenarist-test-id'`. Use the `SCENARIST_TEST_ID_HEADER` constant from your adapter package:
```typescript
import { SCENARIST_TEST_ID_HEADER } from "@scenarist/express-adapter";
// SCENARIST_TEST_ID_HEADER === 'x-scenarist-test-id'
```
**All requests must include this header** for scenario routing to work. ### Automatic Test ID Propagation [Playwright helpers](/testing/playwright-integration/) automatically include the test ID header:
```typescript
// tests/fixtures.ts - Set up once
import { withScenarios, expect } from "@scenarist/playwright-helpers";
import { scenarios } from "./scenarios";
export const test = withScenarios(scenarios);
export { expect };
// tests/my-test.spec.ts - Use in tests
import { test, expect } from "./fixtures"; // ✅ Import from fixtures
test("my test", async ({ page, switchScenario }) => {
await switchScenario(page, "success"); // ✅ Type-safe scenario IDs
// Helper automatically:
// 1. Generates unique test ID
// 2. Calls POST /__scenario__ with test ID header
// 3. Sets page.setExtraHTTPHeaders({ 'x-scenarist-test-id': testId })
await page.goto("/profile");
// All navigation requests include x-scenarist-test-id header
await page.request.post("/api/data", { data: { foo: "bar" } });
// API requests need explicit test ID (Playwright limitation):
const testId = await switchScenario(page, "success");
await page.request.post("/api/data", {
headers: { "x-scenarist-test-id": testId }, // Manual header for page.request
data: { foo: "bar" },
});
});
```
### Default Test ID When no test ID header is present, Scenarist uses a default test ID:
```typescript
// Request without x-scenarist-test-id header
GET / api / user;
// No x-scenarist-test-id header
// Scenarist uses default test ID: 'default-test'
// Routes to 'default' scenario
```
**Use case:** Manual testing during development. Open browser, navigate to app, no test ID needed. Change the fallback with `defaultTestId`. To treat a missing header as an error instead, set `defaultTestId: ''` and choose an [`onMissingTestId`](/reference/errors/#configurable-error-behaviors) behavior:
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true
scenarios,
defaultTestId: "", // No fallback
errorBehaviors: { onMissingTestId: "throw" }, // Respond 500 with MISSING_TEST_ID
});
```
## How Ephemeral Endpoints Enable Parallel Tests Traditional E2E testing forces **sequential execution** because of shared global state:
```typescript
// ❌ Traditional approach - shared MSW handlers
beforeAll(() => {
server.use(http.get("/api/user", () => HttpResponse.json({ role: "admin" })));
});
test("test 1: admin view", () => {
// Uses global handler: admin
});
test("test 2: guest view", () => {
// PROBLEM: Still sees admin handler!
// Must run sequentially or reset handlers between tests
});
```
Scenarist enables **parallel execution** via test ID isolation:
```typescript
// ✅ Scenarist approach - isolated scenarios
test("test 1: admin view", async ({ page, switchScenario }) => {
await switchScenario(page, "admin-user");
// Test ID: test-1 → Scenario: admin-user
});
test("test 2: guest view", async ({ page, switchScenario }) => {
await switchScenario(page, "guest-user");
// Test ID: test-2 → Scenario: guest-user
// Runs in parallel with test 1, no interference
});
```
**10x faster test suites** because tests run in parallel instead of sequentially. ## Runtime Scenario Switching Ephemeral endpoints enable **runtime scenario switching** without restarting your application: ### Traditional Approach (Slow)
```bash
# Test success case
NODE_ENV=test SCENARIO=success npm start
# Run test
# Kill server
# Test error case
NODE_ENV=test SCENARIO=error npm start
# Run test
# Kill server
# Test edge case
NODE_ENV=test SCENARIO=edge npm start
# Run test
# Kill server
```
**Problem:** Each scenario requires full server restart. Slow and painful. ### Scenarist Approach (Fast)
```typescript
// Start server once
// NODE_ENV=test npm start
// Switch scenarios at runtime
test("success case", async ({ page, switchScenario }) => {
await switchScenario(page, "success");
// Instant scenario switch, no restart
});
test("error case", async ({ page, switchScenario }) => {
await switchScenario(page, "error");
// Instant scenario switch, no restart
});
test("edge case", async ({ page, switchScenario }) => {
await switchScenario(page, "edge");
// Instant scenario switch, no restart
});
```
**Benefit:** Server runs continuously. Tests switch scenarios instantly via HTTP requests to `/__scenario__` endpoint. ## Manual Testing with Scenarios Ephemeral endpoints also enable **manual testing** with different scenarios:
```bash
# Start your app in test mode
NODE_ENV=test npm start
# Switch to error scenario via curl
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-H "x-scenarist-test-id: manual-testing" \
-d '{"scenario": "payment-error"}'
# Open browser and manually test error scenario
# All requests with x-scenarist-test-id: manual-testing see error scenario
# Switch to success scenario
curl -X POST http://localhost:3000/__scenario__ \
-H "Content-Type: application/json" \
-H "x-scenarist-test-id: manual-testing" \
-d '{"scenario": "payment-success"}'
# Manually test success scenario
```
**Use cases:** * Demo different scenarios to stakeholders * Debug edge cases locally * Validate UI behavior across scenarios ## Architecture: How It Works ### Request Flow
```plaintext
1. Test calls switchScenario('payment-error')
↓
2. Helper generates test ID: 'test-abc123'
↓
3. Helper calls: POST /__scenario__
Headers: { x-scenarist-test-id: 'test-abc123' }
Body: { scenario: 'payment-error' }
↓
4. Endpoint stores: test-abc123 → payment-error
↓
5. Test makes request: GET /api/payment
Headers: { x-scenarist-test-id: 'test-abc123' }
↓
6. Middleware extracts test ID from header
↓
7. ScenarioManager looks up active scenario for test ID
↓
8. MSW handler finds matching mock in scenario
↓
9. Response returned from mock definition
```
### Scenario Storage Scenarios are stored in-memory (current implementation):
```typescript
// Simplified internal structure
const scenarioStore = new Map();
// After switchScenario('test-abc123', 'payment-error')
scenarioStore.set("test-abc123", {
scenarioId: "payment-error",
});
// On request with x-scenarist-test-id: test-abc123
const activeScenario = scenarioStore.get("test-abc123");
// { scenarioId: 'payment-error' }
```
**Why in-memory?** This is the right choice for scenario-based testing - scenarios only need to persist for the duration of a test run. The test ID isolation ensures parallel tests don’t interfere with each other. ### State and Sequence Isolation State capture and sequence positions are also isolated by test ID:
```typescript
// Internal structure (simplified)
const stateStore = new Map>();
const sequenceStore = new Map();
// Test ID: test-abc123
stateStore.set("test-abc123", { cartItems: ["item-1", "item-2"] });
sequenceStore.set("test-abc123:scenario:mockIndex", { position: 2 });
// Test ID: test-def456
stateStore.set("test-def456", { cartItems: ["item-3"] });
sequenceStore.set("test-def456:scenario:mockIndex", { position: 0 });
// Tests have independent state and sequence positions
```
## Configuration Reference Complete configuration options related to ephemeral endpoints:
```typescript
const scenarist = createScenarist({
// createScenarist() returns undefined when false
enabled: process.env.NODE_ENV === "test", // Express; in Next.js use enabled: true
// Scenario definitions
scenarios: {
default: defaultScenario,
// ... other scenarios
},
// Endpoint paths (configurable)
endpoints: {
setScenario: "/__scenario__", // POST: switch scenario
getScenario: "/__scenario__", // GET: get active scenario
},
// Note: Test ID header is standardized to 'x-scenarist-test-id'
// Use SCENARIST_TEST_ID_HEADER constant from your adapter package
});
```
## Next Steps * [Writing Scenarios →](/scenarios/overview/) - Learn the complete scenario structure * [Default Scenarios →](/scenarios/default-scenarios/) - Understand override behavior * [Endpoint APIs →](/reference/api-endpoints/) - Reference for GET/POST /**scenario**
# Error Handling
> Error types, error codes, and configurable error behaviors in Scenarist
Scenarist provides comprehensive error handling with rich context, actionable hints, and configurable behaviors. All errors extend `ScenaristError` and include machine-readable codes for programmatic handling. ## Error Codes | Code | Description | When Thrown | | -------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `SCENARIO_NOT_FOUND` | Attempted to switch to a scenario that hasn’t been registered | `switchScenario()` with unknown ID (returned as `result.error`, not thrown) | | `DUPLICATE_SCENARIO` | Attempted to register a scenario with an ID that already exists | `registerScenario()` with duplicate ID | | `NO_MOCK_FOUND` | No mock matched the incoming request | Unmatched request with `errorBehaviors.onNoMockFound: 'throw'` | | `SEQUENCE_EXHAUSTED` | Sequence with `repeat: 'none'` has no more responses and no fallback mock | Request to exhausted sequence with `errorBehaviors.onSequenceExhausted: 'throw'` | | `MISSING_TEST_ID` | No test ID could be resolved for the request | Request without a test ID header, with `defaultTestId: ''` and `errorBehaviors.onMissingTestId: 'throw'` | | `VALIDATION_ERROR` | Scenario definition failed schema validation | `registerScenario()` with invalid definition | ## ScenaristError All Scenarist errors extend `ScenaristError`, which provides:
```typescript
class ScenaristError extends Error {
readonly code: string; // Machine-readable error code
readonly context: ErrorContext; // Rich context information
}
type ErrorContext = {
readonly testId?: string; // Test that triggered the error
readonly scenarioId?: string; // Scenario involved
readonly requestInfo?: { // Request details
method: string;
url: string;
};
readonly hint?: string; // Actionable guidance
};
```
### Example Error `switchScenario()` returns a result instead of throwing. The error is typed as `Error`; at runtime it is a `ScenaristError`:
```typescript
import { ScenaristError } from '@scenarist/core';
const result = scenarist.switchScenario('test-123', scenarioId);
if (!result.success && result.error instanceof ScenaristError) {
console.log(result.error.code); // 'SCENARIO_NOT_FOUND'
console.log(result.error.message); // "Scenario '...' not found..."
console.log(result.error.context);
// {
// testId: 'test-123',
// scenarioId: '...',
// hint: 'Make sure to register the scenario before switching...'
// }
}
```
The Express adapter type-checks scenario IDs against your scenarios object; the Next.js adapters accept any string. Over HTTP, `POST /__scenario__` with an unknown scenario responds `400` with the error message. ## Configurable Error Behaviors Control how Scenarist handles request-time error conditions using `errorBehaviors`:
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test',
scenarios,
logger: createConsoleLogger({ level: 'warn' }),
errorBehaviors: {
onNoMockFound: 'warn', // Log a warning, then strictMode decides
onSequenceExhausted: 'throw', // Respond 500 with SEQUENCE_EXHAUSTED
},
});
```
### Behavior Options ignore **Default.** Continue silently. `strictMode` decides what happens next. warn Log a warning through the configured `logger`, then let `strictMode` decide. Without a `logger`, this behaves like `ignore`. throw Respond with HTTP 500 and a JSON body containing the error `code` and `message`. The request your application made receives that response. A `throw` response looks like this:
```json
{
"error": "Internal mock server error",
"message": "No mock matched for GET https://api.example.com/users",
"code": "NO_MOCK_FOUND"
}
```
### Behavior Defaults All behaviors default to `ignore`, so an unmatched request behaves exactly as `strictMode` says:
```typescript
const DEFAULT_ERROR_BEHAVIORS = {
onNoMockFound: 'ignore',
onSequenceExhausted: 'ignore',
onMissingTestId: 'ignore',
};
```
In-process tests see your own requests too MSW intercepts every outgoing request in the process, including requests your test makes to your own app with supertest. With `strictMode: true` or `onNoMockFound: 'throw'`, those requests are also treated as unmatched. Use these options when your app runs in a separate process (for example, Playwright against a running server), or mock the app’s own URL. ### When onMissingTestId Applies Adapters fall back to `defaultTestId` (`'default-test'`) when a request has no `x-scenarist-test-id` header, so by default a test ID is always resolved and `onMissingTestId` never fires. To make a missing header an error, disable the fallback:
```typescript
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test', // Express; in Next.js use enabled: true
scenarios,
defaultTestId: '',
errorBehaviors: { onMissingTestId: 'throw' },
});
```
### Multiple Scenarist Instances When several instances are started in one process (for example, App Router and Pages Router adapters in the same Next.js app), each request is offered to every instance. A `throw` from one instance is used only if no other instance has a mock for the request. ## Validation Errors Scenario definitions are validated at registration time using Zod schemas. Validation errors include the path to the invalid field:
```typescript
try {
manager.registerScenario({
id: 'test',
name: '', // Empty name - validation error
description: 'Test scenario',
mocks: [
{
method: 'GET',
url: '', // Empty URL - validation error
response: { status: 600 }, // Invalid status - validation error
},
],
});
} catch (error) {
console.log(error.code); // 'VALIDATION_ERROR'
console.log(error.message);
// 'Invalid scenario definition for 'test': name: Too small: expected string to have >=1 characters, mocks.0.url: Too small: expected string to have >=1 characters, mocks.0.response.status: Too big: expected number to be <=599'
}
```
### Validation Rules | Field | Rule | | --------------------------------- | -------------------------------- | | `id` | Non-empty string | | `name` | Non-empty string | | `url` | Non-empty string or valid RegExp | | `status` | Integer between 100-599 | | `sequence.responses` | At least one response | | `stateResponse.conditions[].when` | At least one key | | `afterResponse.setState` | At least one key | ## Error Handling Patterns ### Test Setup Validation Validate your test setup by catching registration errors:
```typescript
describe('API tests', () => {
const scenarios = {
default: createDefaultScenario(),
error: createErrorScenario(),
};
// Validate all scenarios at test suite startup
beforeAll(() => {
for (const [id, scenario] of Object.entries(scenarios)) {
try {
manager.registerScenario(scenario);
} catch (error) {
if (error instanceof ScenaristError) {
throw new Error(
`Invalid scenario '${id}': ${error.message}\nHint: ${error.context.hint}`
);
}
throw error;
}
}
});
});
```
### Graceful Degradation Use `warn` locally to surface issues without blocking, and `throw` in CI:
```typescript
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: createConsoleLogger({ level: 'warn' }),
errorBehaviors: {
onNoMockFound: process.env.CI ? 'throw' : 'warn',
onSequenceExhausted: process.env.CI ? 'throw' : 'warn',
},
});
```
### Asserting on Request-Time Errors Request-time errors (`NO_MOCK_FOUND`, `SEQUENCE_EXHAUSTED`, `MISSING_TEST_ID`) are not thrown into your application code. With `throw`, the external request your app made receives a 500 response whose body carries the `code`, so your app’s own error handling runs:
```typescript
import { ErrorCodes } from '@scenarist/core';
// Errors thrown while handling a mocked request do not propagate to your code.
// The request receives a 500 JSON response: { error, message, code }
const response = await fetch('https://api.example.com/users');
if (response.status === 500) {
const body = await response.json();
switch (body.code) {
case ErrorCodes.NO_MOCK_FOUND:
console.log('No mock matched:', body.message);
break;
case ErrorCodes.SEQUENCE_EXHAUSTED:
console.log('Sequence exhausted for mock');
break;
default:
console.log('Scenarist error:', body.message);
}
}
```
Switching errors are returned, not thrown: check `result.success` (see [Example Error](#example-error)). Registration errors (`DUPLICATE_SCENARIO`, `VALIDATION_ERROR`) are thrown as `ScenaristError` when scenarios are registered, which adapters do inside `createScenarist()`. See [Validation Errors](#validation-errors). ## API Reference ### ErrorCodes
```typescript
export const ErrorCodes = {
SCENARIO_NOT_FOUND: 'SCENARIO_NOT_FOUND',
DUPLICATE_SCENARIO: 'DUPLICATE_SCENARIO',
NO_MOCK_FOUND: 'NO_MOCK_FOUND',
SEQUENCE_EXHAUSTED: 'SEQUENCE_EXHAUSTED',
MISSING_TEST_ID: 'MISSING_TEST_ID',
VALIDATION_ERROR: 'VALIDATION_ERROR',
} as const;
```
### ErrorBehaviors
```typescript
type ErrorBehavior = 'throw' | 'warn' | 'ignore';
type ErrorBehaviors = {
readonly onNoMockFound: ErrorBehavior;
readonly onSequenceExhausted: ErrorBehavior;
readonly onMissingTestId: ErrorBehavior;
};
```
### ScenaristError
```typescript
class ScenaristError extends Error {
constructor(
message: string,
options: {
code: string;
context: ErrorContext;
}
);
readonly code: string;
readonly context: ErrorContext;
}
```
## Next Steps * [Debugging with Logs](/reference/logging/) - Configure logging for error debugging * [Testing Best Practices](/testing/best-practices/) - Patterns for robust tests * [API Endpoints](/reference/api-endpoints/) - Understand the mock server API
# Debugging with Logs
> Configure and use Scenarist's logging infrastructure for debugging scenario matching and observability
Scenarist provides a flexible logging infrastructure for debugging scenario matching, state management, and request handling. Logging is **disabled by default** (silent) and can be enabled when needed. ## Quick Start
```typescript
import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
// Or: import { createScenarist, createConsoleLogger } from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({
enabled: true,
scenarios,
// Enable logging with pretty format at info level
logger: createConsoleLogger({ level: 'info' }),
});
```
## Logger Types Scenarist includes two logger implementations: NoOpLogger **Default.** Silent - no output. Zero overhead for production-like tests. ConsoleLogger Human-readable or JSON output. Great for debugging scenario matching issues. ## ConsoleLogger The `ConsoleLogger` provides human-readable or JSON output to the console. ### Basic Usage
```typescript
import { createConsoleLogger } from '@scenarist/express-adapter';
// Pretty format (default) - colored, human-readable
const logger = createConsoleLogger({ level: 'info' });
// JSON format - for log aggregation tools
const logger = createConsoleLogger({ level: 'info', format: 'json' });
// Debug level with category filtering
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
});
```
### Configuration
```typescript
type ConsoleLoggerConfig = {
/** Minimum log level to output */
level: 'error' | 'warn' | 'info' | 'debug' | 'trace';
/** Output format (default: 'pretty') */
format?: 'pretty' | 'json';
/** Filter to specific categories (default: all categories) */
categories?: LogCategory[];
};
```
### Log Levels Levels are hierarchical - each level includes all less verbose levels: | Level | Description | Use Case | | ------- | ----------------- | --------------------------------------------- | | `error` | Critical failures | Scenario not found, invalid config | | `warn` | Potential issues | No mock matched, sequence exhausted | | `info` | Key events | Scenario switched, mock selected | | `debug` | Decision logic | Match criteria evaluation, specificity scores | | `trace` | Verbose details | Request/response bodies, template replacement | ### Log Categories Filter logs to specific areas of concern: | Category | Description | Example Messages | | ----------- | -------------------- | ------------------------------------------------------------------------------------ | | `lifecycle` | Startup, shutdown | None emitted yet | | `scenario` | Scenario switching | `scenario_switched`, `scenario_not_found`, `scenario_registered`, `scenario_cleared` | | `matching` | Mock selection | `mock_selected`, `mock_no_match`, `mock_candidates_found`, `mock_match_evaluated` | | `sequence` | Sequence state | `sequence_exhausted` | | `state` | State updates | `state_set`, `state_response_resolved` | | `template` | Template replacement | None emitted yet | | `request` | Request lifecycle | None emitted yet |
```typescript
// Only show matching and scenario logs
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
});
```
### Output Formats * Pretty Format
```plaintext
12:34:56.789 INF [test-user-login] 🎬 scenario scenario_switched
12:34:56.801 DBG [test-user-login] 🎯 matching mock_match_evaluated mockIndex=0 matched=false hasCriteria=true
12:34:56.802 DBG [test-user-login] 🎯 matching mock_match_evaluated mockIndex=1 matched=true hasCriteria=true
12:34:56.803 INF [test-user-login] 🎯 matching mock_selected mockIndex=1 specificity=5
12:34:56.810 DBG [test-user-login] 💾 state state_set setState={"userId":"user-123"} source="mock-level"
12:34:56.815 WRN [test-user-login] 🎯 matching mock_no_match url="https://api.example.com/unknown" method="GET" candidateCount=0
```
**Features:** * Timestamps with milliseconds * Level indicators: `ERR`, `WRN`, `INF`, `DBG`, `TRC` * Category icons for visual scanning * Colored test IDs (persistent per test) * Key-value data fields * JSON Format
```json
{"timestamp":1732650896789,"level":"info","category":"scenario","message":"scenario_switched","testId":"test-user-login","scenarioId":"premium-user","data":{}}
{"timestamp":1732650896801,"level":"info","category":"matching","message":"mock_selected","testId":"test-user-login","scenarioId":"premium-user","data":{"mockIndex":2,"specificity":5}}
{"timestamp":1732650896802,"level":"debug","category":"matching","message":"mock_match_evaluated","testId":"test-user-login","scenarioId":"premium-user","data":{"mockIndex":0,"matched":false,"hasCriteria":true}}
```
**Use for:** Log aggregation (Datadog, Splunk), structured queries, production debugging ### Parallel Test Support When running tests in parallel, each test gets a unique test ID. The pretty format assigns **persistent colors** to each test ID, making it easy to trace logs from concurrent tests:
```plaintext
12:34:56.789 INF [test-checkout-flow] 🎯 matching mock_selected mockIndex=0 specificity=0
12:34:56.790 INF [test-payment-process] 🎯 matching mock_selected mockIndex=1 specificity=0
12:34:56.791 INF [test-user-login] 🎯 matching mock_selected mockIndex=0 specificity=2
12:34:56.792 DBG [test-checkout-flow] 💾 state state_set setState={"step":"paid"} source="mock-level"
```
Each test ID gets a unique color that remains consistent throughout the test run. ## NoOpLogger The default logger - does nothing. Use for clean test output:
```typescript
import { noOpLogger, createNoOpLogger } from '@scenarist/express-adapter';
// Singleton instance (recommended)
const logger = noOpLogger;
// Or create new instance (functionally identical)
const logger = createNoOpLogger();
```
**When to use:** * Production-like test runs where you want clean output * CI/CD pipelines where logs would be noise * Any situation where you don’t need Scenarist debugging ## Environment Variable Pattern For easy toggling without code changes:
```typescript
import {
createScenarist,
createConsoleLogger,
noOpLogger,
type LogLevel,
type LogFormat,
} from '@scenarist/express-adapter';
// Type-safe environment variable parsing
const LOG_LEVELS: ReadonlyArray> = [
'error', 'warn', 'info', 'debug', 'trace',
];
const LOG_FORMATS: ReadonlyArray = ['pretty', 'json'];
const parseLogLevel = (value: string | undefined): Exclude =>
LOG_LEVELS.includes(value as Exclude)
? (value as Exclude)
: 'info';
const parseLogFormat = (value: string | undefined): LogFormat =>
LOG_FORMATS.includes(value as LogFormat) ? (value as LogFormat) : 'pretty';
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test',
scenarios,
// Enable logging via SCENARIST_LOG=1
logger: process.env.SCENARIST_LOG
? createConsoleLogger({
level: parseLogLevel(process.env.SCENARIST_LOG_LEVEL),
format: parseLogFormat(process.env.SCENARIST_LOG_FORMAT),
})
: noOpLogger,
});
```
These env vars are a convention, not automatic `SCENARIST_LOG`, `SCENARIST_LOG_LEVEL`, and `SCENARIST_LOG_FORMAT` are **conventions for your code**, not something Scenarist reads automatically. Setting `SCENARIST_LOG=1` alone does nothing—you must explicitly pass a `logger` to `createScenarist()` as shown above. This pattern gives you full control over when and how logging is enabled. Then run tests with logging:
```bash
# Enable info-level logging
SCENARIST_LOG=1 pnpm test
# Enable debug-level logging
SCENARIST_LOG=1 SCENARIST_LOG_LEVEL=debug pnpm test
# JSON format for CI
SCENARIST_LOG=1 SCENARIST_LOG_FORMAT=json pnpm test
```
## Vitest Configuration Required for Vitest By default, Vitest captures console output and only displays it for **failed** tests. To see Scenarist logging output for all tests, you must configure Vitest to disable console interception. Add `disableConsoleIntercept: true` to your `vitest.config.ts`: vitest.config.ts
```typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// REQUIRED: Show Scenarist logging output for all tests
// Without this, console output is only shown for failed tests
disableConsoleIntercept: true,
// ... your other config
},
});
```
Then run tests with logging enabled:
```bash
SCENARIST_LOG=1 pnpm test
```
You’ll see output like:
```plaintext
09:00:12.269 DBG [unknown] 🎬 scenario scenario_registered scenarioId="default" mockCount=4
09:00:12.270 DBG [unknown] 🎬 scenario scenario_registered scenarioId="success" mockCount=3
09:00:12.308 INF [my-test-id] 🎬 scenario scenario_switched
```
## Debugging Common Issues ### “Why isn’t my mock being selected?” Enable debug logging for the `matching` category:
```typescript
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching'],
});
```
You’ll see: * `mock_candidates_found` - How many mocks could potentially match * `mock_match_evaluated` - Each mock’s evaluation result and specificity score * `mock_selected` - Which mock was chosen and why * `mock_no_match` - When no mock matched (with the URL that failed) ### “What scenario is active?” Enable info logging for the `scenario` category:
```typescript
const logger = createConsoleLogger({
level: 'info',
categories: ['scenario'],
});
```
You’ll see: * `scenario_switched` - When a test switches scenarios * `scenario_not_found` - When a requested scenario doesn’t exist ### “Is state being captured correctly?” Enable debug logging for the `state` category:
```typescript
const logger = createConsoleLogger({
level: 'debug',
categories: ['state'],
});
```
You’ll see: * `state_set` - When `afterResponse.setState` updates state * `state_response_resolved` - Which `stateResponse` condition (or default) was used ## Custom Loggers Implement the `Logger` interface to integrate with your logging library:
```typescript
import type { Logger, LogCategory, LogContext } from '@scenarist/core';
import pino from 'pino';
const pinoInstance = pino({ level: 'debug' });
const pinoLogger: Logger = {
error: (category, message, context, data) =>
pinoInstance.error({ category, ...context, ...data }, message),
warn: (category, message, context, data) =>
pinoInstance.warn({ category, ...context, ...data }, message),
info: (category, message, context, data) =>
pinoInstance.info({ category, ...context, ...data }, message),
debug: (category, message, context, data) =>
pinoInstance.debug({ category, ...context, ...data }, message),
trace: (category, message, context, data) =>
pinoInstance.trace({ category, ...context, ...data }, message),
isEnabled: (level) => pinoInstance.isLevelEnabled(level),
};
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: pinoLogger,
});
```
## API Reference ### `createConsoleLogger(config)` Create a console logger with the specified configuration.
```typescript
function createConsoleLogger(config: ConsoleLoggerConfig): Logger;
type ConsoleLoggerConfig = {
level: Exclude;
format?: 'pretty' | 'json';
categories?: ReadonlyArray;
};
```
### `noOpLogger` Singleton logger that does nothing.
```typescript
const noOpLogger: Logger;
```
### `createNoOpLogger()` Factory function to create a no-op logger.
```typescript
function createNoOpLogger(): Logger;
```
### `Logger` Interface The port interface all loggers implement:
```typescript
interface Logger {
error(category: LogCategory, message: string, context: LogContext, data?: Record): void;
warn(category: LogCategory, message: string, context: LogContext, data?: Record): void;
info(category: LogCategory, message: string, context: LogContext, data?: Record): void;
debug(category: LogCategory, message: string, context: LogContext, data?: Record): void;
trace(category: LogCategory, message: string, context: LogContext, data?: Record): void;
isEnabled(level: Exclude): boolean;
}
```
### Types
```typescript
type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
type LogCategory =
| 'lifecycle'
| 'scenario'
| 'matching'
| 'sequence'
| 'state'
| 'template'
| 'request';
type LogContext = {
readonly testId?: string;
readonly scenarioId?: string;
readonly requestUrl?: string;
readonly requestMethod?: string;
};
```
## Performance Considerations Scenarist’s logging is designed for minimal overhead: ### NoOpLogger: Zero Cost When logging is disabled (the default), `noOpLogger` is used. Its methods are empty functions that V8’s JIT compiler can inline and potentially eliminate entirely:
```typescript
// NoOpLogger implementation - effectively no-op
export const noOpLogger: Logger = {
error: () => {},
warn: () => {},
info: () => {},
debug: () => {},
trace: () => {},
isEnabled: () => false,
};
```
### Lazy Evaluation For high-frequency code paths, use `isEnabled()` to skip expensive string formatting when logging is disabled:
```typescript
// Check before formatting expensive context
if (logger.isEnabled('debug')) {
logger.debug('matching', 'Details', context, {
body: JSON.stringify(largeObject), // Only formatted if needed
headers: Object.fromEntries(request.headers),
});
}
```
This pattern ensures that expensive operations like: * JSON serialization * Object transformation * String concatenation …are only performed when the output will actually be used. ### V8 Inlining Simple function calls like `logger.debug(category, message, context, data)` are candidates for V8’s inlining optimization when hot. The straightforward signature and return type (`void`) help V8 make efficient inlining decisions. ## Next Steps * [Testing Best Practices](/testing/best-practices/) - Patterns for organizing tests * [Parallel Testing](/testing/parallel-testing/) - Run tests concurrently * [Architecture](/concepts/architecture/) - Understand the ports and adapters pattern
# Verification Guide
> How to verify Scenarist is working correctly in your project
When evaluating whether Scenarist is working correctly in your project, use this guide to verify core functionality, integration quality, and test coverage. ## Core Functionality Checks ### Header Propagation in Parallel Tests **Issue:** When tests fail in parallel but pass sequentially, the root cause is usually **test ID headers not being propagated** through server-side fetch calls. **How Test Isolation Works:** Each test gets a unique test ID (automatically generated). This test ID must be sent with EVERY request to ensure the test uses the correct scenario. If headers aren’t propagated through internal server-side fetches, those requests will use the default scenario instead of the test’s scenario. **Common Symptom:** * ✅ Tests pass when run individually (`--workers=1`) * ❌ Tests fail when run in parallel (`--workers=4`) * ❌ Flaky results that change between runs * ❌ Wrong data appearing in tests (from different scenario) **Root Cause: Missing Header Propagation** When your server-side code makes internal fetch calls, headers don’t automatically propagate. You must explicitly include them. #### Next.js: Header Propagation Helpers **Problem:**
```typescript
// ❌ BAD - Headers not propagated to internal fetch
export default async function Page() {
// This fetch doesn't include test ID header!
const response = await fetch('https://api.stripe.com/v1/products');
const data = await response.json();
return {/* render */}
;
}
```
**Solution for Server Components** (use `getScenaristHeadersFromReadonlyHeaders`):
```typescript
import { headers } from 'next/headers';
import { getScenaristHeadersFromReadonlyHeaders } from '@scenarist/nextjs-adapter/app';
// ✅ GOOD - Headers propagated correctly in Server Components
export 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
},
cache: 'no-store',
});
const data = await response.json();
return {/* render */}
;
}
```
**Solution for Route Handlers** (use `getScenaristHeaders`):
```typescript
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
// ✅ GOOD - Headers propagated correctly in Route Handlers
export async function GET(request: Request) {
const response = await fetch("https://api.stripe.com/v1/products", {
headers: {
...getScenaristHeaders(request), // Include test ID header
},
cache: "no-store",
});
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 in production builds, where no Scenarist instance exists (returns empty object) #### Express: Headers Already Tracked Express adapter uses AsyncLocalStorage to automatically track test IDs per request. No manual header propagation needed for middleware chains. **Internal fetch calls** still need headers:
```typescript
// ✅ GOOD - Include test ID in internal fetches
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 || "default-test",
},
});
const data = await response.json();
res.json(data);
});
```
#### Verification: Use Playwright Tests **Verify headers are propagating correctly:** tests/header-propagation.spec.ts
```typescript
test("headers propagate through server-side fetch", async ({
page,
switchScenario,
}) => {
await switchScenario(page, "premium-user");
await page.goto("/dashboard");
// If headers propagated correctly, should see premium content
await expect(page.getByText("Premium Features")).toBeVisible();
// If headers DIDN'T propagate, would see default content
// This would fail in parallel tests (wrong scenario)
});
```
**Debugging failed tests:** 1. Add logging to see which scenario is active 2. Check server logs for test ID headers 3. Verify header helpers are called before fetch 4. Confirm headers object includes test ID #### Red Flags **❌ Tests fail only in parallel:**
```bash
# Pass individually
pnpm exec playwright test --workers=1
# ✅ All tests pass
# Fail in parallel
pnpm exec playwright test --workers=4
# ❌ Some tests fail with wrong data
```
**Root cause:** Missing header propagation. Tests interfere because they’re all using the default scenario. **❌ Wrong data in tests:**
```typescript
// Test expects premium pricing
await expect(page.getByText("£99.99")).toBeVisible();
// ❌ Error: element not found
// But sees standard pricing instead
await expect(page.getByText("£149.99")).toBeVisible();
// ✅ This passes (wrong scenario!)
```
**Root cause:** Internal fetch didn’t include test ID header, used default scenario instead of premium scenario. **❌ Flaky test results:** * Sometimes premium pricing, sometimes standard * Different results on different runs * Race conditions between parallel tests **Root cause:** Tests sharing scenarios due to missing header propagation. #### Fix Checklist When parallel tests fail: 1. ✅ **Next.js:** Add header helpers before all internal fetch calls (use `getScenaristHeadersFromReadonlyHeaders` in Server Components, `getScenaristHeaders` in Route Handlers) 2. ✅ **Express:** Include test ID header in internal fetch calls 3. ✅ **Playwright:** Verify tests switch scenarios before navigation 4. ✅ **Logging:** Add debug logs to confirm headers are present 5. ✅ **Isolation:** Ensure each test calls `switchScenario()` independently **Quick fix for Next.js Server Components:**
```typescript
import { headers } from "next/headers";
import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app";
// Add this before EVERY external fetch in Server Components
const headersList = await headers();
fetch(url, {
headers: { ...getScenaristHeadersFromReadonlyHeaders(headersList) },
cache: "no-store",
}); // Always include
```
**Quick fix for Next.js Route Handlers:**
```typescript
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
// In your route handler: export async function GET(request: Request)
fetch(url, {
headers: { ...getScenaristHeaders(request) },
cache: "no-store",
}); // Always include
```
### Runtime Scenario Switching **Verify:** Scenarios can be switched without server restarts
```typescript
test("scenario switching", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
await page.goto("/dashboard");
await expect(page.getByText("Premium Features")).toBeVisible();
await switchScenario(page, "free");
await page.goto("/dashboard");
await expect(page.getByText("Upgrade to Premium")).toBeVisible();
});
```
**Expected behavior:** * Scenario changes take effect immediately * No server restart required * Different responses from same endpoints * State is cleared when switching scenarios **Red flags:** * Need to restart server between scenario changes * Scenario switches not taking effect * Previous scenario behavior persisting ### Test ID Isolation **Verify:** Each test has isolated state via unique test ID
```typescript
test("test 1: add item to cart", async ({ page, switchScenario }) => {
await switchScenario(page, "cart");
await page.goto("/cart");
await page.click('[data-testid="add-product-1"]');
const items = await page.locator('[data-testid="cart-item"]').count();
expect(items).toBe(1);
});
test("test 2: empty cart", async ({ page, switchScenario }) => {
await switchScenario(page, "cart");
await page.goto("/cart");
const items = await page.locator('[data-testid="cart-item"]').count();
expect(items).toBe(0); // Should be empty, not affected by test 1
});
```
**Expected behavior:** * Each test starts with clean state * Test 2 doesn’t see items added in Test 1 * Tests can run in any order **Red flags:** * Tests depend on execution order * State leaking between tests * Need to manually clean up state ### Real Backend Execution **Verify:** Backend code executes with real middleware and routing
```typescript
test("middleware executes", async ({ page, switchScenario }) => {
await switchScenario(page, "logged-in");
// Should trigger auth middleware
const response = await page.goto("/protected");
// Middleware should have run and allowed access
expect(response?.status()).toBe(200);
await expect(page.getByText("Protected Content")).toBeVisible();
});
```
**Expected behavior:** * Middleware chains execute normally * Route handlers run with production logic * Business logic processes responses correctly * Only external API calls are mocked **Red flags:** * Middleware is being skipped or mocked * Business logic not executing as in production * Framework internals are mocked ## Integration Quality Checks ### External APIs Only **Verify:** Only external API calls are mocked, not framework internals **Check your test setup:**
```typescript
import type { ScenaristScenarios } from "@scenarist/express-adapter";
// ✅ GOOD - Only external APIs mocked
const scenarios = {
stripe: {
mocks: [
{
method: "POST",
url: "https://api.stripe.com/v1/charges", // External API
response: {
/* ... */
},
},
],
},
} as const satisfies ScenaristScenarios;
// ❌ BAD - Mocking framework internals
const badScenarios = {
nextjs: {
mocks: [
{
method: "GET",
url: "/api/my-endpoint", // Your own endpoint!
response: {
/* ... */
},
},
],
},
}; // Don't do this - mocking your own routes defeats the purpose
```
**What to mock:** * ✅ Stripe API calls * ✅ Auth0 API calls * ✅ SendGrid API calls * ✅ Any external HTTP service **What NOT to mock:** * ❌ Your own API routes * ❌ Framework request/response objects * ❌ Internal middleware ### Scenario Reusability **Verify:** Scenario definitions work across different test suites
```typescript
// scenarios.ts - Shared across all tests
export const premiumScenario = {
id: "premium",
mocks: [
/* ... */
],
};
// Used in multiple test files:
// tests/dashboard.spec.ts
test("dashboard shows premium features", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// ...
});
// tests/checkout.spec.ts
test("checkout with premium discount", async ({ page, switchScenario }) => {
await switchScenario(page, "premium");
// ...
});
```
**Expected behavior:** * Same scenario definition works everywhere * No need to duplicate scenario logic * Changes to scenario affect all tests using it **Red flags:** * Duplicating scenario definitions across test files * Different scenarios for same behavior * Scenarios tightly coupled to specific tests ### Mock Accuracy **Verify:** Mock definitions accurately represent external API contracts
```typescript
// ✅ GOOD - Matches real Stripe response structure
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
id: 'ch_123',
object: 'charge',
amount: 5000,
currency: 'usd',
status: 'succeeded'
// Matches Stripe API documentation
}
}
}
// ❌ BAD - Doesn't match real API structure
{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 200,
body: {
success: true,
chargeId: '123'
// This is not how Stripe responds
}
}
}
```
**How to verify:** * Compare mock responses against API documentation * Use TypeScript types from API client libraries * Validate responses against API schemas if available * Test against real API in staging to verify mock accuracy ## Test Coverage Checks ### Edge Cases and Error States **Verify:** Can test edge cases without complex setup
```typescript
test("handles payment declined", async ({ page, switchScenario }) => {
await switchScenario(page, "payment-declined");
await page.goto("/checkout");
await page.click('[data-testid="submit-payment"]');
await expect(page.getByText("Payment declined")).toBeVisible();
});
test("handles auth timeout", async ({ page, switchScenario }) => {
await switchScenario(page, "auth-timeout");
const response = await page.goto("/dashboard");
expect(response?.status()).toBe(401);
});
```
**Expected behavior:** * Can test decline codes, timeouts, rate limits easily * No need to manipulate external APIs * Error states are deterministic and repeatable **Red flags:** * Can’t test specific error codes * Need complex setup to trigger errors * Error scenarios are flaky ### User Types and Tiers **Verify:** Multiple user types can be tested concurrently
```typescript
test.describe.parallel("User tiers", () => {
test("premium user experience", async ({ page, switchScenario }) => {
await switchScenario(page, "premium-user");
// ... test premium features
});
test("free user experience", async ({ page, switchScenario }) => {
await switchScenario(page, "free-user");
// ... test limited features
});
test("trial user experience", async ({ page, switchScenario }) => {
await switchScenario(page, "trial-user");
// ... test trial features
});
});
```
**Expected behavior:** * All tier tests run in parallel * Each test uses correct tier data * No interference between tiers ### API Retry Logic **Verify:** Retry logic and rate limiting can be tested
```typescript
{
mocks: [
{
method: "POST",
url: "https://api.example.com/action",
sequence: {
responses: [
{ status: 500, body: { error: "Server error" } },
{ status: 500, body: { error: "Server error" } },
{ status: 200, body: { success: true } },
],
repeat: "last",
},
},
];
}
test("retries on failure", async ({ page, switchScenario }) => {
await switchScenario(page, "retry-scenario");
// First two attempts fail, third succeeds
await page.click('[data-testid="submit"]');
// Should eventually show success after retries
await expect(page.getByText("Success")).toBeVisible({ timeout: 10000 });
});
```
**Expected behavior:** * Sequence advances through responses * Retry logic can be verified * Rate limiting scenarios testable ### Performance **Verify:** Tests remain fast for frequent execution **Benchmarks to aim for:** * Test setup (scenario switch): < 100ms * HTTP requests to your backend: Same speed as without Scenarist * Mock response time: < 10ms * Full test suite: Suitable for watch mode during development **Red flags:** * Tests are slower than unit tests * Scenario switching takes multiple seconds * Mock overhead is noticeable ## Production Tree-Shaking Verification When deploying Scenarist to production, it’s critical to verify that implementation code is NOT being delivered to production. The adapters use **conditional exports**: when your bundler resolves the `production` export condition, the adapter entry point resolves to a stub whose `createScenarist()` returns `undefined` and imports nothing. You should verify this is working correctly in your build. ### How Conditional Exports Work 1. The adapter’s `package.json` maps the `production` export condition to a `production.js` stub 2. Your bundler resolves that condition when building for production 3. The stub’s `createScenarist()` returns `undefined` and has no imports 4. **Result:** Neither the Scenarist implementation nor MSW is included in the build output ### Step 1: Build Your Application Build with the `production` condition enabled:
```bash
# esbuild (--conditions=production is required)
esbuild src/server.ts --bundle --platform=node --format=esm \
--outfile=dist/server.js \
--define:process.env.NODE_ENV='"production"' \
--conditions=production
# Next.js
NODE_ENV=production next build
```
For other bundlers, make sure `production` is included in the resolve conditions for production builds. ### Step 2: Verify Implementation is NOT in the Build Output
```bash
# Search for MSW implementation code in the build output
grep -rE '(setupWorker|HttpResponse\.json)' dist/
# Should output nothing (no matches) ✅
```
**Expected result:** No matches. If you see matches, the bundler resolved the default (non-production) entry point and bundled the implementation. ### Red Flags **❌ Implementation code in the build output:**
```bash
$ grep -rE '(setupWorker|HttpResponse\.json)' dist/
# Found matches ← BAD: Implementation bundled
```
**Fix:** Make sure the bundler resolves the `production` export condition: * esbuild: Add `--conditions=production` * Next.js: Build with `NODE_ENV=production` * Other bundlers: Add `production` to the resolve conditions ### Framework-Specific Verification #### Express
```bash
# Build (esbuild with --conditions=production)
pnpm build:production
# Verify implementation not in output
! grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/
# (no matches)
```
#### Next.js App Router and Pages Router
```bash
# Build
NODE_ENV=production next build
# Verify implementation not in output
! grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' .next/ --exclude-dir=cache
# (no matches)
```
### What This Proves ✅ **Zero delivery overhead** - Implementation code never reaches production runtime ✅ **Conditional exports work** - Bundler resolved the production stub ✅ **Production safety** - Test infrastructure code completely absent from production execution ### Next Steps If verification fails: 1. Check that your bundler resolves the `production` export condition 2. Verify `NODE_ENV=production` is set for the build 3. Review [Production Safety Guide](/concepts/production-safety/) for detailed configuration If verification succeeds: ✅ Your production deployment is safe - Scenarist implementation code is completely tree-shaken! ## Common Issues to Watch ### Issue: Tests Interfere With Each Other **Symptoms:** * Tests pass individually but fail in parallel * Flaky results * State leaking between tests **Check:** * Verify each test calls `switchScenario()` before actions * Ensure test IDs are unique (generated automatically by helper) * Check that state is isolated per test ID **Fix:**
```typescript
// ✅ GOOD - Each test switches scenario
test("test 1", async ({ page, switchScenario }) => {
await switchScenario(page, "scenario1"); // Isolates this test
// ...
});
// ❌ BAD - Shared scenario across tests
const scenarioId = "shared";
test("test 1", async ({ page, switchScenario }) => {
await switchScenario(page, scenarioId); // Don't share!
// ...
});
```
### Issue: Framework Internals Are Mocked **Symptoms:** * Middleware doesn’t execute * Business logic is bypassed * Tests don’t reflect production behavior **Check:** * Review mock URLs - should all be external APIs * Verify your route handlers execute normally * Check that middleware chains run **Fix:**
```typescript
// ❌ BAD - Mocking your own routes
{
url: 'http://localhost:3000/api/my-route', // Your app!
response: { /* ... */ }
}
// ✅ GOOD - Mocking external APIs only
{
url: 'https://api.stripe.com/v1/charges', // External!
response: { /* ... */ }
}
```
### Issue: Scenarios Can’t Switch at Runtime **Symptoms:** * Need to restart server to change scenarios * Scenario changes don’t take effect * Old scenario behavior persists **Check:** * Verify `/__scenario__` endpoint is registered * Check that `createScenarist()` returned an instance (it returns `undefined` in production builds) * Ensure test ID headers are being sent **Fix:** * Check adapter setup in your application * Verify Playwright helpers are configured correctly * Check for errors in scenario registration ### Issue: Tests Are Slow **Symptoms:** * Tests slower than expected * Long wait times for responses * Unsuitable for watch mode **Check:** * Look for unnecessary `delay` in mock responses * Check for browser interactions that could be HTTP-only * Verify parallel execution is enabled **Fix:**
```typescript
// Remove unnecessary delays
{
response: {
status: 200,
body: { /* ... */ },
// delay: 5000 ← Remove this
}
}
// Use parallel execution
test.describe.parallel('Fast tests', () => {
// Tests run concurrently
});
```
## Next Steps If verification reveals issues: 1. **Review framework guides** - Ensure adapter is set up correctly * [Next.js →](/frameworks/nextjs-app-router/getting-started/) * [Express →](/frameworks/express/getting-started/) 2. **Check scenario definitions** - Verify mocks match external APIs * [Writing Scenarios →](/scenarios/overview/) 3. **Examine test setup** - Ensure test isolation is working * [How it works: Test Isolation →](/concepts/how-it-works/#how-test-isolation-works-complete-request-flow) 4. **Consult architecture docs** - Understand how pieces fit together * [Architecture →](/concepts/architecture/)