Stateful mocks allow you to capture data from requests and inject it into subsequent responses. This enables testing realistic workflows where the backend "remembers" previous requests.
Many real-world scenarios require state that persists across multiple requests:
- Shopping Carts - Add items in multiple requests, then retrieve the full cart
- Multi-Step Forms - Capture user info across multiple form steps, inject into confirmation
- User Sessions - Login creates a session, subsequent requests use that session data
- Incremental Updates - PATCH requests that accumulate changes over time
Without stateful mocks, you'd need to:
- Hardcode response data that doesn't match what was sent
- Create separate scenarios for every possible state combination
- Lose the ability to test realistic user journeys
With stateful mocks, you can test these flows naturally.
Let's build a simple shopping cart scenario that captures items and returns them.
import { ScenaristScenario } from "@scenarist/core";
export const shoppingCartScenario: ScenaristScenario = {
id: "shopping-cart",
name: "Shopping Cart with State",
description: "Add items to cart, then retrieve them",
mocks: [
// Add item endpoint - CAPTURES the item
{
method: "POST",
url: "https://api.store.com/cart/items",
captureState: {
"items[]": "body.item", // Append to items array
},
response: {
status: 200,
body: { success: true, message: "Item added" },
},
},
// Get cart endpoint - INJECTS the items
{
method: "GET",
url: "https://api.store.com/cart",
response: {
status: 200,
body: {
items: "{{state.items}}", // Inject items array
count: "{{state.items.length}}", // Inject array length
total: 0,
},
},
},
],
};import { test, expect } from "@playwright/test";
test("add items to cart and retrieve them", async ({ page, request }) => {
// Set the scenario
await request.post("http://localhost:3000/__scenario__", {
headers: { "x-scenarist-test-id": "cart-test-1" },
data: { scenario: "shopping-cart" },
});
// Add first item
await request.post("http://localhost:3000/api/cart/items", {
headers: { "x-scenarist-test-id": "cart-test-1" },
data: { item: "Apple" },
});
// Add second item
await request.post("http://localhost:3000/api/cart/items", {
headers: { "x-scenarist-test-id": "cart-test-1" },
data: { item: "Banana" },
});
// Get cart - should contain both items
const cart = await request.get("http://localhost:3000/api/cart", {
headers: { "x-scenarist-test-id": "cart-test-1" },
});
const cartData = await cart.json();
expect(cartData.items).toEqual(["Apple", "Banana"]);
expect(cartData.count).toBe(2); // Number, not "2"!
});- First POST captures
{ item: 'Apple' }→ State becomes{ items: ['Apple'] } - Second POST captures
{ item: 'Banana' }→ State becomes{ items: ['Apple', 'Banana'] } - GET injects state into response → Returns actual array and count
Key Feature: The items[] syntax appends to the array instead of replacing it.
Capture a single value from the request:
{
method: 'POST',
url: 'https://api.example.com/user',
captureState: {
userName: 'body.name', // Captures request.body.name
userEmail: 'body.email', // Captures request.body.email
},
response: { status: 200, body: { success: true } },
}Request: POST { "name": "Alice", "email": "alice@example.com" }
State after: { userName: "Alice", userEmail: "alice@example.com" }
Use [] suffix to append to an array instead of replacing:
{
method: 'POST',
url: 'https://api.example.com/cart/add',
captureState: {
'cartItems[]': 'body.item', // Appends to array
},
response: { status: 200, body: { success: true } },
}First request: POST { "item": "Apple" } → State: { cartItems: ['Apple'] }
Second request: POST { "item": "Banana" } → State: { cartItems: ['Apple', 'Banana'] }
Third request: POST { "item": "Cherry" } → State: { cartItems: ['Apple', 'Banana', 'Cherry'] }
Create nested objects using dot notation:
{
method: 'POST',
url: 'https://api.example.com/profile/update',
captureState: {
'user.profile.name': 'body.name',
'user.profile.bio': 'body.bio',
'user.settings.theme': 'body.theme',
},
response: { status: 200, body: { success: true } },
}Request: POST { "name": "Alice", "bio": "Developer", "theme": "dark" }
State after:
{
"user": {
"profile": {
"name": "Alice",
"bio": "Developer"
},
"settings": {
"theme": "dark"
}
}
}Capture from any part of the request:
{
method: 'POST',
url: 'https://api.example.com/session',
captureState: {
sessionToken: 'headers.authorization', // From Authorization header
userId: 'query.user_id', // From ?user_id=123
loginTime: 'body.timestamp', // From request body
},
response: { status: 200, body: { success: true } },
}Templates allow you to inject captured state into response bodies.
When the entire value is a template, the raw value is injected:
{
method: 'GET',
url: 'https://api.example.com/cart',
response: {
status: 200,
body: {
items: '{{state.cartItems}}', // → ['Apple', 'Banana'] (array)
count: '{{state.cartItems.length}}', // → 2 (number)
total: '{{state.totalPrice}}', // → 15.99 (number)
isActive: '{{state.isActive}}', // → true (boolean)
},
},
}Important: Pure templates preserve JavaScript types:
- Arrays remain arrays (not stringified)
- Numbers remain numbers (not converted to strings)
- Booleans remain booleans
- Objects remain objects
When a template is embedded in text, it's converted to a string:
{
method: 'GET',
url: 'https://api.example.com/greeting',
response: {
status: 200,
body: {
message: 'Hello {{state.userName}}, you have {{state.itemCount}} items',
// → 'Hello Alice, you have 5 items' (string)
},
},
}Access array properties using dot notation:
{
method: 'GET',
url: 'https://api.example.com/summary',
response: {
status: 200,
body: {
itemCount: '{{state.items.length}}', // → 3 (number)
message: 'You have {{state.items.length}} items', // → 'You have 3 items' (string)
},
},
}Access nested state using dot notation:
{
method: 'GET',
url: 'https://api.example.com/profile',
response: {
status: 200,
body: {
name: '{{state.user.profile.name}}',
email: '{{state.user.profile.email}}',
city: '{{state.user.address.city}}',
},
},
}If a state key doesn't exist, the template remains unchanged:
{
method: 'GET',
url: 'https://api.example.com/user',
response: {
status: 200,
body: {
name: '{{state.userName}}', // If userName not captured yet
// → '{{state.userName}}' (template stays as-is)
},
},
}This is useful for optional fields or debugging.
A common pattern is capturing data across multiple form steps and injecting it into a final confirmation.
export const multiStepFormScenario: ScenaristScenario = {
id: "multi-step-form",
name: "Multi-Step Form",
description: "Capture data across form steps",
mocks: [
// Step 1: Personal Info
{
method: "POST",
url: "https://api.example.com/form/step1",
captureState: {
"form.name": "body.name",
"form.email": "body.email",
"form.phone": "body.phone",
},
response: {
status: 200,
body: {
success: true,
nextStep: "/form/step2",
},
},
},
// Step 2: Address
{
method: "POST",
url: "https://api.example.com/form/step2",
captureState: {
"form.street": "body.street",
"form.city": "body.city",
"form.zipCode": "body.zipCode",
},
response: {
status: 200,
body: {
success: true,
message: "Thank you {{state.form.name}}!", // Inject from step 1
nextStep: "/form/step3",
},
},
},
// Step 3: Payment
{
method: "POST",
url: "https://api.example.com/form/step3",
captureState: {
"form.cardLast4": "body.cardNumber", // Just last 4 digits in real app
},
response: {
status: 200,
body: {
success: true,
nextStep: "/form/confirm",
},
},
},
// Final Confirmation - Inject ALL captured state
{
method: "GET",
url: "https://api.example.com/form/confirm",
response: {
status: 200,
body: {
success: true,
confirmation: {
// Personal info from step 1
name: "{{state.form.name}}",
email: "{{state.form.email}}",
phone: "{{state.form.phone}}",
// Address from step 2
street: "{{state.form.street}}",
city: "{{state.form.city}}",
zipCode: "{{state.form.zipCode}}",
// Payment from step 3
cardLast4: "{{state.form.cardLast4}}",
// Static data
confirmationId: "CONF-12345",
timestamp: "2024-01-15T10:30:00Z",
},
},
},
},
],
};test("complete multi-step form", async ({ request }) => {
const testId = "form-test-001";
// Set scenario
await request.post("http://localhost:3000/__scenario__", {
headers: { "x-scenarist-test-id": testId },
data: { scenario: "multi-step-form" },
});
// Step 1: Personal info
await request.post("http://localhost:3000/api/form/step1", {
headers: { "x-scenarist-test-id": testId },
data: {
name: "Alice Johnson",
email: "alice@example.com",
phone: "555-1234",
},
});
// Step 2: Address
const step2 = await request.post("http://localhost:3000/api/form/step2", {
headers: { "x-scenarist-test-id": testId },
data: {
street: "123 Main St",
city: "Portland",
zipCode: "97201",
},
});
const step2Data = await step2.json();
expect(step2Data.message).toBe("Thank you Alice Johnson!");
// Step 3: Payment
await request.post("http://localhost:3000/api/form/step3", {
headers: { "x-scenarist-test-id": testId },
data: { cardNumber: "1234" },
});
// Get confirmation - all data injected
const confirm = await request.get("http://localhost:3000/api/form/confirm", {
headers: { "x-scenarist-test-id": testId },
});
const confirmData = await confirm.json();
expect(confirmData.confirmation).toMatchObject({
name: "Alice Johnson",
email: "alice@example.com",
phone: "555-1234",
street: "123 Main St",
city: "Portland",
zipCode: "97201",
cardLast4: "1234",
});
});Each test ID has completely independent state:
// Test A adds "Apple" to cart
await request.post("/api/cart/items", {
headers: { "x-scenarist-test-id": "test-A" },
data: { item: "Apple" },
});
// Test B adds "Banana" to cart
await request.post("/api/cart/items", {
headers: { "x-scenarist-test-id": "test-B" },
data: { item: "Banana" },
});
// Test A gets cart - only sees "Apple"
const cartA = await request.get("/api/cart", {
headers: { "x-scenarist-test-id": "test-A" },
});
expect(cartA.items).toEqual(["Apple"]);
// Test B gets cart - only sees "Banana"
const cartB = await request.get("/api/cart", {
headers: { "x-scenarist-test-id": "test-B" },
});
expect(cartB.items).toEqual(["Banana"]);This allows parallel test execution without state interference.
When you switch scenarios, state is automatically reset:
// Set shopping cart scenario, add items
await request.post("/__scenario__", {
headers: { "x-scenarist-test-id": "test-1" },
data: { scenario: "shopping-cart" },
});
await request.post("/api/cart/items", {
headers: { "x-scenarist-test-id": "test-1" },
data: { item: "Apple" },
});
// Switch to different scenario - state is reset
await request.post("/__scenario__", {
headers: { "x-scenarist-test-id": "test-1" },
data: { scenario: "user-profile" },
});
// Switch back to shopping cart - state is empty (fresh start)
await request.post("/__scenario__", {
headers: { "x-scenarist-test-id": "test-1" },
data: { scenario: "shopping-cart" },
});
const cart = await request.get("/api/cart", {
headers: { "x-scenarist-test-id": "test-1" },
});
expect(cart.items).toBeUndefined(); // State was resetImportant: State is only reset on successful scenario switch. If the switch fails (scenario not found), state is preserved.
export const userSessionScenario: ScenaristScenario = {
id: "user-session",
name: "User Session",
description: "Login creates session, subsequent requests use it",
mocks: [
// Login - capture session token
{
method: "POST",
url: "https://api.example.com/auth/login",
captureState: {
sessionToken: "body.email", // Use email as session identifier
userId: "body.email",
},
response: {
status: 200,
body: {
token: "mock-jwt-token",
userId: "user-123",
},
},
},
// Get current user - inject session data
{
method: "GET",
url: "https://api.example.com/user/me",
response: {
status: 200,
body: {
id: "user-123",
email: "{{state.userId}}",
name: "Test User",
authenticated: true,
},
},
},
// Logout - could clear state (but state reset happens on scenario switch)
{
method: "POST",
url: "https://api.example.com/auth/logout",
response: {
status: 200,
body: { success: true },
},
},
],
};export const profileUpdateScenario: ScenaristScenario = {
id: "profile-updates",
name: "Incremental Profile Updates",
description: "PATCH requests accumulate changes",
mocks: [
// Update name
{
method: "PATCH",
url: "https://api.example.com/user/profile",
match: {
body: { name: true }, // Only if request contains 'name'
},
captureState: {
"profile.name": "body.name",
},
response: { status: 200, body: { success: true } },
},
// Update bio
{
method: "PATCH",
url: "https://api.example.com/user/profile",
match: {
body: { bio: true }, // Only if request contains 'bio'
},
captureState: {
"profile.bio": "body.bio",
},
response: { status: 200, body: { success: true } },
},
// Update avatar
{
method: "PATCH",
url: "https://api.example.com/user/profile",
match: {
body: { avatar: true }, // Only if request contains 'avatar'
},
captureState: {
"profile.avatar": "body.avatar",
},
response: { status: 200, body: { success: true } },
},
// Get profile - inject all updates
{
method: "GET",
url: "https://api.example.com/user/profile",
response: {
status: 200,
body: {
name: "{{state.profile.name}}",
bio: "{{state.profile.bio}}",
avatar: "{{state.profile.avatar}}",
},
},
},
],
};Combine sequences (Phase 2) with state (Phase 3):
export const jobProcessingScenario: ScenaristScenario = {
id: "job-processing",
name: "Job Processing with State",
description: "Start job, poll status, job remembers parameters",
mocks: [
// Start job - capture job parameters
{
method: "POST",
url: "https://api.example.com/jobs",
captureState: {
"job.type": "body.type",
"job.input": "body.input",
},
response: {
status: 201,
body: {
jobId: "job-123",
status: "pending",
},
},
},
// Poll job status - sequence + state injection
{
method: "GET",
url: "https://api.example.com/jobs/:jobId",
sequence: {
responses: [
{
status: 200,
body: {
jobId: "job-123",
status: "pending",
type: "{{state.job.type}}", // Inject captured type
progress: 0,
},
},
{
status: 200,
body: {
jobId: "job-123",
status: "processing",
type: "{{state.job.type}}",
progress: 50,
},
},
{
status: 200,
body: {
jobId: "job-123",
status: "complete",
type: "{{state.job.type}}",
input: "{{state.job.input}}", // Inject captured input
result: "processed-output",
progress: 100,
},
},
],
repeat: "last",
},
},
],
};Wrong:
captureState: {
items: 'body.item', // This REPLACES items on each request
}Right:
captureState: {
'items[]': 'body.item', // This APPENDS to items array
}Template: '{{state.count}}'
Value: 5
Result: 5 (number, not "5")
If you need a string, use a mixed template:
message: "Count: {{state.count}}"; // → "Count: 5" (string)State is reset when switching scenarios. If you need to preserve data across scenario switches, use a different test ID.
Pure templates (entire value is a template like "{{state.key}}") return undefined when the state key is missing. This provides type safety and prevents leaking template syntax to responses.
Mixed templates (template embedded in a string like "User: {{state.name}}") keep the unreplaced template string. This is useful for debugging but can be confusing if you expect an error.
To check if state was captured:
const response = await request.get("/api/data");
const data = await response.json();
// Pure template with missing state returns undefined
if (data.value === undefined) {
console.log("State not captured yet");
}
// Mixed template with missing state keeps template string
if (typeof data.message === "string" && data.message.includes("{{")) {
console.log("Some state keys not captured yet");
}See: ADR-0012 for the rationale behind pure template behavior.
Always check that numbers stay numbers and arrays stay arrays:
const cart = await request.get("/api/cart");
const cartData = await cart.json();
expect(cartData.count).toBe(3); // Number assertion
expect(typeof cartData.count).toBe("number"); // Type check
expect(Array.isArray(cartData.items)).toBe(true); // Array checkVerify concurrent tests don't interfere:
test("concurrent tests have independent state", async ({ request }) => {
// Start both tests in parallel
await Promise.all([
request.post("/api/cart/items", {
headers: { "x-scenarist-test-id": "test-A" },
data: { item: "Apple" },
}),
request.post("/api/cart/items", {
headers: { "x-scenarist-test-id": "test-B" },
data: { item: "Banana" },
}),
]);
// Each test sees only its own data
const [cartA, cartB] = await Promise.all([
request.get("/api/cart", { headers: { "x-scenarist-test-id": "test-A" } }),
request.get("/api/cart", { headers: { "x-scenarist-test-id": "test-B" } }),
]);
expect((await cartA.json()).items).toEqual(["Apple"]);
expect((await cartB.json()).items).toEqual(["Banana"]);
});test("state resets on scenario switch", async ({ request }) => {
const testId = "reset-test";
// Add data in scenario A
await request.post("/__scenario__", {
headers: { "x-scenarist-test-id": testId },
data: { scenario: "shopping-cart" },
});
await request.post("/api/cart/items", {
headers: { "x-scenarist-test-id": testId },
data: { item: "Apple" },
});
// Switch to scenario B
await request.post("/__scenario__", {
headers: { "x-scenarist-test-id": testId },
data: { scenario: "user-profile" },
});
// Switch back to scenario A - state should be empty
await request.post("/__scenario__", {
headers: { "x-scenarist-test-id": testId },
data: { scenario: "shopping-cart" },
});
const cart = await request.get("/api/cart", {
headers: { "x-scenarist-test-id": testId },
});
const cartData = await cart.json();
expect(cartData.items).toBeUndefined(); // Fresh state
});- Explore Examples: Check out the example scenarios in
apps/express-example/src/scenarios.ts - Try Bruno Tests: Run the Bruno collection in
apps/express-example/bruno/Dynamic Responses/State/ - Read API Reference: See the full API documentation for state features
- Check Integration Tests: See
apps/express-example/tests/stateful-scenarios.test.tsfor real-world test patterns
Symptom: Response contains '{{state.userName}}' instead of 'Alice'
Causes:
- State key was never captured (check your
captureStateconfiguration) - Wrong test ID (each test ID has independent state)
- State was reset due to scenario switch
Debug:
// Check if state was captured by looking for template strings
const response = await request.get("/api/user");
const data = await response.json();
if (typeof data.name === "string" && data.name.includes("{{")) {
console.error("State not captured - template not replaced");
console.error("Check captureState configuration");
}Symptom: Array only contains the last item added
Cause: Missing [] suffix in captureState
Fix:
// Wrong
captureState: {
items: 'body.item', // Replaces
}
// Right
captureState: {
'items[]': 'body.item', // Appends
}Symptom: Got "3" instead of 3
This is actually correct! Check if the template is pure:
// Pure template → number
count: "{{state.items.length}}"; // → 3
// Mixed template → string
message: "You have {{state.items.length}} items"; // → 'You have 3 items'If you need a number from a mixed template, you'll need to adjust your scenario to use a pure template instead.
Stateful mocks enable realistic testing of:
- Shopping carts
- Multi-step forms
- User sessions
- Incremental updates
- Polling workflows
Key concepts:
captureStateextracts data from requests{{state.key}}templates inject data into responsesitems[]syntax appends to arrays- Pure templates preserve types (arrays, numbers, booleans)
- State is isolated per test ID
- State resets on scenario switch
Start with simple capture/inject patterns and build up to complex multi-step workflows.