Skip to content

Testing Utilities

Mock factories for testing Durable Object hierarchies and cascade operations.

Prerequisites: core-concepts.md


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 tracking
  • createCascadeMock() - 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';

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
import { createMockHierarchy } from '@z0-app/sdk/test-utils';
import type { MockHierarchy, MockHierarchyConfig } from '@z0-app/sdk/test-utils';
function createMockHierarchy(config: MockHierarchyConfig): MockHierarchy
interface 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
}

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

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
import { createCascadeMock } from '@z0-app/sdk/test-utils';
import type { CascadeMock } from '@z0-app/sdk/test-utils';
function createCascadeMock(): CascadeMock
interface 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;
}

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

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: '[email protected]' },
}),
})
);
// Verify exact body match (deep comparison)
expect(
cascadeMock.assertCalledWithBody('entity_123', {
type: 'user_created',
data: { name: 'Alice', email: '[email protected]' },
})
).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);
});
});

UtilityPurposeKey Features
createMockHierarchy()Mock DO parent-child chainsCall tracking, mock responses, context integration
createCascadeMock()Track cascade call sequencesAssertion 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.