Testing Utilities
Mock factories for testing Durable Object hierarchies and cascade operations.
Prerequisites: core-concepts.md
Overview
Section titled “Overview”The z0 SDK includes test utilities for mocking Durable Objects in unit tests, with special support for:
createMockHierarchy()- Mock DO parent-child chains with call trackingcreateCascadeMock()- Track and assert on multi-DO call sequences
These utilities integrate with Vitest and provide type-safe mocks for testing EntityLedger implementations without deploying to Cloudflare.
Import from test utils:
import { createMockHierarchy, createCascadeMock} from '@z0-app/sdk/test-utils';Hierarchy Mocks
Section titled “Hierarchy Mocks”createMockHierarchy()
Section titled “createMockHierarchy()”Create a mock DO hierarchy with parent-child relationships, complete with call tracking and configurable responses.
Use cases:
- Testing parent-child DO communication patterns
- Verifying budget checks flow up the hierarchy
- Testing cascade operations across entity hierarchies
- Isolating DO interactions in unit tests
Signature
Section titled “Signature”import { createMockHierarchy } from '@z0-app/sdk/test-utils';import type { MockHierarchy, MockHierarchyConfig } from '@z0-app/sdk/test-utils';
function createMockHierarchy(config: MockHierarchyConfig): MockHierarchyinterface MockHierarchyConfig { parent: string; // Parent DO ID children: string[]; // Child DO IDs mockResponses?: Record<string, Response>; // Optional responses by path}
interface MockHierarchy { parent: MockDOStubWithTracking; // Parent DO stub parentContext: MockDOContext; // Parent DO context getChild: (childId: string) => ChildDOInfo; getChildContext: (childId: string) => MockDOContext; reset: () => void; // Clear calls, keep structure}
interface ChildDOInfo { id: string; parentId: string; stub: MockDOStubWithTracking; // For making calls context: MockDOContext; // For ledger constructor}
interface MockDOStubWithTracking { id: string; parentId?: string; fetch: (request: Request) => Promise<Response>; getCalls: () => TrackedCall[];}
interface TrackedCall { targetId: string; // Which DO was called method: string; // GET, POST, etc. path: string; // URL path body?: unknown; // Parsed JSON body url: string; // Full URL timestamp: number; // When the call was made}Examples
Section titled “Examples”Basic hierarchy setup:
import { describe, it, expect } from 'vitest';import { createMockHierarchy } from '@z0-app/sdk/test-utils';
describe('Entity hierarchy', () => { it('should establish parent-child relationship', () => { const hierarchy = createMockHierarchy({ parent: 'tenant_123', children: ['org_a', 'org_b'], });
const orgA = hierarchy.getChild('org_a'); expect(orgA.parentId).toBe('tenant_123');
const orgB = hierarchy.getChild('org_b'); expect(orgB.parentId).toBe('tenant_123'); });});Testing budget checks with mock responses:
import { describe, it, expect } from 'vitest';import { createMockHierarchy } from '@z0-app/sdk/test-utils';import { EntityLedger } from '@z0-app/sdk';
class OrgLedger extends EntityLedger { async checkBudget(amount: number): Promise<boolean> { // In real code, would use ParentDOClient const response = await this.parentStub.fetch( new Request('http://parent/budget-check', { method: 'POST', body: JSON.stringify({ amount }), }) ); return response.ok; }}
describe('Budget enforcement', () => { it('should reject when parent budget exceeded', async () => { const hierarchy = createMockHierarchy({ parent: 'tenant_123', children: ['org_a'], mockResponses: { // Parent returns 402 Payment Required when budget exceeded '/budget-check': new Response( JSON.stringify({ allowed: false, reason: 'budget_exceeded' }), { status: 402, headers: { 'Content-Type': 'application/json' }, } ), }, });
const childContext = hierarchy.getChildContext('org_a'); const ledger = new OrgLedger(childContext, env);
// Inject parent stub (in real code, would come from ParentDOClient) ledger.parentStub = hierarchy.parent;
const allowed = await ledger.checkBudget(1000); expect(allowed).toBe(false);
// Verify parent was called const parentCalls = hierarchy.parent.getCalls(); expect(parentCalls).toHaveLength(1); expect(parentCalls[0]?.path).toBe('/budget-check'); expect(parentCalls[0]?.body).toEqual({ amount: 1000 }); });});Testing cascade operations:
import { describe, it, expect } from 'vitest';import { createMockHierarchy } from '@z0-app/sdk/test-utils';import { cascade } from '@z0-app/sdk';
describe('Entity creation cascade', () => { it('should create parent then children', async () => { const hierarchy = createMockHierarchy({ parent: 'tenant_123', children: ['org_a', 'org_b'], });
const operations = [ { execute: async () => { await hierarchy.parent.fetch( new Request('http://test/create-tenant', { method: 'POST' }) ); return 'tenant_created'; }, rollback: async () => { await hierarchy.parent.fetch( new Request('http://test/delete-tenant', { method: 'DELETE' }) ); }, }, { execute: async () => { await hierarchy.getChild('org_a').stub.fetch( new Request('http://test/create-org', { method: 'POST' }) ); return 'org_created'; }, rollback: async () => { await hierarchy.getChild('org_a').stub.fetch( new Request('http://test/delete-org', { method: 'DELETE' }) ); }, }, ];
const result = await cascade(operations); expect(result.results).toEqual(['tenant_created', 'org_created']);
// Verify call sequence expect(hierarchy.parent.getCalls()).toHaveLength(1); expect(hierarchy.getChild('org_a').stub.getCalls()).toHaveLength(1); });});Reset for test isolation:
import { describe, it, expect, beforeEach } from 'vitest';import { createMockHierarchy } from '@z0-app/sdk/test-utils';
describe('Test suite with shared hierarchy', () => { const hierarchy = createMockHierarchy({ parent: 'tenant_123', children: ['org_a'], });
beforeEach(() => { // Clear call history between tests hierarchy.reset(); });
it('test 1: makes a call', async () => { await hierarchy.parent.fetch(new Request('http://test/endpoint1')); expect(hierarchy.parent.getCalls()).toHaveLength(1); });
it('test 2: starts clean', async () => { // Calls from test 1 were cleared by reset() expect(hierarchy.parent.getCalls()).toHaveLength(0);
await hierarchy.parent.fetch(new Request('http://test/endpoint2')); expect(hierarchy.parent.getCalls()).toHaveLength(1); });});Integration with EntityLedger:
import { describe, it, expect } from 'vitest';import { createMockHierarchy } from '@z0-app/sdk/test-utils';import { EntityLedger } from '@z0-app/sdk';
class UserLedger extends EntityLedger { async createUser(name: string): Promise<void> { await this.createEntity({ id: this.entityId, type: 'user', data: { name }, }); }}
describe('UserLedger', () => { it('should create user entity', async () => { const hierarchy = createMockHierarchy({ parent: 'org_123', children: ['user_alice'], });
const userContext = hierarchy.getChildContext('user_alice'); const ledger = new UserLedger(userContext, env);
await ledger.createUser('Alice');
const entity = await ledger.getEntity(); expect(entity?.data).toEqual({ name: 'Alice' }); });});Cascade Mocks
Section titled “Cascade Mocks”createCascadeMock()
Section titled “createCascadeMock()”Track multi-DO call sequences with assertion helpers for verifying cascade operations.
Use cases:
- Testing cascade operation ordering
- Verifying all DOs in a cascade were called
- Asserting on request bodies across multiple DOs
- Testing rollback behavior
Signature
Section titled “Signature”import { createCascadeMock } from '@z0-app/sdk/test-utils';import type { CascadeMock } from '@z0-app/sdk/test-utils';
function createCascadeMock(): CascadeMockinterface CascadeMock { registerStub: (doId: string) => MockDOStubWithTracking; getCallSequence: () => TrackedCall[]; assertCalled: (doId: string) => boolean; assertCalledBefore: (doIdA: string, doIdB: string) => boolean; assertCalledWithBody: (doId: string, expectedBody: unknown) => boolean; assertCallCount: (doId: string, expectedCount: number) => boolean; reset: () => void;}Assertion Helpers
Section titled “Assertion Helpers”assertCalled(doId) - Returns true if the DO was called at least once
assertCalledBefore(doIdA, doIdB) - Returns true if DO A was called before DO B
assertCalledWithBody(doId, body) - Returns true if the DO was called with matching JSON body (deep comparison)
assertCallCount(doId, count) - Returns true if the DO was called exactly count times
Examples
Section titled “Examples”Basic call tracking:
import { describe, it, expect } from 'vitest';import { createCascadeMock } from '@z0-app/sdk/test-utils';
describe('Cascade operations', () => { it('should track call sequence', async () => { const cascadeMock = createCascadeMock();
const stubA = cascadeMock.registerStub('do_a'); const stubB = cascadeMock.registerStub('do_b'); const stubC = cascadeMock.registerStub('do_c');
// Make calls in sequence await stubA.fetch(new Request('http://test/step1', { method: 'POST' })); await stubB.fetch(new Request('http://test/step2', { method: 'POST' })); await stubC.fetch(new Request('http://test/step3', { method: 'POST' }));
const sequence = cascadeMock.getCallSequence(); expect(sequence).toHaveLength(3); expect(sequence[0]?.targetId).toBe('do_a'); expect(sequence[1]?.targetId).toBe('do_b'); expect(sequence[2]?.targetId).toBe('do_c'); });});Testing cascade with assertCalled:
import { describe, it, expect } from 'vitest';import { createCascadeMock, cascade } from '@z0-app/sdk/test-utils';
describe('Entity creation cascade', () => { it('should call all DOs in cascade', async () => { const cascadeMock = createCascadeMock();
const tenantStub = cascadeMock.registerStub('tenant_123'); const orgStub = cascadeMock.registerStub('org_abc'); const userStub = cascadeMock.registerStub('user_alice');
const operations = [ { execute: async () => { await tenantStub.fetch( new Request('http://test/create', { method: 'POST' }) ); }, rollback: async () => {}, }, { execute: async () => { await orgStub.fetch( new Request('http://test/create', { method: 'POST' }) ); }, rollback: async () => {}, }, { execute: async () => { await userStub.fetch( new Request('http://test/create', { method: 'POST' }) ); }, rollback: async () => {}, }, ];
await cascade(operations);
// Verify all were called expect(cascadeMock.assertCalled('tenant_123')).toBe(true); expect(cascadeMock.assertCalled('org_abc')).toBe(true); expect(cascadeMock.assertCalled('user_alice')).toBe(true); });});Testing order with assertCalledBefore:
import { describe, it, expect } from 'vitest';import { createCascadeMock } from '@z0-app/sdk/test-utils';
describe('Budget check ordering', () => { it('should check parent budget before creating child', async () => { const cascadeMock = createCascadeMock();
const parentStub = cascadeMock.registerStub('tenant_123'); const childStub = cascadeMock.registerStub('org_abc');
// Simulate budget check then entity creation await parentStub.fetch( new Request('http://test/budget-check', { method: 'POST' }) ); await childStub.fetch( new Request('http://test/create-org', { method: 'POST' }) );
// Verify parent was called before child expect(cascadeMock.assertCalledBefore('tenant_123', 'org_abc')).toBe(true); expect(cascadeMock.assertCalledBefore('org_abc', 'tenant_123')).toBe(false); });});Testing request bodies with assertCalledWithBody:
import { describe, it, expect } from 'vitest';import { createCascadeMock } from '@z0-app/sdk/test-utils';
describe('Fact propagation', () => { it('should pass fact data through cascade', async () => { const cascadeMock = createCascadeMock();
const stub = cascadeMock.registerStub('entity_123');
await stub.fetch( new Request('http://test/append-fact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'user_created', data: { name: 'Alice', email: 'alice@example.com' }, }), }) );
// Verify exact body match (deep comparison) expect( cascadeMock.assertCalledWithBody('entity_123', { type: 'user_created', data: { name: 'Alice', email: 'alice@example.com' }, }) ).toBe(true);
// Wrong body doesn't match expect( cascadeMock.assertCalledWithBody('entity_123', { type: 'user_deleted', }) ).toBe(false); });});Testing retry logic with assertCallCount:
import { describe, it, expect } from 'vitest';import { createCascadeMock } from '@z0-app/sdk/test-utils';
describe('Retry behavior', () => { it('should retry failed operation 3 times', async () => { const cascadeMock = createCascadeMock(); const stub = cascadeMock.registerStub('flaky_do');
// Simulate 3 retries for (let i = 0; i < 3; i++) { await stub.fetch(new Request('http://test/flaky-operation')); }
// Verify exact call count expect(cascadeMock.assertCallCount('flaky_do', 3)).toBe(true); expect(cascadeMock.assertCallCount('flaky_do', 2)).toBe(false); expect(cascadeMock.assertCallCount('flaky_do', 4)).toBe(false); });});Reset for test isolation:
import { describe, it, expect, beforeEach } from 'vitest';import { createCascadeMock } from '@z0-app/sdk/test-utils';
describe('Test suite with shared cascade mock', () => { const cascadeMock = createCascadeMock(); const stub = cascadeMock.registerStub('entity_123');
beforeEach(() => { // Clear call sequence between tests cascadeMock.reset(); });
it('test 1: makes a call', async () => { await stub.fetch(new Request('http://test/endpoint1')); expect(cascadeMock.assertCallCount('entity_123', 1)).toBe(true); });
it('test 2: starts clean', async () => { // Calls from test 1 were cleared by reset() expect(cascadeMock.assertCallCount('entity_123', 0)).toBe(true);
await stub.fetch(new Request('http://test/endpoint2')); expect(cascadeMock.assertCallCount('entity_123', 1)).toBe(true); });});Complex cascade testing:
import { describe, it, expect } from 'vitest';import { createCascadeMock } from '@z0-app/sdk/test-utils';
describe('Multi-step workflow', () => { it('should execute workflow steps in correct order', async () => { const cascadeMock = createCascadeMock();
const inventoryStub = cascadeMock.registerStub('inventory'); const paymentStub = cascadeMock.registerStub('payment'); const orderStub = cascadeMock.registerStub('order');
// Simulate order processing workflow await inventoryStub.fetch( new Request('http://test/reserve', { method: 'POST', body: JSON.stringify({ items: ['item_1', 'item_2'] }), }) );
await paymentStub.fetch( new Request('http://test/charge', { method: 'POST', body: JSON.stringify({ amount: 5000 }), }) );
await orderStub.fetch( new Request('http://test/fulfill', { method: 'POST', }) );
// Verify all steps were called expect(cascadeMock.assertCalled('inventory')).toBe(true); expect(cascadeMock.assertCalled('payment')).toBe(true); expect(cascadeMock.assertCalled('order')).toBe(true);
// Verify correct order expect(cascadeMock.assertCalledBefore('inventory', 'payment')).toBe(true); expect(cascadeMock.assertCalledBefore('payment', 'order')).toBe(true);
// Verify request bodies expect( cascadeMock.assertCalledWithBody('inventory', { items: ['item_1', 'item_2'], }) ).toBe(true);
expect( cascadeMock.assertCalledWithBody('payment', { amount: 5000 }) ).toBe(true);
// Verify each step was called exactly once expect(cascadeMock.assertCallCount('inventory', 1)).toBe(true); expect(cascadeMock.assertCallCount('payment', 1)).toBe(true); expect(cascadeMock.assertCallCount('order', 1)).toBe(true); });});Summary
Section titled “Summary”| Utility | Purpose | Key Features |
|---|---|---|
createMockHierarchy() | Mock DO parent-child chains | Call tracking, mock responses, context integration |
createCascadeMock() | Track cascade call sequences | Assertion helpers, ordering verification, body matching |
These testing utilities integrate with Vitest and provide type-safe mocks for testing EntityLedger implementations and cascade operations without deploying to Cloudflare. Use reset() between tests for proper isolation.