Skip to content

State-Aware Mocking

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

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 POST
sequence: {
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.

CapabilityPurposeCategory
stateResponseReturn different responses based on current stateState-Driven Responses
afterResponse.setStateMutate state after returning a responseState Transitions
match.stateSelect which mock handles a request based on stateState-Driven Matching

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 default response
  • If state.step === 'submitted' → returns “reviewing” response
  • If state.step === 'reviewed' → returns “approved” response

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)

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:

  1. Mock returns the response ({ success: true })
  2. After response is sent, state is updated (step: 'submitted')
  3. Subsequent requests see the new state

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:

  1. If condition matched AND has afterResponse key → use condition’s (including null)
  2. If condition matched AND has no afterResponse key → use mock-level afterResponse
  3. 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).

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 }
}
}

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 state
const 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.

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' } }
}
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();
});
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 }
}
}
]
};
FeatureUse When
stateResponseOne endpoint, multiple possible responses based on accumulated state
afterResponse.setStateNeed to advance workflow state after a request
match.stateSame endpoint needs completely different mock behavior based on state
captureStateNeed to capture request data for injection into responses
sequenceBehavior depends on call count (predictable number of calls)
AspectState-Aware MockingSequences
Changes based onAccumulated stateCall count
Predictable callsNot requiredRequired
Use caseWorkflows, state machinesPolling with known count
ResilienceResilient to re-rendersFragile with variable calls

Rule of thumb: If you find yourself padding sequences with extra responses “just in case,” switch to state-aware mocking.

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) - simultaneous
GET /status → 'pending' (independent state)

State resets when:

  • Scenario switches (clean slate for new scenario)
  • Test ends (test ID cleaned up)

This ensures idempotent tests.

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 }
}
}
┌──────────────────────────────────────────────────┐
│ Test ID: test-checkout-123 │
│ ┌─────────────────────────────────────────────┐ │
│ │ Shared State │ │
│ │ { 'cart.items': 3, 'user.tier': 'premium' }│ │
│ └─────────────────────────────────────────────┘ │
│ ↑ write ↑ write ↓ read │
│ POST /cart/add POST /login GET /pricing │
└──────────────────────────────────────────────────┘

Since state is shared, use namespaced keys to avoid collisions:

// ✅ DO: Namespace your keys by domain
afterResponse: { setState: { 'cart.itemCount': 3 } }
afterResponse: { setState: { 'user.authenticated': true } }
when: { 'cart.itemCount': 3, 'user.authenticated': true }
// ❌ DON'T: Use generic keys that could collide
afterResponse: { setState: { count: 3 } } // What count?
afterResponse: { setState: { status: 'active' } } // Which status?

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.

When tests fail, you often need to inspect the current state to understand what went wrong. Scenarist provides debug tools for this.

All adapters expose a debug endpoint at GET /__scenarist__/state:

Terminal window
curl -H "x-scenarist-test-id: test-123" http://localhost:3000/__scenarist__/state

Response:

{
"testId": "test-123",
"state": {
"cart.items": 3,
"user.tier": "premium",
"checkout.started": true
}
}

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
},
});