Integration Testing
Test complete saga flows with the TestHarness.
Full Flow Testing​
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestHarness } from '@saga-bus/test';
import { orderSaga } from './sagas/order-saga';
describe('Order flow', () => {
let harness: TestHarness;
beforeEach(async () => {
harness = new TestHarness();
await harness.start({
sagas: [{ definition: orderSaga }],
});
});
afterEach(async () => {
await harness.stop();
});
it('completes order flow', async () => {
// 1. Submit order
await harness.publish({
type: 'OrderSubmitted',
orderId: 'order-123',
customerId: 'cust-456',
items: [{ productId: 'prod-1', quantity: 2, price: 50 }],
total: 100,
});
// 2. Wait for payment request
const paymentRequest = await harness.waitForMessage('PaymentRequested');
expect(paymentRequest.amount).toBe(100);
// 3. Simulate payment captured
await harness.publish({
type: 'PaymentCaptured',
orderId: 'order-123',
transactionId: 'txn-789',
});
// 4. Wait for inventory reservation
await harness.waitForMessage('ReserveInventory');
// 5. Simulate inventory reserved
await harness.publish({
type: 'InventoryReserved',
orderId: 'order-123',
reservationId: 'res-001',
});
// 6. Verify final state
const state = await harness.getSagaState('OrderSaga', 'order-123');
expect(state.status).toBe('completed');
expect(state.transactionId).toBe('txn-789');
expect(state.reservationId).toBe('res-001');
});
});
Testing Multiple Sagas​
describe('Multi-saga interaction', () => {
beforeEach(async () => {
harness = new TestHarness();
await harness.start({
sagas: [
{ definition: orderSaga },
{ definition: paymentSaga },
{ definition: inventorySaga },
],
});
});
it('coordinates across sagas', async () => {
await harness.publish({
type: 'OrderSubmitted',
orderId: '123',
total: 100,
});
// Order saga publishes PaymentRequested
// Payment saga handles it and publishes PaymentCaptured
// Order saga handles PaymentCaptured...
await harness.waitForSaga('OrderSaga', '123', { status: 'completed' });
});
});
Testing Compensation​
describe('Compensation flow', () => {
it('compensates on failure', async () => {
// Start order
await harness.publish({
type: 'OrderSubmitted',
orderId: '123',
total: 100,
});
// Payment succeeds
await harness.publish({
type: 'PaymentCaptured',
orderId: '123',
transactionId: 'txn-1',
});
// Inventory fails
await harness.publish({
type: 'InventoryFailed',
orderId: '123',
reason: 'Out of stock',
});
// Verify refund was requested
const refundMsg = await harness.waitForMessage('RefundRequested');
expect(refundMsg.transactionId).toBe('txn-1');
// Complete refund
await harness.publish({
type: 'RefundCompleted',
orderId: '123',
});
// Verify final state
const state = await harness.getSagaState('OrderSaga', '123');
expect(state.status).toBe('cancelled');
});
});
Testing Timeouts​
describe('Timeout handling', () => {
it('handles payment timeout', async () => {
await harness.publish({
type: 'OrderSubmitted',
orderId: '123',
});
await harness.waitForSaga('OrderSaga', '123', { status: 'submitted' });
// Advance time past timeout
await harness.advanceTime(5 * 60 * 1000); // 5 minutes
// Timeout should trigger
await harness.waitForMessage('PaymentTimeout');
const state = await harness.getSagaState('OrderSaga', '123');
expect(state.status).toBe('payment_timeout');
});
});
Testing with Real Database​
import { PostgresSagaStore } from '@saga-bus/store-postgres';
import { Pool } from 'pg';
describe('Integration with PostgreSQL', () => {
let pool: Pool;
let harness: TestHarness;
beforeAll(async () => {
pool = new Pool({
connectionString: process.env.TEST_DATABASE_URL,
});
await createSchema(pool);
});
afterAll(async () => {
await pool.end();
});
beforeEach(async () => {
// Clean database
await pool.query('TRUNCATE sagas');
const store = new PostgresSagaStore({ pool });
harness = new TestHarness({ store });
await harness.start({
sagas: [{ definition: orderSaga }],
});
});
it('persists saga state', async () => {
await harness.publish({
type: 'OrderSubmitted',
orderId: '123',
});
await harness.waitForSaga('OrderSaga', '123');
// Verify in database
const result = await pool.query(
'SELECT * FROM sagas WHERE correlation_id = $1',
['123']
);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].state.status).toBe('submitted');
});
});
Testing Concurrent Messages​
describe('Concurrent processing', () => {
it('handles concurrent messages correctly', async () => {
await harness.publish({
type: 'OrderSubmitted',
orderId: '123',
});
// Publish multiple messages concurrently
await Promise.all([
harness.publish({ type: 'PaymentCaptured', orderId: '123' }),
harness.publish({ type: 'CustomerUpdated', orderId: '123' }),
harness.publish({ type: 'ShippingCalculated', orderId: '123' }),
]);
await harness.waitForSaga('OrderSaga', '123', { status: 'paid' });
// All messages should be processed
const state = await harness.getSagaState('OrderSaga', '123');
expect(state).toBeDefined();
});
});
Debugging Tests​
const harness = new TestHarness({
debug: true, // Enable debug logging
});
// Log all published messages
harness.onMessage((msg) => {
console.log('Message:', msg);
});
// Log state changes
harness.onStateChange((sagaName, correlationId, state) => {
console.log(`${sagaName}[${correlationId}]:`, state);
});
Best Practices​
Use Realistic Test Data​
import { faker } from '@faker-js/faker';
const testOrder = {
type: 'OrderSubmitted',
orderId: faker.string.uuid(),
customerId: faker.string.uuid(),
items: [
{
productId: faker.string.uuid(),
quantity: faker.number.int({ min: 1, max: 10 }),
price: faker.number.float({ min: 10, max: 1000 }),
},
],
};
Isolate Tests​
// Each test gets fresh harness
beforeEach(async () => {
harness = new TestHarness();
await harness.start({ ... });
});
afterEach(async () => {
await harness.stop();
});
Test Error Recovery​
it('recovers from transient failure', async () => {
// Simulate failure
harness.simulateFailure('PaymentService', 2); // Fail first 2 attempts
await harness.publish({ type: 'OrderSubmitted', orderId: '123' });
// Should eventually succeed with retries
await harness.waitForSaga('OrderSaga', '123', {
status: 'paid',
timeout: 30000,
});
});