Mocking
Mock transports, stores, and external services for testing.
Mocking External Services​
import { vi, describe, it, expect, beforeEach } from 'vitest';
const mockPaymentService = {
capture: vi.fn(),
refund: vi.fn(),
};
const mockInventoryService = {
reserve: vi.fn(),
release: vi.fn(),
};
describe('Order processing', () => {
beforeEach(() => {
vi.clearAllMocks();
mockPaymentService.capture.mockResolvedValue({
transactionId: 'txn-123',
status: 'captured',
});
mockInventoryService.reserve.mockResolvedValue({
reservationId: 'res-456',
});
});
it('processes payment', async () => {
// Test with mocked services
const saga = createOrderSaga({
paymentService: mockPaymentService,
inventoryService: mockInventoryService,
});
// ...test saga
});
});
Mocking Transport​
In-Memory Transport​
import { InMemoryTransport } from '@saga-bus/transport-inmemory';
const transport = new InMemoryTransport();
// Spy on publish
const publishSpy = vi.spyOn(transport, 'publish');
const bus = createBus({
transport,
store,
sagas: [{ definition: orderSaga }],
});
// Verify message was published
expect(publishSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'PaymentRequested',
}),
expect.any(Object)
);
Mock Transport​
import { createMockTransport } from '@saga-bus/test';
const mockTransport = createMockTransport();
// Configure behavior
mockTransport.onPublish('PaymentRequested', async (msg) => {
// Simulate external service response
await mockTransport.receive({
type: 'PaymentCaptured',
orderId: msg.orderId,
transactionId: 'txn-' + Date.now(),
});
});
const bus = createBus({
transport: mockTransport,
store,
sagas: [{ definition: orderSaga }],
});
Mocking Store​
In-Memory Store​
import { InMemorySagaStore } from '@saga-bus/store-inmemory';
const store = new InMemorySagaStore();
// Pre-populate state
await store.insert('OrderSaga', 'order-123', {
orderId: 'order-123',
status: 'paid',
transactionId: 'txn-456',
});
// Test saga with existing state
const bus = createBus({
transport,
store,
sagas: [{ definition: orderSaga }],
});
Mock Store​
import { createMockStore } from '@saga-bus/test';
const mockStore = createMockStore();
// Configure responses
mockStore.getByCorrelationId.mockResolvedValue({
orderId: '123',
status: 'pending',
});
mockStore.update.mockRejectedValueOnce(
new ConcurrencyError('Version conflict')
);
Simulating Failures​
Transport Failures​
const mockTransport = createMockTransport();
// Fail first 2 publish attempts
let attempts = 0;
mockTransport.publish = vi.fn().mockImplementation(async () => {
if (++attempts <= 2) {
throw new Error('Connection failed');
}
});
// Test retry behavior
Store Failures​
const mockStore = createMockStore();
// Simulate concurrency conflict
mockStore.update.mockRejectedValueOnce(
new ConcurrencyError('Version mismatch')
);
// Second attempt succeeds
mockStore.update.mockResolvedValueOnce(undefined);
Timeout Simulation​
const mockPaymentService = {
capture: vi.fn().mockImplementation(
() => new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 100)
)
),
};
Testing with Fake Time​
Vitest Fake Timers​
import { vi, describe, it, beforeEach, afterEach } from 'vitest';
describe('Timeout handling', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('triggers timeout after delay', async () => {
await harness.publish({
type: 'OrderSubmitted',
orderId: '123',
});
// Advance time
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
// Timeout should have triggered
const state = await harness.getSagaState('OrderSaga', '123');
expect(state.status).toBe('timeout');
});
});
TestHarness Time Control​
// Advance harness time
await harness.advanceTime(5 * 60 * 1000);
// Set specific time
await harness.setTime(new Date('2024-01-15T12:00:00Z'));
Mocking HTTP Services​
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.post('https://api.payment.com/capture', () => {
return HttpResponse.json({
transactionId: 'txn-123',
status: 'captured',
});
}),
http.post('https://api.inventory.com/reserve', () => {
return HttpResponse.json({
reservationId: 'res-456',
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('integrates with payment API', async () => {
// Test with mocked HTTP endpoints
});
Creating Test Fixtures​
// fixtures/orders.ts
export const pendingOrder = {
orderId: 'order-123',
status: 'pending',
customerId: 'cust-456',
items: [
{ productId: 'prod-1', quantity: 2, price: 50 },
],
total: 100,
createdAt: new Date('2024-01-15T10:00:00Z'),
};
export const paidOrder = {
...pendingOrder,
status: 'paid',
transactionId: 'txn-789',
paidAt: new Date('2024-01-15T10:05:00Z'),
};
export const completedOrder = {
...paidOrder,
status: 'completed',
reservationId: 'res-001',
completedAt: new Date('2024-01-15T10:10:00Z'),
};
// Usage
import { pendingOrder, paidOrder } from './fixtures/orders';
it('transitions from pending to paid', async () => {
await store.insert('OrderSaga', pendingOrder.orderId, pendingOrder);
// ...
});
Mock Factory Functions​
// test/factories.ts
export function createMockContext<TState>(
overrides: Partial<SagaContext<TState>> = {}
) {
return {
state: {} as TState,
message: {},
setState: vi.fn(),
publish: vi.fn(),
complete: vi.fn(),
correlationId: 'test-corr-id',
sagaId: 'test-saga-id',
metadata: new Map(),
...overrides,
};
}
export function createMockPaymentService() {
return {
capture: vi.fn().mockResolvedValue({ transactionId: 'txn-mock' }),
refund: vi.fn().mockResolvedValue({ refundId: 'ref-mock' }),
};
}