Loading content...
If testing event raising verifies that producers fulfill their obligations, testing event handling ensures that consumers uphold theirs. An event handler is a promise: "When this event occurs, I will perform this action correctly."
But handlers introduce complexities that synchronous code avoids:
Testing handlers means verifying all of this—without building an entire distributed system in your test suite.
By the end of this page, you will master: (1) How to test handlers in isolation using mocks and fakes, (2) Patterns for verifying side effects (database writes, API calls, notifications), (3) Strategies for testing idempotent behavior, (4) Techniques for verifying error handling and retry logic, and (5) Best practices from production systems at scale.
Event handlers follow a consistent pattern: receive an event, perform actions based on the event data, and update state or trigger further operations. Testing this pattern requires three key steps:
Unlike testing functions that return values, handler tests primarily verify side effects—did the handler write to the database? Did it call the notification service? Did it publish follow-up events?
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
/** * Example: Testing an OrderConfirmationEmailHandler * * This handler listens for OrderPlacedEvent and sends * a confirmation email to the customer. */describe('OrderConfirmationEmailHandler', () => { let handler: OrderConfirmationEmailHandler; let emailService: MockEmailService; let customerRepository: MockCustomerRepository; beforeEach(() => { // Arrange - Set up mocks emailService = new MockEmailService(); customerRepository = new MockCustomerRepository(); handler = new OrderConfirmationEmailHandler( emailService, customerRepository ); }); it('should send confirmation email when order is placed', async () => { // Arrange - Create event and set up customer data const customerId = CustomerId.from('cust-123'); const customer = Customer.create({ id: customerId, email: 'john@example.com', name: 'John Doe', }); customerRepository.add(customer); const event = new OrderPlacedEvent({ orderId: OrderId.from('order-456'), customerId: customerId, items: [{ productId: 'prod-1', quantity: 2, price: Money.of(50.00) }], totalAmount: Money.of(100.00), occurredAt: new Date(), }); // Act - Invoke the handler await handler.handle(event); // Assert - Verify email was sent expect(emailService.sentEmails).toHaveLength(1); const sentEmail = emailService.sentEmails[0]; expect(sentEmail.to).toBe('john@example.com'); expect(sentEmail.subject).toContain('Order Confirmation'); expect(sentEmail.body).toContain('order-456'); expect(sentEmail.body).toContain('$100.00'); }); it('should include customer name in email greeting', async () => { // Arrange const customer = Customer.create({ id: CustomerId.from('cust-789'), email: 'jane@example.com', name: 'Jane Smith', }); customerRepository.add(customer); const event = new OrderPlacedEvent({ orderId: OrderId.from('order-999'), customerId: customer.id, items: [{ productId: 'prod-1', quantity: 1, price: Money.of(25.00) }], totalAmount: Money.of(25.00), occurredAt: new Date(), }); // Act await handler.handle(event); // Assert const sentEmail = emailService.sentEmails[0]; expect(sentEmail.body).toContain('Dear Jane Smith'); }); it('should include all order items in email', async () => { // Arrange const customer = Customer.create({ id: CustomerId.from('cust-123'), email: 'buyer@example.com', name: 'Buyer', }); customerRepository.add(customer); const event = new OrderPlacedEvent({ orderId: OrderId.from('order-multi'), customerId: customer.id, items: [ { productId: 'Widget A', quantity: 2, price: Money.of(10.00) }, { productId: 'Widget B', quantity: 1, price: Money.of(30.00) }, { productId: 'Widget C', quantity: 5, price: Money.of(5.00) }, ], totalAmount: Money.of(75.00), occurredAt: new Date(), }); // Act await handler.handle(event); // Assert const sentEmail = emailService.sentEmails[0]; expect(sentEmail.body).toContain('Widget A'); expect(sentEmail.body).toContain('Widget B'); expect(sentEmail.body).toContain('Widget C'); expect(sentEmail.body).toContain('Quantity: 2'); expect(sentEmail.body).toContain('Quantity: 5'); });});The TypeScript example uses 'fakes' (MockEmailService with actual collection storage), while the Java example uses 'mocks' (Mockito stubs). Both approaches work—fakes are more explicit and easier to debug; mocks are more concise and integrate well with frameworks. Choose based on team preference and test complexity.
Handler tests rely heavily on test doubles—substitutes for production dependencies. Let's examine how to build test doubles that are both realistic enough to catch real bugs and simple enough to maintain.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
/** * Fake Email Service - Records sent emails for assertion */export class MockEmailService implements EmailService { public readonly sentEmails: Email[] = []; private shouldFail: boolean = false; private failureError?: Error; async send(email: Email): Promise<void> { if (this.shouldFail) { throw this.failureError ?? new Error('Email service unavailable'); } this.sentEmails.push({ ...email, sentAt: new Date() }); } // Test control methods simulateFailure(error?: Error): void { this.shouldFail = true; this.failureError = error; } reset(): void { this.sentEmails.length = 0; this.shouldFail = false; this.failureError = undefined; } // Assertion helpers assertEmailSentTo(recipient: string): Email { const email = this.sentEmails.find(e => e.to === recipient); if (!email) { throw new Error( `Expected email to ${recipient}, but sent to: ` + `${this.sentEmails.map(e => e.to).join(', ') || 'nobody'}` ); } return email; } assertNoEmailsSent(): void { if (this.sentEmails.length > 0) { throw new Error( `Expected no emails, but ${this.sentEmails.length} were sent` ); } }} /** * In-Memory Repository - Simulates database storage */export class InMemoryCustomerRepository implements CustomerRepository { private readonly customers = new Map<string, Customer>(); add(customer: Customer): void { this.customers.set(customer.id.value, customer); } async findById(id: CustomerId): Promise<Customer | null> { return this.customers.get(id.value) ?? null; } async findByEmail(email: string): Promise<Customer | null> { for (const customer of this.customers.values()) { if (customer.email === email) { return customer; } } return null; } async save(customer: Customer): Promise<void> { this.customers.set(customer.id.value, customer); } // Test helpers clear(): void { this.customers.clear(); } getAll(): Customer[] { return Array.from(this.customers.values()); }} /** * Spy Event Publisher - Records published events */export class SpyEventPublisher implements EventPublisher { public readonly publishedEvents: DomainEvent[] = []; private publishDelay: number = 0; async publish(event: DomainEvent): Promise<void> { if (this.publishDelay > 0) { await sleep(this.publishDelay); } this.publishedEvents.push(event); } async publishAll(events: DomainEvent[]): Promise<void> { for (const event of events) { await this.publish(event); } } // Test configuration withDelay(ms: number): this { this.publishDelay = ms; return this; } // Assertion helpers expectEvent<T extends DomainEvent>( eventType: new (...args: any[]) => T ): T { const event = this.publishedEvents.find(e => e instanceof eventType); if (!event) { throw new Error(`Expected ${eventType.name} to be published`); } return event as T; } expectNoEvents(): void { if (this.publishedEvents.length > 0) { throw new Error( `Expected no events, but found: ` + this.publishedEvents.map(e => e.constructor.name).join(', ') );} }}assertEmailSentTo() make tests more readable than manual assertions.simulateFailure() enable testing error handling paths.sentEmails or publishedEvents allows flexible assertions.Handlers exist to produce side effects. Let's examine comprehensive patterns for verifying database updates, API calls, and downstream event publishing.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
/** * InventoryReservationHandler creates inventory reservations * when orders are placed. */describe('InventoryReservationHandler', () => { let handler: InventoryReservationHandler; let inventoryRepository: InMemoryInventoryRepository; let reservationRepository: InMemoryReservationRepository; beforeEach(() => { inventoryRepository = new InMemoryInventoryRepository(); reservationRepository = new InMemoryReservationRepository(); handler = new InventoryReservationHandler( inventoryRepository, reservationRepository ); }); describe('Successful Reservation', () => { it('should create reservation for each order item', async () => { // Arrange inventoryRepository.add(Inventory.create('prod-1', 100)); inventoryRepository.add(Inventory.create('prod-2', 50)); const event = new OrderPlacedEvent({ orderId: OrderId.from('order-123'), customerId: CustomerId.from('cust-1'), items: [ { productId: 'prod-1', quantity: 5, price: Money.of(10.00) }, { productId: 'prod-2', quantity: 3, price: Money.of(20.00) }, ], totalAmount: Money.of(110.00), occurredAt: new Date(), }); // Act await handler.handle(event); // Assert - Verify reservations created const reservations = reservationRepository.findByOrderId('order-123'); expect(reservations).toHaveLength(2); const prod1Reservation = reservations.find(r => r.productId === 'prod-1'); expect(prod1Reservation).toBeDefined(); expect(prod1Reservation!.quantity).toBe(5); expect(prod1Reservation!.status).toBe(ReservationStatus.Active); const prod2Reservation = reservations.find(r => r.productId === 'prod-2'); expect(prod2Reservation).toBeDefined(); expect(prod2Reservation!.quantity).toBe(3); }); it('should decrement available inventory', async () => { // Arrange inventoryRepository.add(Inventory.create('prod-1', 100)); const event = new OrderPlacedEvent({ orderId: OrderId.from('order-456'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 25, price: Money.of(10.00) }], totalAmount: Money.of(250.00), occurredAt: new Date(), }); // Act await handler.handle(event); // Assert - Verify inventory reduced const inventory = await inventoryRepository.findByProductId('prod-1'); expect(inventory!.availableQuantity).toBe(75); // 100 - 25 expect(inventory!.reservedQuantity).toBe(25); }); it('should link reservation to order ID for traceability', async () => { // Arrange inventoryRepository.add(Inventory.create('prod-1', 100)); const orderId = OrderId.from('order-trace-test'); const event = new OrderPlacedEvent({ orderId: orderId, customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 10, price: Money.of(5.00) }], totalAmount: Money.of(50.00), occurredAt: new Date(), }); // Act await handler.handle(event); // Assert const reservations = reservationRepository.findByOrderId(orderId.value); reservations.forEach(reservation => { expect(reservation.orderId).toEqual(orderId); }); }); }); describe('Insufficient Inventory', () => { it('should not create reservation when inventory insufficient', async () => { // Arrange - Only 5 available, order requests 10 inventoryRepository.add(Inventory.create('prod-1', 5)); const event = new OrderPlacedEvent({ orderId: OrderId.from('order-fail'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 10, price: Money.of(10.00) }], totalAmount: Money.of(100.00), occurredAt: new Date(), }); // Act & Assert await expect(handler.handle(event)) .rejects.toThrow(InsufficientInventoryError); // Verify no reservation created const reservations = reservationRepository.findByOrderId('order-fail'); expect(reservations).toHaveLength(0); }); it('should not modify inventory when reservation fails', async () => { // Arrange inventoryRepository.add(Inventory.create('prod-1', 5)); const event = new OrderPlacedEvent({ orderId: OrderId.from('order-fail'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 10, price: Money.of(10.00) }], totalAmount: Money.of(100.00), occurredAt: new Date(), }); // Act try { await handler.handle(event); } catch { // Expected to fail } // Assert - Inventory unchanged const inventory = await inventoryRepository.findByProductId('prod-1'); expect(inventory!.availableQuantity).toBe(5); expect(inventory!.reservedQuantity).toBe(0); }); });}); 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
/** * PaymentCaptureHandler calls an external payment API * when orders are shipped. */describe('PaymentCaptureHandler', () => { let handler: PaymentCaptureHandler; let paymentGateway: MockPaymentGateway; let orderRepository: InMemoryOrderRepository; beforeEach(() => { paymentGateway = new MockPaymentGateway(); orderRepository = new InMemoryOrderRepository(); handler = new PaymentCaptureHandler(paymentGateway, orderRepository); }); it('should capture payment when order is shipped', async () => { // Arrange const order = Order.createWithPaymentAuth( OrderId.from('order-123'), CustomerId.from('cust-1'), PaymentAuthorizationId.from('auth-abc123'), Money.of(150.00) ); orderRepository.save(order); const event = new OrderShippedEvent({ orderId: order.id, trackingNumber: TrackingNumber.from('TRACK123'), shippedAt: new Date(), }); // Act await handler.handle(event); // Assert - Verify API call expect(paymentGateway.capturedPayments).toHaveLength(1); const capture = paymentGateway.capturedPayments[0]; expect(capture.authorizationId).toBe('auth-abc123'); expect(capture.amount.equals(Money.of(150.00))).toBe(true); }); it('should include order reference in capture for reconciliation', async () => { // Arrange const orderId = OrderId.from('order-reconcile-test'); const order = Order.createWithPaymentAuth( orderId, CustomerId.from('cust-1'), PaymentAuthorizationId.from('auth-xyz789'), Money.of(75.00) ); orderRepository.save(order); const event = new OrderShippedEvent({ orderId: orderId, trackingNumber: TrackingNumber.from('TRACK456'), shippedAt: new Date(), }); // Act await handler.handle(event); // Assert const capture = paymentGateway.capturedPayments[0]; expect(capture.metadata.orderId).toBe('order-reconcile-test'); }); describe('Payment Gateway Failures', () => { it('should throw when payment capture fails', async () => { // Arrange paymentGateway.simulateFailure( new PaymentDeclinedError('Insufficient funds') ); const order = Order.createWithPaymentAuth( OrderId.from('order-fail'), CustomerId.from('cust-1'), PaymentAuthorizationId.from('auth-fail'), Money.of(10000.00) ); orderRepository.save(order); const event = new OrderShippedEvent({ orderId: order.id, trackingNumber: TrackingNumber.from('TRACK'), shippedAt: new Date(), }); // Act & Assert await expect(handler.handle(event)) .rejects.toThrow(PaymentDeclinedError); }); it('should not mark order as paid when capture fails', async () => { // Arrange paymentGateway.simulateFailure(new PaymentDeclinedError('Declined')); const order = Order.createWithPaymentAuth( OrderId.from('order-fail-2'), CustomerId.from('cust-1'), PaymentAuthorizationId.from('auth-fail-2'), Money.of(500.00) ); orderRepository.save(order); const event = new OrderShippedEvent({ orderId: order.id, trackingNumber: TrackingNumber.from('TRACK'), shippedAt: new Date(), }); // Act try { await handler.handle(event); } catch { // Expected } // Assert - Order payment status unchanged const savedOrder = await orderRepository.findById(order.id); expect(savedOrder!.paymentStatus).toBe(PaymentStatus.Authorized); expect(savedOrder!.paymentStatus).not.toBe(PaymentStatus.Captured); }); });}); Testing only the 'happy path' is insufficient. Each external dependency can fail, and handlers must behave correctly when they do. The payment tests above verify both successful captures AND that failures don't corrupt state.
In distributed systems, events may be delivered more than once. Network failures, retries, and exactly-once delivery impossibility mean handlers must be idempotent—processing the same event multiple times should produce the same result as processing it once.
Idempotency testing verifies this critical property.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
describe('Idempotency', () => { describe('InventoryReservationHandler', () => { let handler: InventoryReservationHandler; let inventoryRepository: InMemoryInventoryRepository; let reservationRepository: InMemoryReservationRepository; let idempotencyStore: InMemoryIdempotencyStore; beforeEach(() => { inventoryRepository = new InMemoryInventoryRepository(); reservationRepository = new InMemoryReservationRepository(); idempotencyStore = new InMemoryIdempotencyStore(); handler = new InventoryReservationHandler( inventoryRepository, reservationRepository, idempotencyStore ); }); it('should create reservation only once for duplicate events', async () => { // Arrange inventoryRepository.add(Inventory.create('prod-1', 100)); const event = new OrderPlacedEvent({ eventId: EventId.from('evt-duplicate-test'), orderId: OrderId.from('order-123'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 10, price: Money.of(10.00) }], totalAmount: Money.of(100.00), occurredAt: new Date(), }); // Act - Process same event twice await handler.handle(event); await handler.handle(event); // Duplicate delivery // Assert - Only one reservation created const reservations = reservationRepository.findByOrderId('order-123'); expect(reservations).toHaveLength(1); }); it('should not double-decrement inventory on duplicate events', async () => { // Arrange inventoryRepository.add(Inventory.create('prod-1', 100)); const event = new OrderPlacedEvent({ eventId: EventId.from('evt-inventory-test'), orderId: OrderId.from('order-456'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 30, price: Money.of(10.00) }], totalAmount: Money.of(300.00), occurredAt: new Date(), }); // Act - Process multiple times await handler.handle(event); await handler.handle(event); await handler.handle(event); // Assert - Inventory decremented only once const inventory = await inventoryRepository.findByProductId('prod-1'); expect(inventory!.availableQuantity).toBe(70); // 100 - 30, not 100 - 90 }); it('should record event processing in idempotency store', async () => { // Arrange inventoryRepository.add(Inventory.create('prod-1', 100)); const eventId = EventId.from('evt-record-test'); const event = new OrderPlacedEvent({ eventId: eventId, orderId: OrderId.from('order-789'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 5, price: Money.of(10.00) }], totalAmount: Money.of(50.00), occurredAt: new Date(), }); // Act await handler.handle(event); // Assert expect(idempotencyStore.hasProcessed(eventId)).toBe(true); }); }); describe('EmailNotificationHandler', () => { let handler: EmailNotificationHandler; let emailService: MockEmailService; let idempotencyStore: InMemoryIdempotencyStore; beforeEach(() => { emailService = new MockEmailService(); idempotencyStore = new InMemoryIdempotencyStore(); handler = new EmailNotificationHandler(emailService, idempotencyStore); }); it('should send email only once for duplicate events', async () => { // Arrange const event = new OrderPlacedEvent({ eventId: EventId.from('evt-email-test'), orderId: OrderId.from('order-email'), customerId: CustomerId.from('cust-email'), items: [{ productId: 'prod-1', quantity: 1, price: Money.of(10.00) }], totalAmount: Money.of(10.00), occurredAt: new Date(), }); // Act await handler.handle(event); await handler.handle(event); await handler.handle(event); // Assert - Only one email sent expect(emailService.sentEmails).toHaveLength(1); }); }); describe('Multiple Handler Coordination', () => { it('should handle event across multiple handlers idempotently', async () => { // Arrange - Multiple handlers process same event const sharedIdempotencyStore = new InMemoryIdempotencyStore(); const emailHandler = new EmailNotificationHandler( new MockEmailService(), sharedIdempotencyStore ); const inventoryHandler = new InventoryReservationHandler( new InMemoryInventoryRepository(), new InMemoryReservationRepository(), sharedIdempotencyStore ); const analyticsHandler = new AnalyticsTrackingHandler( new MockAnalyticsService(), sharedIdempotencyStore ); // Each handler has its own idempotency key prefix inventoryHandler.getIdempotencyKey = (event) => `inventory:${event.eventId.value}`; emailHandler.getIdempotencyKey = (event) => `email:${event.eventId.value}`; analyticsHandler.getIdempotencyKey = (event) => `analytics:${event.eventId.value}`; const event = new OrderPlacedEvent({ eventId: EventId.from('evt-multi-handler'), orderId: OrderId.from('order-multi'), customerId: CustomerId.from('cust-multi'), items: [{ productId: 'prod-1', quantity: 1, price: Money.of(10.00) }], totalAmount: Money.of(10.00), occurredAt: new Date(), }); // Act - Process same event through all handlers, twice await emailHandler.handle(event); await inventoryHandler.handle(event); await analyticsHandler.handle(event); // Simulate duplicate delivery await emailHandler.handle(event); await inventoryHandler.handle(event); await analyticsHandler.handle(event); // Assert - Each handler processed exactly once expect(sharedIdempotencyStore.getProcessedCount('inventory:')).toBe(1); expect(sharedIdempotencyStore.getProcessedCount('email:')).toBe(1); expect(sharedIdempotencyStore.getProcessedCount('analytics:')).toBe(1); }); });});When multiple handlers process the same event, each needs its own idempotency tracking. A single 'event processed' flag would prevent subsequent handlers from running. Use handler-specific key prefixes like email:{eventId} and inventory:{eventId}.
Handlers fail. Networks time out, databases become unavailable, external services return errors. Robust handlers must handle these failures gracefully—either recovering automatically or failing in a way that allows retry.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
describe('Handler Error Handling', () => { describe('Transient Failure Recovery', () => { let handler: ResilientInventoryHandler; let inventoryService: MockInventoryService; let retryPolicy: RetryPolicy; beforeEach(() => { inventoryService = new MockInventoryService(); retryPolicy = RetryPolicy.exponentialBackoff({ maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 100, }); handler = new ResilientInventoryHandler( inventoryService, retryPolicy ); }); it('should retry on transient failure and succeed', async () => { // Arrange - Fail twice, then succeed inventoryService.failNextNCalls(2, new TransientError('Network timeout')); const event = new OrderPlacedEvent({ eventId: EventId.from('evt-retry'), orderId: OrderId.from('order-retry'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 5, price: Money.of(10.00) }], totalAmount: Money.of(50.00), occurredAt: new Date(), }); // Act await handler.handle(event); // Assert - Eventually succeeded expect(inventoryService.reservations).toHaveLength(1); expect(inventoryService.callCount).toBe(3); // 2 failures + 1 success }); it('should give up after max retries exceeded', async () => { // Arrange - Fail more times than max attempts inventoryService.failNextNCalls(10, new TransientError('Service down')); const event = new OrderPlacedEvent({ eventId: EventId.from('evt-give-up'), orderId: OrderId.from('order-give-up'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 5, price: Money.of(10.00) }], totalAmount: Money.of(50.00), occurredAt: new Date(), }); // Act & Assert await expect(handler.handle(event)) .rejects.toThrow(MaxRetriesExceededError); // Verify exactly max attempts were made expect(inventoryService.callCount).toBe(3); }); it('should not retry on permanent failure', async () => { // Arrange - Permanent failure (shouldn't retry) inventoryService.failWith( new ValidationError('Invalid product ID') ); const event = new OrderPlacedEvent({ eventId: EventId.from('evt-permanent'), orderId: OrderId.from('order-permanent'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'invalid-product', quantity: 5, price: Money.of(10.00) }], totalAmount: Money.of(50.00), occurredAt: new Date(), }); // Act & Assert await expect(handler.handle(event)) .rejects.toThrow(ValidationError); // Should fail immediately without retries expect(inventoryService.callCount).toBe(1); }); }); describe('Dead Letter Queue Handling', () => { let handler: InventoryHandler; let inventoryService: MockInventoryService; let deadLetterQueue: MockDeadLetterQueue; beforeEach(() => { inventoryService = new MockInventoryService(); deadLetterQueue = new MockDeadLetterQueue(); handler = new InventoryHandler( inventoryService, deadLetterQueue ); }); it('should move to dead letter queue after permanent failure', async () => { // Arrange inventoryService.failWith(new PermanentError('Data corruption')); const event = new OrderPlacedEvent({ eventId: EventId.from('evt-dlq'), orderId: OrderId.from('order-dlq'), customerId: CustomerId.from('cust-1'), items: [{ productId: 'prod-1', quantity: 5, price: Money.of(10.00) }], totalAmount: Money.of(50.00), occurredAt: new Date(), }); // Act await handler.handle(event); // Assert - Event moved to DLQ expect(deadLetterQueue.messages).toHaveLength(1); const dlqMessage = deadLetterQueue.messages[0]; expect(dlqMessage.originalEvent).toEqual(event); expect(dlqMessage.error).toBeInstanceOf(PermanentError); expect(dlqMessage.failedAt).toBeDefined(); }); it('should include failure context in dead letter message', async () => { // Arrange const specificError = new DatabaseError( 'foreign_key_violation', 'cust-999 does not exist' ); inventoryService.failWith(specificError); const event = new OrderPlacedEvent({ eventId: EventId.from('evt-dlq-context'), orderId: OrderId.from('order-dlq-context'), customerId: CustomerId.from('cust-999'), items: [{ productId: 'prod-1', quantity: 5, price: Money.of(10.00) }], totalAmount: Money.of(50.00), occurredAt: new Date(), }); // Act await handler.handle(event); // Assert const dlqMessage = deadLetterQueue.messages[0]; expect(dlqMessage.error.code).toBe('foreign_key_violation'); expect(dlqMessage.error.message).toContain('cust-999'); expect(dlqMessage.handlerName).toBe('InventoryHandler'); expect(dlqMessage.attemptCount).toBe(1); }); }); describe('Partial Failure Handling', () => { it('should handle partial success in batch operations', async () => { // For handlers that process multiple items, test partial failure const handler = new BatchItemHandler(new MockItemProcessor()); const event = new BatchProcessEvent({ eventId: EventId.from('evt-batch'), items: [ { id: 'item-1', data: 'valid' }, { id: 'item-2', data: 'invalid' }, // Will fail { id: 'item-3', data: 'valid' }, ], }); // Act const result = await handler.handle(event); // Assert - Returned partial result expect(result.successCount).toBe(2); expect(result.failureCount).toBe(1); expect(result.failedItems).toContain('item-2'); }); });});| Error Type | Retry? | Example | Handler Behavior |
|---|---|---|---|
| Transient | Yes | Network timeout, service unavailable | Retry with backoff |
| Rate Limited | Yes (with delay) | 429 Too Many Requests | Retry after delay from response |
| Validation | No | Invalid data format | Fail immediately, DLQ |
| Authorization | No | Insufficient permissions | Fail immediately, alert |
| Not Found | No (usually) | Resource deleted | Skip or compensate |
Many handlers don't just update state—they publish follow-up events to trigger additional workflows. Testing these event chains requires verifying both the action completed and the correct event was published.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
describe('Downstream Event Publishing', () => { describe('PaymentProcessingHandler', () => { let handler: PaymentProcessingHandler; let paymentGateway: MockPaymentGateway; let eventPublisher: SpyEventPublisher; beforeEach(() => { paymentGateway = new MockPaymentGateway(); eventPublisher = new SpyEventPublisher(); handler = new PaymentProcessingHandler( paymentGateway, eventPublisher ); }); it('should publish PaymentCapturedEvent on successful payment', async () => { // Arrange paymentGateway.setNextCaptureResult({ transactionId: 'txn-123', amount: Money.of(100.00), status: 'captured', }); const event = new OrderShippedEvent({ orderId: OrderId.from('order-456'), paymentAuthId: PaymentAuthId.from('auth-789'), amount: Money.of(100.00), shippedAt: new Date(), }); // Act await handler.handle(event); // Assert - Verify downstream event published const publishedEvent = eventPublisher.expectEvent(PaymentCapturedEvent); expect(publishedEvent.orderId.value).toBe('order-456'); expect(publishedEvent.transactionId).toBe('txn-123'); expect(publishedEvent.amount.equals(Money.of(100.00))).toBe(true); }); it('should publish PaymentFailedEvent on payment failure', async () => { // Arrange paymentGateway.simulateFailure( new PaymentDeclinedError('Card declined', 'insufficient_funds') ); const event = new OrderShippedEvent({ orderId: OrderId.from('order-declined'), paymentAuthId: PaymentAuthId.from('auth-declined'), amount: Money.of(5000.00), shippedAt: new Date(), }); // Act await handler.handle(event); // Assert const publishedEvent = eventPublisher.expectEvent(PaymentFailedEvent); expect(publishedEvent.orderId.value).toBe('order-declined'); expect(publishedEvent.failureReason).toBe('insufficient_funds'); }); it('should preserve correlation ID in downstream events', async () => { // Arrange const correlationId = CorrelationId.from('corr-trace-123'); paymentGateway.setNextCaptureResult({ transactionId: 'txn-corr', amount: Money.of(50.00), status: 'captured', }); const event = new OrderShippedEvent({ orderId: OrderId.from('order-corr'), paymentAuthId: PaymentAuthId.from('auth-corr'), amount: Money.of(50.00), shippedAt: new Date(), correlationId: correlationId, }); // Act await handler.handle(event); // Assert - Correlation ID propagated const publishedEvent = eventPublisher.expectEvent(PaymentCapturedEvent); expect(publishedEvent.correlationId).toEqual(correlationId); }); it('should set causation ID to source event ID', async () => { // Arrange const sourceEventId = EventId.from('evt-source'); paymentGateway.setNextCaptureResult({ transactionId: 'txn-cause', amount: Money.of(75.00), status: 'captured', }); const event = new OrderShippedEvent({ eventId: sourceEventId, orderId: OrderId.from('order-cause'), paymentAuthId: PaymentAuthId.from('auth-cause'), amount: Money.of(75.00), shippedAt: new Date(), }); // Act await handler.handle(event); // Assert - Causation chain maintained const publishedEvent = eventPublisher.expectEvent(PaymentCapturedEvent); expect(publishedEvent.causationId).toEqual(sourceEventId); }); }); describe('Event Chain Verification', () => { it('should publish events in correct order for multi-step process', async () => { // Handler that produces multiple events const handler = new OrderFulfillmentHandler( new MockInventoryService(), new MockShippingService(), new SpyEventPublisher() ); const event = new OrderPaidEvent({ orderId: OrderId.from('order-multi'), paidAt: new Date(), }); // Act await handler.handle(event); // Assert - Events in correct order const events = handler.eventPublisher.publishedEvents; expect(events[0]).toBeInstanceOf(InventoryReservedEvent); expect(events[1]).toBeInstanceOf(ShippingLabelCreatedEvent); expect(events[2]).toBeInstanceOf(OrderReadyForShipmentEvent); }); });});When testing downstream events, always verify that correlation and causation IDs are correctly propagated. These enable distributed tracing—without them, debugging production issues across services becomes nearly impossible.
As your handler tests grow, organization becomes critical. Let's examine patterns for structuring handler tests to maximize clarity and maintainability.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
/** * Well-organized handler test file structure */describe('NotificationHandler', () => { // Shared dependencies let handler: NotificationHandler; let notificationService: MockNotificationService; let userRepository: InMemoryUserRepository; let preferences: InMemoryPreferencesRepository; // Setup runs before each test beforeEach(() => { notificationService = new MockNotificationService(); userRepository = new InMemoryUserRepository(); preferences = new InMemoryPreferencesRepository(); handler = new NotificationHandler( notificationService, userRepository, preferences ); }); // Group: Happy Path describe('Successful Notifications', () => { it('should send push notification to user', async () => { // Test implementation }); it('should send email when push notification opted out', async () => { // Test implementation }); it('should include personalized content in notification', async () => { // Test implementation }); }); // Group: User Preferences describe('Notification Preferences', () => { it('should respect user quiet hours', async () => { // Test implementation }); it('should not send notification when user opted out', async () => { // Test implementation }); it('should use preferred communication channel', async () => { // Test implementation }); }); // Group: Error Handling describe('Error Handling', () => { it('should handle notification service timeout', async () => { // Test implementation }); it('should log error when user not found', async () => { // Test implementation }); it('should continue processing other users on partial failure', async () => { // Test implementation }); }); // Group: Idempotency describe('Idempotency', () => { it('should not send duplicate notifications', async () => { // Test implementation }); it('should track processed event IDs', async () => { // Test implementation }); }); // Group: Edge Cases describe('Edge Cases', () => { it('should handle event with empty item list', async () => { // Test implementation }); it('should handle maximum notification length', async () => { // Test implementation }); it('should handle special characters in user name', async () => { // Test implementation }); });});Testing event handlers thoroughly ensures the reliability of your event-driven system. Let's consolidate the essential practices:
What's Next:
Unit-testing individual handlers is essential, but insufficient. Real event-driven systems involve multiple handlers, event buses, and databases working together. The next page explores integration testing with events—verifying that the system works correctly as a whole.
You now have the techniques to thoroughly test event handlers in isolation. These unit tests provide fast feedback and catch most bugs. Next, we'll expand to integration testing, where events flow through real (or realistic) infrastructure.