State-Aware Mocking
What This Enables
Section titled “What This Enables”Build state machines where mock responses depend on accumulated state from previous requests. Perfect for testing workflows where the same endpoint returns different data based on what happened earlier.
Use cases:
- Loan applications: Status changes from “pending” → “reviewing” → “approved” based on form submissions
- Multi-step workflows: Same GET returns different data after POSTs modify state
- Feature flags: Toggle behavior via API, subsequent requests reflect the change
- Authentication flows: Login sets state, protected endpoints check it
The Problem It Solves
Section titled “The Problem It Solves”Response Sequences work when you can predict the exact number of calls:
// This works IF you know there will be exactly 3 calls before the POSTsequence: { responses: [ { body: { status: 'pending' } }, { body: { status: 'pending' } }, { body: { status: 'pending' } }, { body: { status: 'approved' } }, ]}But with modern frontends (React re-renders, middleware, async timing), call counts are unpredictable. You might need 11 “pending” responses in one test and 15 in another.
State-aware mocking solves this: Response changes based on state, not call count.
Three Capabilities
Section titled “Three Capabilities”| Capability | Purpose | Category |
|---|---|---|
stateResponse | Return different responses based on current state | State-Driven Responses |
afterResponse.setState | Mutate state after returning a response | State Transitions |
match.state | Select which mock handles a request based on state | State-Driven Matching |
State-Driven Responses: stateResponse
Section titled “State-Driven Responses: stateResponse”Return different responses from a single mock based on current test state. Use when one endpoint needs multiple possible responses depending on accumulated workflow state.
import type { ScenaristMock } from '@scenarist/express-adapter';
const mock: ScenaristMock = { method: 'GET', url: '/api/application/status', stateResponse: { default: { status: 200, body: { status: 'pending', message: 'Application not yet submitted' } }, conditions: [ { when: { step: 'submitted' }, then: { status: 200, body: { status: 'reviewing', message: 'Under review' } } }, { when: { step: 'reviewed' }, then: { status: 200, body: { status: 'approved', message: 'Application approved' } } } ] }};Behavior:
- If state is empty or has no matching condition → returns
defaultresponse - If
state.step === 'submitted'→ returns “reviewing” response - If
state.step === 'reviewed'→ returns “approved” response
Specificity-Based Selection
Section titled “Specificity-Based Selection”When multiple conditions match, the most specific one wins (more keys = more specific):
conditions: [ // Specificity: 1 (one key) { when: { step: 'reviewed' }, then: { body: { tier: 'basic' } } }, // Specificity: 2 (two keys) - wins when both match { when: { step: 'reviewed', urgent: true }, then: { body: { tier: 'priority' } } }]
// State: { step: 'reviewed', urgent: true }// → Returns 'priority' (2 keys beats 1 key)State Transitions: afterResponse.setState
Section titled “State Transitions: afterResponse.setState”Mutate test state after returning a response. Use to advance workflow state when a request completes.
{ method: 'POST', url: '/api/application/submit', response: { status: 200, body: { success: true, message: 'Submitted' } }, afterResponse: { setState: { step: 'submitted' } }}Behavior:
- Mock returns the response (
{ success: true }) - After response is sent, state is updated (
step: 'submitted') - Subsequent requests see the new state
Conditional afterResponse
Section titled “Conditional afterResponse”When using stateResponse, you can define condition-specific afterResponse to run different state mutations based on which condition matched:
{ method: 'GET', url: '/api/loan/status', stateResponse: { default: { status: 200, body: { status: 'pending' } }, conditions: [ { when: { submitted: true }, then: { status: 200, body: { status: 'reviewing' } }, afterResponse: { setState: { phase: 'review' } } // Condition-specific }, { when: { approved: true }, then: { status: 200, body: { status: 'complete' } }, afterResponse: null // Explicitly no mutation } ] }, afterResponse: { setState: { phase: 'initial' } } // Fallback for default}Resolution logic:
- If condition matched AND has
afterResponsekey → use condition’s (includingnull) - If condition matched AND has no
afterResponsekey → use mock-level afterResponse - If default matched → use mock-level afterResponse
Key insight: afterResponse: null means “explicitly no state mutation” - different from omitting it (which inherits from mock-level).
Works with Any Response Type
Section titled “Works with Any Response Type”afterResponse.setState combines with response, sequence, or stateResponse:
// With sequence{ method: 'POST', url: '/api/verify', sequence: { responses: [ { status: 200, body: { verified: false } }, { status: 200, body: { verified: true } } ], repeat: 'last' }, afterResponse: { setState: { verificationAttempted: true } }}
// With stateResponse{ method: 'POST', url: '/api/process', stateResponse: { default: { body: { processed: false } }, conditions: [ { when: { ready: true }, then: { body: { processed: true } } } ] }, afterResponse: { setState: { processAttempted: true } }}State-Driven Matching: match.state
Section titled “State-Driven Matching: match.state”Select which mock handles a request based on current state. Different from stateResponse (one mock, many responses) - this selects which mock.
// Same endpoint, different mocks based on stateconst mocks = [ // When step is 'initial' → transition to 'reviewed' { method: 'POST', url: '/api/review', match: { state: { step: 'initial' } }, response: { body: { newStatus: 'pending_approval' } }, afterResponse: { setState: { step: 'reviewed' } } }, // When step is 'reviewed' → transition to 'approved' { method: 'POST', url: '/api/review', match: { state: { step: 'reviewed' } }, response: { body: { newStatus: 'approved' } }, afterResponse: { setState: { step: 'approved' } } }, // Fallback (no state match) → transition to 'reviewed' { method: 'POST', url: '/api/review', response: { body: { newStatus: 'pending_approval' } }, afterResponse: { setState: { step: 'reviewed' } } }];Use case: Same endpoint needs completely different behavior (not just different response data) based on workflow state.
Combined with Other Match Criteria
Section titled “Combined with Other Match Criteria”match.state works with existing match criteria (AND logic):
{ method: 'POST', url: '/api/review', match: { state: { step: 'pending_review' }, body: { decision: 'approve' } }, response: { body: { status: 'approved' } }, afterResponse: { setState: { step: 'approved' } }},{ method: 'POST', url: '/api/review', match: { state: { step: 'pending_review' }, body: { decision: 'reject' } }, response: { body: { status: 'rejected' } }, afterResponse: { setState: { step: 'rejected' } }}Complete Example: Loan Application
Section titled “Complete Example: Loan Application”import type { ScenaristScenario } from '@scenarist/express-adapter';
export const loanApplicationScenario: ScenaristScenario = { id: 'loan-application', name: 'Loan Application Workflow', description: 'State-aware loan workflow with automatic state transitions', mocks: [ // GET status - responds based on workflow state { method: 'GET', url: 'https://api.loans.com/application/status', stateResponse: { default: { status: 200, body: { status: 'pending', message: 'Not yet submitted' } }, conditions: [ { when: { step: 'submitted' }, then: { status: 200, body: { status: 'reviewing', message: 'Under review' } } }, { when: { step: 'reviewed' }, then: { status: 200, body: { status: 'approved', message: 'Approved!' } } } ] } }, // POST submit - advances state to 'submitted' { method: 'POST', url: 'https://api.loans.com/application/submit', response: { status: 200, body: { success: true, message: 'Application submitted' } }, afterResponse: { setState: { step: 'submitted' } } }, // POST review - advances state to 'reviewed' { method: 'POST', url: 'https://api.loans.com/application/review', response: { status: 200, body: { success: true, message: 'Review completed' } }, afterResponse: { setState: { step: 'reviewed' } } } ]};Test workflow:
test('loan application workflow', async ({ page, switchScenario }) => { await switchScenario(page, 'loan-application');
// Initial state - pending await page.goto('/application'); await expect(page.getByText('Not yet submitted')).toBeVisible();
// Submit form - advances state await page.click('[data-action="submit"]');
// Now shows reviewing await page.goto('/application'); await expect(page.getByText('Under review')).toBeVisible();
// Complete review - advances state again await page.click('[data-action="review"]');
// Now shows approved await page.goto('/application'); await expect(page.getByText('Approved!')).toBeVisible();});Complete Example: Feature Flags
Section titled “Complete Example: Feature Flags”export const featureFlagsScenario: ScenaristScenario = { id: 'feature-flags', name: 'Feature Flags', description: 'Toggle features via API, subsequent requests reflect changes', mocks: [ // Toggle feature flag - captures state { method: 'POST', url: 'https://api.features.com/flags', captureState: { premiumEnabled: 'body.enabled' }, response: { status: 200, body: { success: true, message: 'Flag updated' } } }, // GET pricing - premium when flag enabled (match.state) { method: 'GET', url: 'https://api.pricing.com/pricing', match: { state: { premiumEnabled: true } }, response: { status: 200, body: { tier: 'premium', price: 50, discount: '50% off' } } }, // GET pricing - standard (fallback) { method: 'GET', url: 'https://api.pricing.com/pricing', response: { status: 200, body: { tier: 'standard', price: 100 } } } ]};When to Use What
Section titled “When to Use What”| Feature | Use When |
|---|---|
stateResponse | One endpoint, multiple possible responses based on accumulated state |
afterResponse.setState | Need to advance workflow state after a request |
match.state | Same endpoint needs completely different mock behavior based on state |
captureState | Need to capture request data for injection into responses |
sequence | Behavior depends on call count (predictable number of calls) |
State vs Sequences
Section titled “State vs Sequences”| Aspect | State-Aware Mocking | Sequences |
|---|---|---|
| Changes based on | Accumulated state | Call count |
| Predictable calls | Not required | Required |
| Use case | Workflows, state machines | Polling with known count |
| Resilience | Resilient to re-renders | Fragile with variable calls |
Rule of thumb: If you find yourself padding sequences with extra responses “just in case,” switch to state-aware mocking.
State Isolation
Section titled “State Isolation”State is isolated per test ID - parallel tests don’t interfere:
// Test 1 (test-id: abc-123)POST /submit → state.step = 'submitted'GET /status → 'reviewing'
// Test 2 (test-id: xyz-789) - simultaneousGET /status → 'pending' (independent state)State Reset
Section titled “State Reset”State resets when:
- Scenario switches (clean slate for new scenario)
- Test ends (test ID cleaned up)
This ensures idempotent tests.
Combining with Other Features
Section titled “Combining with Other Features”State-aware mocking combines with all existing features:
{ method: 'POST', url: '/api/checkout', match: { state: { cartReady: true }, // State matching headers: { 'x-tier': 'premium' } // Request matching }, stateResponse: { default: { body: { discount: 10 } }, conditions: [ { when: { loyaltyTier: 'gold' }, then: { body: { discount: 20 } } } ] }, afterResponse: { setState: { checkoutComplete: true } }}State Model
Section titled “State Model”┌──────────────────────────────────────────────────┐│ Test ID: test-checkout-123 ││ ┌─────────────────────────────────────────────┐ ││ │ Shared State │ ││ │ { 'cart.items': 3, 'user.tier': 'premium' }│ ││ └─────────────────────────────────────────────┘ ││ ↑ write ↑ write ↓ read ││ POST /cart/add POST /login GET /pricing │└──────────────────────────────────────────────────┘Namespace Your Keys
Section titled “Namespace Your Keys”Since state is shared, use namespaced keys to avoid collisions:
// ✅ DO: Namespace your keys by domainafterResponse: { setState: { 'cart.itemCount': 3 } }afterResponse: { setState: { 'user.authenticated': true } }when: { 'cart.itemCount': 3, 'user.authenticated': true }
// ❌ DON'T: Use generic keys that could collideafterResponse: { setState: { count: 3 } } // What count?afterResponse: { setState: { status: 'active' } } // Which status?Why Not Per-Endpoint?
Section titled “Why Not Per-Endpoint?”The primary use case is cross-endpoint coordination:
// POST /api/loan/submit sets state{ method: 'POST', url: '/api/loan/submit', response: { status: 200 }, afterResponse: { setState: { 'loan.submitted': true } },}
// GET /api/loan/status reads that state{ method: 'GET', url: '/api/loan/status', stateResponse: { default: { status: 200, body: { step: 'pending' } }, conditions: [ { when: { 'loan.submitted': true }, then: { status: 200, body: { step: 'reviewing' } } }, ], },}If state were per-endpoint, this coordination pattern wouldn’t work.
Debugging State
Section titled “Debugging State”When tests fail, you often need to inspect the current state to understand what went wrong. Scenarist provides debug tools for this.
Debug Endpoint
Section titled “Debug Endpoint”All adapters expose a debug endpoint at GET /__scenarist__/state:
curl -H "x-scenarist-test-id: test-123" http://localhost:3000/__scenarist__/stateResponse:
{ "testId": "test-123", "state": { "cart.items": 3, "user.tier": "premium", "checkout.started": true }}Playwright Debug Fixtures
Section titled “Playwright Debug Fixtures”For Playwright tests, use the debugState and waitForDebugState fixtures:
import { test, expect } from './fixtures';
test('checkout flow', async ({ page, switchScenario, debugState, waitForDebugState }) => { 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 }
// Wait for async state to stabilize await page.click('#checkout'); const finalState = await waitForDebugState( page, (s) => s['checkout.status'] === 'complete', { timeout: 10000 } );
expect(finalState['checkout.status']).toBe('complete');});Available fixtures:
debugState(page)- Fetch current state (no testId needed - fixture manages it)waitForDebugState(page, condition, options)- Poll until condition is met
Configure the endpoint in playwright.config.ts:
export default defineConfig({ use: { baseURL: 'http://localhost:3000', scenaristStateEndpoint: '/__scenarist__/state', // Default value },});Next Steps
Section titled “Next Steps”- Stateful Mocks → - Capture and inject request data
- Response Sequences → - Call-count based responses
- Request Matching → - Match on request content
- Combining Features → - Use all features together