Message Queue & Event-Driven Testing
Synchronous tests verify what happens now. Async tests verify what happens later. With message queues, you can't just check the response; you must wait for side effects, handle duplicates, and test failure modes that don't exist in request-response.
1 The Hook — Why This Matters
In 2022, a major NZ bank processed customer transfers via Kafka. When a user transferred NZD 1000 to a friend, an event TransferRequested was published. A Ledger Service listened and recorded the transaction. A Notification Service listened and sent an SMS. Everything worked fine for six months. Then, a network partition isolated the Ledger Service from Kafka for 30 seconds. During those 30 seconds, 500 transfer events were queued. When the partition healed, the Ledger Service reprocessed all 500 events — but it had no deduplication logic. Each transfer was recorded twice. Customers saw duplicate withdrawals. Recovery took a week and cost the bank NZD 150,000 in goodwill credits.
Event-driven systems are asynchronous, distributed, and unreliable by default. Testing must account for duplicates, ordering, timeouts, and replay. A single missing test for idempotency cost millions.
2 The Rule — The One-Sentence Version
Every producer must be testable independently. Every consumer must handle duplicates, out-of-order messages, and schema evolution. The entire chain must be testable for ordering, timing, and replay.
Event-driven testing spans three layers: (1) Producer Testing — does the producer emit the right message to the right topic? (2) Consumer Testing — can the consumer handle the message, duplicates, and errors? (3) End-to-End Testing — do messages flow through the entire chain, and can we recover from failures? Test each layer independently with fast unit tests, then together with integration tests using TestContainers or real brokers.
3 The Analogy — Think Of It Like...
Testing a postal service, not a phone call.
With synchronous request-response, it's like a phone call: you ask a question, wait for an answer, know immediately if it failed. With asynchronous messaging, it's like mailing a letter. You drop it in a mailbox (producer), hope it arrives (broker), and wait days for a reply (consumer). The post office might deliver the letter twice (duplicate). The letter might arrive out of order (two letters sent Monday and Tuesday arrive in reverse order). The recipient might be away and not read it for a week. Test that the sender writes the letter correctly, the recipient reads it and acts on it, the postal service doesn't lose it, and duplicates don't cause chaos.
4 Watch Me Do It — Step by Step
Here is a real NZ example: an ecommerce platform publishes OrderPlaced events. A Notification Service listens and sends emails. A Fulfillment Service listens and picks items. Test each layer.
-
Test the producer in isolation
Mock the message broker. When the Order Service places an order, verify it publishes an
OrderPlacedevent with the correct schema (orderId, customerId, items, total). Use a test double to capture the message and assert its contents. Do not use the real broker yet.// Producer test (unit level) const mockBroker = new MockBroker(); const orderService = new OrderService(mockBroker); await orderService.placeOrder({customerId: 'cust-123', items: [...]}); expect(mockBroker.publishedMessages).toContainEqual( expect.objectContaining({ type: 'OrderPlaced', orderId: expect.any(String), customerId: 'cust-123' }) ); -
Test the consumer in isolation
Mock the message broker. Create a
OrderPlacedevent and feed it to the Notification Service. Verify the service sends an email (by mocking the email client). Do not use the real broker yet.// Consumer test (unit level) const mockEmailClient = new MockEmailClient(); const notificationService = new NotificationService(mockEmailClient); await notificationService.handleOrderPlaced({ orderId: 'ord-123', customerId: 'cust-123', email: 'user@example.com' }); expect(mockEmailClient.sentEmails).toContainEqual( expect.objectContaining({to: 'user@example.com', subject: expect.stringContaining('Order Confirmed')}) ); -
Test idempotency (the crucial test)
Feed the same message to the consumer twice. Verify it's handled idempotently: the email is sent only once, the database record is not duplicated. This is the test that the NZ bank skipped, costing NZD 150,000.
// Idempotency test const notification = { messageId: 'msg-abc123', // unique message ID from broker type: 'OrderPlaced', orderId: 'ord-123' }; await notificationService.handleOrderPlaced(notification); await notificationService.handleOrderPlaced(notification); // same message again // Verify email sent only once expect(mockEmailClient.sentEmails).toHaveLength(1); -
Test exactly-once semantics (or at-least-once + deduplication)
Message brokers guarantee "at-least-once" delivery, not "exactly-once." That means a message might be delivered multiple times. Your consumer must deduplicate based on messageId. Test: publish a message, simulate a network failure, republish the same message, verify the consumer handles both instances as one logical event.
Pattern: Store messageIds in the database. Before processing a message, check if the messageId exists. If it does, skip processing. If not, process and insert the messageId. This guarantees idempotency at the cost of one database query.
-
Test dead letter queue handling
When a consumer fails to process a message (e.g., email service is down), the message should be retried. After N retries, it should be moved to a Dead Letter Queue (DLQ). Test: mock the email service to fail, publish a message, verify the message is retried N times, then moved to DLQ. Verify DLQ messages can be replayed manually.
// Consumer with DLQ try { await emailService.send(email); } catch (error) { retryCount++; if (retryCount > MAX_RETRIES) { await deadLetterQueue.send(message); // move to DLQ } else { await broker.republish(message, {delayMs: 1000 * retryCount}); // exponential backoff } } - Test message ordering (if applicable) Kafka guarantees ordering within a partition, but not across partitions. If your consumer processes messages out of order, does it still work? Test: publish three events (OrderPlaced, OrderShipped, OrderDelivered) to the same partition, verify they're processed in order. Then, publish to different partitions and verify you handle out-of-order gracefully (e.g., ignore DeliveryNotification if Order is not yet placed).
-
Test schema evolution
The producer adds a new field (e.g.,
shippingAddress). The consumer doesn't expect it. Will it crash? Test: publish an event with the new field, verify the old consumer ignores it and continues working. Then, publish an event without the new field, verify the new consumer defaults the value and continues working. -
Test end-to-end with TestContainers or real broker
Spin up Kafka (or RabbitMQ) in Docker. Spin up Order Service, Notification Service, and Fulfillment Service. Place an order, verify it's published to Kafka, verify Notification Service receives it and sends an email, verify Fulfillment Service receives it and picks items. Verify all three happen asynchronously. Then, kill Notification Service, place another order, verify it's queued in Kafka, restart Notification Service, verify it processes the queued order.
// TestContainers example const kafka = new KafkaContainer(); await kafka.start(); const orderService = new OrderService({brokerUrl: kafka.getBrokerUrl()}); const notificationService = new NotificationService({brokerUrl: kafka.getBrokerUrl()}); // Place order and verify both producer and consumer work await orderService.placeOrder({...}); await sleep(500); // allow async processing expect(notificationService.handledEvents).toContainEqual(expect.objectContaining({type: 'OrderPlaced'}));
5 When to Use It / When NOT to Use It
✅ Prioritise event-driven testing when...
- Your system uses Kafka, RabbitMQ, AWS SQS, or similar
- Services communicate asynchronously via events
- You need high throughput or loose coupling
- You have multiple consumers per event
- Data consistency is eventual, not immediate
- You need to replay or reprocess events
❌ Don't fall into these traps...
- Testing only the happy path (message arrives, processed once)
- Skipping idempotency tests (it will bite you in production)
- Ignoring dead letter queues and error handling
- Testing without real timing/delays (async breaks without sleep)
- Not testing message ordering or schema evolution
- Assuming the broker will never lose a message (it can; test recovery)
6 Common Mistakes — Don't Do This
❌ Testing only the happy path (message arrives, processed once)
I used to think: If the message is published and the consumer processes it, everything works.
Actually: Networks are unreliable. Messages get duplicated. Consumers crash and restart. Brokers fail. The NZ bank paid NZD 150,000 to learn this. Test failures: broker down, consumer down during processing, message arrives twice, message arrives out of order.
❌ Skipping idempotency tests
I used to think: Deduplication is the broker's job, not mine.
Actually: The broker guarantees at-least-once delivery, not exactly-once. Your consumer must be idempotent. Feeding the same message twice should produce the same side effects as feeding it once. Test this explicitly. Store messageIds in the database and deduplicate.
❌ Testing without real timing and delays
I used to think: If I mock the broker and assert immediately, it's fast and sufficient.
Actually: Async breaks on timing. The consumer processes the message after some delay. If you assert before the consumer finishes, the test passes incorrectly. Use TestContainers (real broker) and add explicit waits (sleep, polling, latch) to verify consumers eventually process messages.
7 Now You Try — Interview Warm-Up
Question: You're testing payment processing: a Payment Service publishes PaymentProcessed events to Kafka. A Ledger Service listens and records the transaction. A Notifications Service listens and sends a receipt. The Ledger Service handles idempotency using messageId deduplication. A network glitch causes Kafka to re-deliver the same PaymentProcessed message twice. What do you test to ensure Ledger Service handles this correctly?
Think about the test cases before revealing.
Test cases to write:
- First delivery: Ledger Service receives PaymentProcessed with messageId abc123. Verify transaction is recorded in the database. Verify messageId abc123 is stored (marked as processed).
- Duplicate delivery: Ledger Service receives the same message again (same messageId abc123). Verify it checks the messageId, finds it already processed, and skips re-recording. Verify the database still has only one transaction (not two).
- Persistence check: Restart Ledger Service. Resend the duplicate message. Verify it still deduplicates (messageId check must survive restarts, so it must be in the database, not memory).
- Notifications are sent once: Verify Notification Service receives the first PaymentProcessed but not the duplicate (or handles its own deduplication). Receipt email sent only once, not twice.
8 Self-Check — Can You Actually Do This?
Click each question to reveal the answer. If you got all three, you're ready to test async systems.
Q1. What's the difference between "at-least-once" and "exactly-once" delivery?
At-least-once: The broker guarantees the message will be delivered at least once. It might be delivered twice if there's a failure and retry. Exactly-once: Guaranteed one delivery, no duplicates. Most brokers (Kafka, RabbitMQ) guarantee at-least-once. Exactly-once is a consumer responsibility: store messageIds and deduplicate. The NZ bank's mistake: they assumed the broker would deduplicate. It didn't.
Q2. How do you test idempotency?
Send the same message to the consumer twice (with the same messageId). Verify the side effect happens only once. For example, if the message is "record transaction," check that only one transaction row is in the database, not two. If the message is "send email," verify only one email is sent. This requires the consumer to store and check messageIds before processing.
Q3. Why is a Dead Letter Queue (DLQ) important?
When a consumer fails to process a message (e.g., database is down, third-party API is unreachable), you can't just drop the message. After N retries, move it to a DLQ where it can be inspected, debugged, and replayed manually. Without a DLQ, failed messages are lost silently. Test that your consumer moves messages to the DLQ after MAX_RETRIES and that you can replay DLQ messages.
9 Interview Prep — Common Questions
Q. "How do you ensure exactly-once processing with at-least-once delivery?"
I store messageIds in the database. Before processing a message, I check if the messageId exists. If it does, the message has been processed before, so I skip it. If not, I process the message and insert the messageId. This guarantees idempotency: redelivered messages are silently deduped. I test this by sending the same messageId twice and verifying the side effect happens only once.
Q. "What's your strategy for testing async consumers?"
I test in three layers: (1) Unit test the consumer logic with mocked broker and mocked external services. (2) Integration test with TestContainers (real Kafka/RabbitMQ in Docker). (3) End-to-end test with the full system (producer, broker, consumer). For timing, I use explicit waits (sleep, polling, CountdownLatch) to verify consumers eventually process messages. I never assert immediately in async code.
Q. "How do you handle dead letter queues and poison pills?"
When a consumer fails to process a message, I retry with exponential backoff (1s, 2s, 4s, ...). After MAX_RETRIES, I move the message to a DLQ. A separate monitoring service watches the DLQ, alerts engineers, and allows manual replay. I test that messages move to DLQ after failures, and that replayed messages are processed correctly. This prevents silent data loss.
Q. "How do you test message ordering?"
Kafka guarantees ordering within a partition. If my consumer processes messages out of order, I must handle it gracefully. I test by publishing three events (1, 2, 3) to the same partition and verify they're consumed in order. Then, I publish to different partitions and verify I handle out-of-order gracefully (e.g., store messages and process when all dependencies are available). This is important for state machines (order state transitions must be sequential).