Loading content...
Event-driven systems present a fundamentally different testing challenge than traditional request-response architectures. When a user places an order, how do you verify that the system correctly raised an OrderPlaced event? The event disappears into an event bus, potentially triggering dozens of downstream handlers. There's no return value to assert against, no immediate response to validate.
This invisibility is the core challenge of testing event-driven designs. Unlike testing a function that returns a value, testing events requires verifying that something was published—and that what was published contains exactly the right data, at exactly the right time, under exactly the right conditions.
By the end of this page, you will master: (1) Why event raising requires specialized testing strategies, (2) How to intercept and verify events without coupling tests to infrastructure, (3) Patterns for asserting event content, timing, and ordering, (4) Anti-patterns that make event testing brittle and unmaintainable, and (5) Real-world techniques used by Principal Engineers at companies like Stripe, Netflix, and Uber.
In synchronous, procedural code, testing is relatively straightforward. You call a function, receive a return value, and assert that the return value matches expectations:
const result = calculator.add(2, 3);
expect(result).toBe(5); // Simple, direct verification
But event-driven code breaks this mental model. When an aggregate raises an event, that event doesn't "return" anything. The aggregate's responsibility ends at publishing the event; what happens next is entirely in the hands of other components.
The fundamental insight:
Testing event raising requires observability—the ability to see what events were raised without actually sending them through production infrastructure. This observability must be built into your architecture, not bolted on as an afterthought.
The techniques we'll explore make event raising verifiable while keeping tests fast, isolated, and deterministic.
At Netflix, Uber, and similar scale, untested event raising causes cascading failures. An order event missing a customer ID propagates through fulfillment, notifications, analytics, and billing—each system failing in its own way. We don't test event raising for academic purity; we test it because production failures from incorrect events are extraordinarily expensive to debug.
The most powerful technique for testing event raising is event capturing—intercepting events before they reach production infrastructure and storing them for assertion. This pattern allows tests to verify:
Let's examine multiple implementations of this pattern, from simple to sophisticated.
In Domain-Driven Design (DDD), aggregates are natural event producers. A common pattern is to have aggregates collect their events internally, allowing tests to inspect them after operations complete.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
// Base class for aggregates that produce domain eventsabstract class AggregateRoot<TId> { private readonly _domainEvents: DomainEvent[] = []; public readonly id: TId; protected constructor(id: TId) { this.id = id; } // Subclasses call this to record an event protected addDomainEvent(event: DomainEvent): void { this._domainEvents.push(event); } // Tests and infrastructure can read collected events public getDomainEvents(): readonly DomainEvent[] { return [...this._domainEvents]; } // Called after events are persisted/published public clearDomainEvents(): void { this._domainEvents.length = 0; }} // Concrete aggregate: Orderclass Order extends AggregateRoot<OrderId> { private _status: OrderStatus; private readonly _items: OrderItem[]; private readonly _customerId: CustomerId; private _totalAmount: Money; private constructor( id: OrderId, customerId: CustomerId, items: OrderItem[], totalAmount: Money ) { super(id); this._status = OrderStatus.Pending; this._customerId = customerId; this._items = items; this._totalAmount = totalAmount; } public static create( customerId: CustomerId, items: OrderItem[] ): Order { const id = OrderId.generate(); const totalAmount = Order.calculateTotal(items); const order = new Order(id, customerId, items, totalAmount); // Raise domain event capturing all relevant data order.addDomainEvent(new OrderPlacedEvent({ orderId: id, customerId: customerId, items: items.map(item => ({ productId: item.productId, quantity: item.quantity, price: item.price, })), totalAmount: totalAmount, occurredAt: new Date(), })); return order; } public confirm(): void { if (this._status !== OrderStatus.Pending) { throw new InvalidOrderStateError( `Cannot confirm order in ${this._status} state` ); } this._status = OrderStatus.Confirmed; this.addDomainEvent(new OrderConfirmedEvent({ orderId: this.id, confirmedAt: new Date(), })); } private static calculateTotal(items: OrderItem[]): Money { return items.reduce( (sum, item) => sum.add(item.price.multiply(item.quantity)), Money.zero() ); }}Collecting events on the aggregate itself (rather than immediately publishing) has multiple benefits: (1) Events can be inspected in tests without mocking, (2) The repository can publish events atomically with persistence, (3) Failed persistence doesn't result in orphaned events, and (4) The aggregate remains a pure domain object with no infrastructure dependencies.
With the event capturing pattern in place, writing tests becomes straightforward. Let's examine comprehensive test strategies that cover the full spectrum of event verification.
The most basic assertion: verify that performing an action resulted in the expected event being produced.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
describe('Order Event Raising', () => { describe('Order Creation', () => { it('should raise OrderPlacedEvent when order is created', () => { // Arrange const customerId = CustomerId.from('cust-123'); const items = [ OrderItem.create('prod-1', 2, Money.of(25.00)), OrderItem.create('prod-2', 1, Money.of(50.00)), ]; // Act const order = Order.create(customerId, items); // Assert - Verify event was raised const events = order.getDomainEvents(); expect(events).toHaveLength(1); expect(events[0]).toBeInstanceOf(OrderPlacedEvent); }); it('should include correct customer ID in OrderPlacedEvent', () => { // Arrange const customerId = CustomerId.from('cust-456'); const items = [OrderItem.create('prod-1', 1, Money.of(100.00))]; // Act const order = Order.create(customerId, items); // Assert - Verify event content const event = order.getDomainEvents()[0] as OrderPlacedEvent; expect(event.customerId).toEqual(customerId); }); it('should calculate and include correct total amount', () => { // Arrange const customerId = CustomerId.from('cust-789'); const items = [ OrderItem.create('prod-1', 2, Money.of(25.00)), // 50.00 OrderItem.create('prod-2', 3, Money.of(10.00)), // 30.00 ]; // Act const order = Order.create(customerId, items); // Assert const event = order.getDomainEvents()[0] as OrderPlacedEvent; expect(event.totalAmount.equals(Money.of(80.00))).toBe(true); }); it('should include all order items in event payload', () => { // Arrange const customerId = CustomerId.from('cust-999'); const items = [ OrderItem.create('prod-1', 2, Money.of(25.00)), OrderItem.create('prod-2', 1, Money.of(50.00)), OrderItem.create('prod-3', 5, Money.of(10.00)), ]; // Act const order = Order.create(customerId, items); // Assert const event = order.getDomainEvents()[0] as OrderPlacedEvent; expect(event.items).toHaveLength(3); expect(event.items[0].productId).toBe('prod-1'); expect(event.items[0].quantity).toBe(2); expect(event.items[1].productId).toBe('prod-2'); expect(event.items[2].productId).toBe('prod-3'); }); it('should assign unique order ID to event', () => { // Act - Create two orders const order1 = Order.create( CustomerId.from('cust-1'), [OrderItem.create('prod-1', 1, Money.of(10.00))] ); const order2 = Order.create( CustomerId.from('cust-2'), [OrderItem.create('prod-2', 1, Money.of(20.00))] ); // Assert - Verify unique IDs const event1 = order1.getDomainEvents()[0] as OrderPlacedEvent; const event2 = order2.getDomainEvents()[0] as OrderPlacedEvent; expect(event1.orderId).not.toEqual(event2.orderId); }); it('should include timestamp in event', () => { // Arrange const before = new Date(); // Act const order = Order.create( CustomerId.from('cust-1'), [OrderItem.create('prod-1', 1, Money.of(10.00))] ); const after = new Date(); // Assert const event = order.getDomainEvents()[0] as OrderPlacedEvent; expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime()); }); });});Notice how each test verifies one specific aspect of the event. This granularity is intentional: when a test fails, you immediately know what is broken. A single test asserting everything would tell you 'something is wrong' without pinpointing the issue.
Real-world aggregates often raise multiple events during their lifecycle. A complex operation might produce several events in a specific order. Testing this ordering is critical when downstream handlers depend on processing events sequentially.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
describe('Order Lifecycle Events', () => { describe('Complete Order Workflow', () => { it('should raise events in correct order through lifecycle', () => { // Arrange const customerId = CustomerId.from('cust-123'); const items = [OrderItem.create('prod-1', 1, Money.of(100.00))]; // Act - Complete workflow const order = Order.create(customerId, items); // Creates OrderPlacedEvent order.confirm(); // Creates OrderConfirmedEvent order.ship(TrackingNumber.from('TRACK123')); // Creates OrderShippedEvent // Assert - Verify events were raised in correct order const events = order.getDomainEvents(); expect(events).toHaveLength(3); // Verify ordering expect(events[0]).toBeInstanceOf(OrderPlacedEvent); expect(events[1]).toBeInstanceOf(OrderConfirmedEvent); expect(events[2]).toBeInstanceOf(OrderShippedEvent); }); it('should maintain event causality through order IDs', () => { // Arrange & Act const order = Order.create( CustomerId.from('cust-123'), [OrderItem.create('prod-1', 1, Money.of(100.00))] ); order.confirm(); order.ship(TrackingNumber.from('TRACK123')); // Assert - All events reference same order ID const events = order.getDomainEvents(); const orderId = order.id; events.forEach(event => { expect(event.orderId).toEqual(orderId); }); }); it('should include tracking number only in ShippedEvent', () => { // Arrange & Act const trackingNumber = TrackingNumber.from('TRACK456'); const order = Order.create( CustomerId.from('cust-123'), [OrderItem.create('prod-1', 1, Money.of(100.00))] ); order.confirm(); order.ship(trackingNumber); // Assert const events = order.getDomainEvents(); const placedEvent = events[0] as OrderPlacedEvent; const confirmedEvent = events[1] as OrderConfirmedEvent; const shippedEvent = events[2] as OrderShippedEvent; expect(placedEvent).not.toHaveProperty('trackingNumber'); expect(confirmedEvent).not.toHaveProperty('trackingNumber'); expect(shippedEvent.trackingNumber).toEqual(trackingNumber); }); }); describe('Event Timestamps', () => { it('should have non-decreasing timestamps across events', async () => { // Arrange const order = Order.create( CustomerId.from('cust-123'), [OrderItem.create('prod-1', 1, Money.of(100.00))] ); // Small delay to ensure timestamp difference await sleep(10); order.confirm(); await sleep(10); order.ship(TrackingNumber.from('TRACK123')); // Assert const events = order.getDomainEvents(); for (let i = 1; i < events.length; i++) { const prevEvent = events[i - 1]; const currEvent = events[i]; expect(currEvent.occurredAt.getTime()) .toBeGreaterThanOrEqual(prevEvent.occurredAt.getTime()); } }); }); describe('Conditional Event Raising', () => { it('should raise CancellationEvent only when order is cancellable', () => { // Arrange - Order in pending state const order = Order.create( CustomerId.from('cust-123'), [OrderItem.create('prod-1', 1, Money.of(100.00))] ); order.clearDomainEvents(); // Clear creation event for clarity // Act order.cancel('Customer changed mind'); // Assert const events = order.getDomainEvents(); expect(events).toHaveLength(1); expect(events[0]).toBeInstanceOf(OrderCancelledEvent); const cancelEvent = events[0] as OrderCancelledEvent; expect(cancelEvent.reason).toBe('Customer changed mind'); }); it('should not raise event when cancellation fails', () => { // Arrange - Order already shipped (not cancellable) const order = Order.create( CustomerId.from('cust-123'), [OrderItem.create('prod-1', 1, Money.of(100.00))] ); order.confirm(); order.ship(TrackingNumber.from('TRACK123')); order.clearDomainEvents(); // Act & Assert expect(() => order.cancel('Want to cancel')) .toThrow(InvalidOrderStateError); // Verify no event was raised on failure expect(order.getDomainEvents()).toHaveLength(0); }); });}); // Helper functionfunction sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms));}When an operation fails (throws an exception or returns an error), no event should be raised. Events represent facts that happened—if the operation didn't complete successfully, there's no fact to record. The test 'should not raise event when cancellation fails' verifies this crucial invariant.
As your event testing matures, you'll find yourself writing the same assertion patterns repeatedly. Custom assertions and test utilities dramatically improve test readability and reduce boilerplate.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
/** * Fluent assertion builder for domain events * Provides expressive, chainable assertions for event verification */export class EventAssertions<TEvent extends DomainEvent> { constructor( private readonly events: readonly DomainEvent[], private readonly eventType: new (...args: any[]) => TEvent ) {} static forAggregate(aggregate: AggregateRoot<any>): EventAssertionStart { return new EventAssertionStart(aggregate.getDomainEvents()); } static forEvents(events: readonly DomainEvent[]): EventAssertionStart { return new EventAssertionStart(events); } // Verify exactly one event of this type was raised wasSingleRaised(): SingleEventAssertion<TEvent> { const matching = this.events.filter(e => e instanceof this.eventType); if (matching.length === 0) { throw new Error( `Expected ${this.eventType.name} to be raised, but it was not. ` + `Events raised: ${this.describeEvents()}` ); } if (matching.length > 1) { throw new Error( `Expected exactly one ${this.eventType.name}, but ${matching.length} were raised` ); } return new SingleEventAssertion(matching[0] as TEvent); } // Verify at least one event of this type was raised wasRaised(): MultiEventAssertion<TEvent> { const matching = this.events.filter(e => e instanceof this.eventType); if (matching.length === 0) { throw new Error( `Expected ${this.eventType.name} to be raised, but it was not` ); } return new MultiEventAssertion(matching as TEvent[]); } // Verify no events of this type were raised wasNotRaised(): void { const matching = this.events.filter(e => e instanceof this.eventType); if (matching.length > 0) { throw new Error( `Expected ${this.eventType.name} NOT to be raised, but ${matching.length} were` ); } } // Verify event was raised at specific position wasRaisedAtPosition(position: number): SingleEventAssertion<TEvent> { const eventAtPosition = this.events[position]; if (!eventAtPosition) { throw new Error(`No event at position ${position}`); } if (!(eventAtPosition instanceof this.eventType)) { throw new Error( `Expected ${this.eventType.name} at position ${position}, ` + `but found ${eventAtPosition.constructor.name}` ); } return new SingleEventAssertion(eventAtPosition as TEvent); } private describeEvents(): string { if (this.events.length === 0) return 'none'; return this.events.map(e => e.constructor.name).join(', '); }} export class EventAssertionStart { constructor(private readonly events: readonly DomainEvent[]) {} expectEvent<TEvent extends DomainEvent>( eventType: new (...args: any[]) => TEvent ): EventAssertions<TEvent> { return new EventAssertions(this.events, eventType); } expectNoEvents(): void { if (this.events.length > 0) { throw new Error( `Expected no events, but ${this.events.length} were raised` ); } } expectExactlyNEvents(n: number): void { if (this.events.length !== n) { throw new Error( `Expected exactly ${n} events, but ${this.events.length} were raised` ); } }} export class SingleEventAssertion<TEvent extends DomainEvent> { constructor(private readonly event: TEvent) {} // Access the event for detailed assertions get(): TEvent { return this.event; } // Verify event properties with predicate satisfies(predicate: (event: TEvent) => boolean, message?: string): this { if (!predicate(this.event)) { throw new Error(message || 'Event did not satisfy predicate'); } return this; } // Verify specific property value hasProperty<K extends keyof TEvent>( key: K, expectedValue: TEvent[K] ): this { const actualValue = this.event[key]; if (!deepEquals(actualValue, expectedValue)) { throw new Error( `Expected ${String(key)} to be ${JSON.stringify(expectedValue)}, ` + `but was ${JSON.stringify(actualValue)}` ); } return this; }} export class MultiEventAssertion<TEvent extends DomainEvent> { constructor(private readonly events: TEvent[]) {} // Get count of matching events withCount(expectedCount: number): this { if (this.events.length !== expectedCount) { throw new Error( `Expected ${expectedCount} events, but found ${this.events.length}` ); } return this; } // Get all matching events getAll(): TEvent[] { return [...this.events]; } // Get first matching event first(): SingleEventAssertion<TEvent> { return new SingleEventAssertion(this.events[0]); } // Verify all events satisfy predicate allSatisfy(predicate: (event: TEvent) => boolean): this { this.events.forEach((event, index) => { if (!predicate(event)) { throw new Error(`Event at index ${index} did not satisfy predicate`); } }); return this; }}With these utilities, tests become dramatically more expressive:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
describe('Order Events with Custom Assertions', () => { it('should raise OrderPlacedEvent with correct data', () => { // Arrange const customerId = CustomerId.from('cust-123'); const items = [OrderItem.create('prod-1', 2, Money.of(50.00))]; // Act const order = Order.create(customerId, items); // Assert - Clean, expressive assertions EventAssertions.forAggregate(order) .expectEvent(OrderPlacedEvent) .wasSingleRaised() .hasProperty('customerId', customerId) .satisfies( e => e.totalAmount.equals(Money.of(100.00)), 'Total should be calculated correctly' ); }); it('should raise events in correct order', () => { // Arrange & Act const order = Order.create( CustomerId.from('cust-123'), [OrderItem.create('prod-1', 1, Money.of(100.00))] ); order.confirm(); order.ship(TrackingNumber.from('TRACK123')); // Assert const assertions = EventAssertions.forAggregate(order); assertions.expectEvent(OrderPlacedEvent).wasRaisedAtPosition(0); assertions.expectEvent(OrderConfirmedEvent).wasRaisedAtPosition(1); assertions.expectEvent(OrderShippedEvent).wasRaisedAtPosition(2); assertions.expectExactlyNEvents(3); }); it('should not raise cancellation event for shipped orders', () => { // Arrange const order = Order.create( CustomerId.from('cust-123'), [OrderItem.create('prod-1', 1, Money.of(100.00))] ); order.confirm(); order.ship(TrackingNumber.from('TRACK123')); order.clearDomainEvents(); // Act expect(() => order.cancel('Changed mind')).toThrow(); // Assert EventAssertions.forAggregate(order) .expectEvent(OrderCancelledEvent) .wasNotRaised(); });});Building custom test utilities like EventAssertions requires upfront investment, but pays dividends across hundreds of tests. The improved readability also serves as documentation—new team members can understand what's being verified without deep domain knowledge.
Beyond the primary payload, events carry metadata essential for tracing, auditing, and debugging. This metadata must also be tested to ensure observability in production.
| Field | Purpose | Testing Considerations |
|---|---|---|
| eventId | Unique identifier for deduplication and tracing | Must be globally unique (UUID); verify format |
| occurredAt | When the event happened | Should be close to 'now'; verify not null |
| correlationId | Links related events across systems | Should propagate from incoming request or be generated |
| causationId | ID of command/event that caused this event | Enables event chain reconstruction |
| userId | Who triggered the action | Must match authenticated user |
| version | Schema version for compatibility | Supports event evolution; verify against expected version |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
describe('Event Metadata', () => { describe('Event ID', () => { it('should generate unique event ID for each event', () => { // Generate many events const events: OrderPlacedEvent[] = []; for (let i = 0; i < 100; i++) { const order = Order.create( CustomerId.from(`cust-${i}`), [OrderItem.create('prod-1', 1, Money.of(10.00))] ); events.push(order.getDomainEvents()[0] as OrderPlacedEvent); } // Verify all IDs are unique const eventIds = events.map(e => e.eventId); const uniqueIds = new Set(eventIds); expect(uniqueIds.size).toBe(events.length); }); it('should generate valid UUID format for event ID', () => { // Arrange & Act const order = Order.create( CustomerId.from('cust-1'), [OrderItem.create('prod-1', 1, Money.of(10.00))] ); const event = order.getDomainEvents()[0]; // Assert const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; expect(event.eventId).toMatch(uuidRegex); }); }); describe('Correlation ID Propagation', () => { it('should propagate correlation ID from command context', () => { // Arrange const correlationId = CorrelationId.from('req-12345'); const context = CommandContext.with({ correlationId }); // Act const order = Order.create( CustomerId.from('cust-1'), [OrderItem.create('prod-1', 1, Money.of(10.00))], context ); // Assert const event = order.getDomainEvents()[0]; expect(event.correlationId).toEqual(correlationId); }); it('should maintain same correlation ID across multiple events', () => { // Arrange const correlationId = CorrelationId.from('req-99999'); const context = CommandContext.with({ correlationId }); // Act const order = Order.create( CustomerId.from('cust-1'), [OrderItem.create('prod-1', 1, Money.of(10.00))], context ); order.confirm(context); // Assert const events = order.getDomainEvents(); events.forEach(event => { expect(event.correlationId).toEqual(correlationId); }); }); }); describe('Causation Tracking', () => { it('should set causation ID to command ID that triggered event', () => { // Arrange const commandId = CommandId.from('cmd-place-order-123'); const context = CommandContext.with({ commandId }); // Act const order = Order.create( CustomerId.from('cust-1'), [OrderItem.create('prod-1', 1, Money.of(10.00))], context ); // Assert const event = order.getDomainEvents()[0]; expect(event.causationId).toEqual(commandId); }); }); describe('Event Versioning', () => { it('should include schema version for OrderPlacedEvent', () => { // Act const order = Order.create( CustomerId.from('cust-1'), [OrderItem.create('prod-1', 1, Money.of(10.00))] ); // Assert const event = order.getDomainEvents()[0] as OrderPlacedEvent; expect(event.version).toBe(1); // Current schema version }); }); describe('User Context', () => { it('should capture authenticated user ID in event', () => { // Arrange const userId = UserId.from('user-admin-42'); const context = CommandContext.with({ userId }); // Act const order = Order.create( CustomerId.from('cust-1'), [OrderItem.create('prod-1', 1, Money.of(10.00))], context ); // Assert const event = order.getDomainEvents()[0]; expect(event.triggeredBy).toEqual(userId); }); });});Event testing can go wrong in subtle ways. Let's examine common anti-patterns and their consequences.
Surprisingly common, especially with events. 'The event just gets published, what's to test?' But events are contracts with consumers. An untested event is a liability—you'll discover missing fields or wrong data types when production handlers fail.
Testing event raising is foundational to reliable event-driven systems. Let's consolidate the key principles:
What's Next:
Now that we've mastered testing event raising, the next page explores the other side of the coin: testing event handling. We'll examine how to verify that handlers process events correctly, handle errors gracefully, and maintain idempotency under duplicate delivery.
You now possess the techniques to verify that your event-driven components raise the right events, with the right data, at the right times. These skills form the bedrock of trustworthy asynchronous systems. Next, we'll tackle the equally important challenge of testing event consumption.