Testing Kafka Consumers With an In-Memory Broker

Kafka consumers are usually tested in one of two unsatisfying ways: against a real cluster that takes thirty seconds to start and behaves differently every run, or not at all beyond the handler function. The first makes the suite slow and flaky; the second leaves the consumer’s own responsibilities — decoding, per-partition ordering, committing offsets only after success, redelivering after failure — completely unverified. This guide builds a small in-memory broker that models the parts of Kafka a consumer depends on, uses it to test those responsibilities deterministically, and keeps a handful of tests against a real broker for the client library itself. It targets KafkaJS 2.x with Vitest, and sits under event-driven and queue mocking.

Root Cause Analysis

A Kafka consumer has more responsibility than it appears to. Beyond calling a handler for each message, it decides when a message counts as done — by committing its offset — and that decision determines what happens after a crash. Commit before the handler finishes and a crash loses the message; commit after and a crash redelivers it. Getting this wrong is invisible in normal operation and catastrophic during an incident, and it is precisely the behaviour a handler-only test cannot see.

Ordering is the second responsibility. Kafka guarantees order within a partition, not across the topic, and a consumer that processes a partition’s messages concurrently breaks the guarantee its producers were relying on. A test with a single message cannot detect that; a test with several messages for the same key, processed with realistic concurrency, can.

A real cluster exercises both, but it does so non-deterministically. Partition assignment, rebalance timing and batch boundaries vary run to run, so a test that relies on them is flaky by construction. An in-memory model that captures only partitions, offsets and commits — and lets the test decide when things happen — turns those behaviours into ordinary, reproducible assertions.

When to commit an offset, and what a crash does in each case Committing before the handler finishes means a crash loses the message, committing after means a crash redelivers it; at-least-once processing with idempotent handlers requires the second. Commit first commit offset run handler crash message lost — never retried Commit after success run handler crash redelivered handled again — needs idempotency the second is almost always right, and only a consumer-level test can tell which one you have
Commit placement is a correctness decision that handler tests cannot observe.

Reproducible Setup

Keep the consumer’s logic in a function that depends on a small interface rather than on the KafkaJS client directly. The real adapter implements the interface with KafkaJS; tests implement it in memory.

// src/messaging/consumer-port.ts
export type Record = { topic: string; partition: number; offset: string; key: string; value: string };

export interface ConsumerPort {
  run(eachMessage: (r: Record) => Promise<void>): Promise<void>;
  commit(r: Record): Promise<void>;
}
// src/orders/consume-orders.ts — the consumer under test
import type { ConsumerPort } from '../messaging/consumer-port';
import { orderEventSchema } from './events';
import { onOrderEvent } from './handlers';

export function consumeOrders(port: ConsumerPort, deps: Deps) {
  return port.run(async (record) => {
    const parsed = orderEventSchema.safeParse(JSON.parse(record.value));
    if (!parsed.success) {
      await deps.deadLetter.put(record, 'schema_invalid');
      await port.commit(record);                       // do not block the partition on a poison message
      return;
    }
    await onOrderEvent(parsed.data, deps);             // throws on failure → no commit → redelivery
    await port.commit(record);
  });
}

Implementation

Step 1 — Model partitions and offsets in memory. The broker keeps an append-only log per partition and a committed offset per partition; that is all a consumer’s correctness depends on.

// test/fakes/memory-kafka.ts
import type { ConsumerPort, Record } from '../../src/messaging/consumer-port';

export function memoryKafka(partitions = 3) {
  const logs: Record[][] = Array.from({ length: partitions }, () => []);
  const committed = new Array<number>(partitions).fill(-1);
  const partitionFor = (key: string) => [...key].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % partitions;

  return {
    produce(topic: string, key: string, value: unknown) {
      const p = partitionFor(key);
      logs[p].push({ topic, partition: p, offset: String(logs[p].length), key, value: JSON.stringify(value) });
    },
    committed: () => [...committed],
    port(): ConsumerPort & { drain(): Promise<void> } {
      let handler: (r: Record) => Promise<void>;
      return {
        async run(h) { handler = h; },
        async commit(r) { committed[r.partition] = Number(r.offset); },
        async drain() {
          for (let p = 0; p < partitions; p++) {
            for (const r of logs[p].slice(committed[p] + 1)) {
              try { await handler(r); } catch { break; }   // stop this partition, as Kafka would
            }
          }
        },
      };
    },
  };
}

Step 2 — Assert that offsets advance only after success. A failing handler must leave the offset where it was, so the next poll redelivers the same message.

// src/orders/consume-orders.test.ts
import { test, expect } from 'vitest';
import { memoryKafka } from '../../test/fakes/memory-kafka';
import { consumeOrders } from './consume-orders';
import { memoryDeps } from '../../test/fakes/order-deps';

test('does not commit when the handler fails, so the message is redelivered', async () => {
  const kafka = memoryKafka(1);
  const deps = memoryDeps();
  deps.invoices.failNext();

  kafka.produce('orders', 'o1', { type: 'OrderPlaced', eventId: 'e1', orderId: 'o1', totalPence: 1000 });
  const port = kafka.port();
  await consumeOrders(port, deps);

  await port.drain();
  expect(kafka.committed()).toEqual([-1]);           // nothing committed after the failure

  await port.drain();                                 // redelivery
  expect(kafka.committed()).toEqual([0]);
  expect(deps.invoices.all()).toHaveLength(1);
});

Step 3 — Assert per-key ordering. Messages for the same key land on the same partition and must be handled in the order produced, which is the guarantee producers rely on.

test('processes events for one order in the order they were produced', async () => {
  const kafka = memoryKafka(3);
  const deps = memoryDeps();
  for (const type of ['OrderPlaced', 'OrderPaid', 'OrderShipped'] as const) {
    kafka.produce('orders', 'o9', { type, eventId: `${type}-o9`, orderId: 'o9', totalPence: 500 });
  }
  const port = kafka.port();
  await consumeOrders(port, deps);
  await port.drain();

  expect(deps.history('o9')).toEqual(['OrderPlaced', 'OrderPaid', 'OrderShipped']);
});

Step 4 — Assert that a poison message does not block its partition. A payload that can never be processed goes to the dead-letter store and is committed, so later messages on the same partition still flow.

test('dead-letters an invalid payload and carries on with the partition', async () => {
  const kafka = memoryKafka(1);
  const deps = memoryDeps();
  kafka.produce('orders', 'o1', { nonsense: true });
  kafka.produce('orders', 'o1', { type: 'OrderPlaced', eventId: 'e2', orderId: 'o1', totalPence: 100 });

  const port = kafka.port();
  await consumeOrders(port, deps);
  await port.drain();

  expect(deps.deadLetter.all()).toHaveLength(1);
  expect(deps.invoices.all()).toHaveLength(1);
  expect(kafka.committed()).toEqual([1]);
});
Ordering is per partition, not per topic Messages keyed by one order all land on the same partition and must be processed in sequence, while messages for other orders on other partitions may be processed in parallel with no ordering relationship. partition 0 o9 placed o9 paid o9 shipped strictly in order partition 1 o2 placed o5 placed partition 2 o3 placed partitions may run in parallel; messages within one may not
A consumer that parallelises within a partition breaks the one ordering guarantee Kafka provides.

Step 5 — Keep a few tests against a real broker. The KafkaJS adapter — connection, group membership, the mapping to the port — needs a real cluster to verify, but only a handful of tests.

// test/integration/kafka-adapter.test.ts
import { KafkaContainer } from '@testcontainers/kafka';

let container: StartedKafkaContainer;
beforeAll(async () => { container = await new KafkaContainer('confluentinc/cp-kafka:7.6.1').start(); }, 120_000);
afterAll(() => container.stop());

test('the adapter commits after the handler resolves', async () => {
  // produce one message, run the real adapter, and read the group's committed offset
});

Step 6 — Namespace topics and consumer groups per run. A shared cluster in CI will otherwise deliver one job’s messages to another job’s consumer.

const run = process.env.GITHUB_RUN_ID ?? 'local';
const topic = `orders-${run}-${process.pid}`;
const groupId = `orders-consumer-${run}-${process.pid}`;

Verification

Run the in-memory suite and confirm it covers each consumer responsibility, not only the happy path.

npx vitest run src/orders/consume-orders.test.ts --reporter=verbose
# ✓ does not commit when the handler fails, so the message is redelivered
# ✓ processes events for one order in the order they were produced
# ✓ dead-letters an invalid payload and carries on with the partition

Then prove the commit test is meaningful by moving the commit before the handler call in the consumer. The redelivery test must fail; if it still passes, the fake is not modelling commits faithfully.

What each tier verifies The in-memory broker verifies commit placement, per-key ordering and dead-lettering in milliseconds, while the containerised broker verifies the KafkaJS adapter's connection and group behaviour in a few slow tests. in-memory broker commit placement, ordering, poison messages, retries dozens of cases, milliseconds real broker client connection, group membership, real commits a handful, tens of seconds
The fake carries the logic; the container confirms the adapter speaks to Kafka correctly.

Troubleshooting

Symptom: a failed message is never retried in the in-memory tests. Diagnosis: the fake commits unconditionally, or the consumer swallows the handler’s error. Fix: let handler errors propagate to the port, and have the fake stop draining a partition at the first failure, as Kafka would.

Symptom: ordering tests pass but production processes a key out of order. Diagnosis: the real consumer uses a batch or concurrency setting that processes several messages from one partition at once. Fix: configure per-partition concurrency to one for order-sensitive topics, and assert that setting in the adapter’s own test.

Symptom: the containerised tests time out waiting for messages. Diagnosis: the consumer joined the group after the producer wrote, and it starts from the latest offset. Fix: set fromBeginning: true for test consumers, or subscribe before producing.

Symptom: rebalances cause duplicate processing in the real-broker tests. Diagnosis: expected behaviour — a rebalance can redeliver uncommitted messages. Fix: this is what the idempotency tests exist for; assert on side-effect counts rather than on handler invocation counts in these tests.

FAQ

Is there an existing in-memory Kafka library I should use?

Some exist, but they tend to model more of Kafka than a consumer test needs and less faithfully than you would hope. A fake built around the three things your consumer depends on — per-partition logs, committed offsets, stop-on-failure — is small, obvious, and exactly as faithful as the tests require.

Should I mock KafkaJS directly with vi.mock?

You can, but the resulting tests are coupled to the client’s call shapes and break on upgrades. Putting a port between your code and the client keeps the tests about your behaviour and confines KafkaJS to one adapter, verified by the small containerised suite.

How do I test consumer lag or throughput?

Not in the functional suite. Lag and throughput are properties of a deployment under load, measured with load tools against a realistic cluster. The functional suite’s job is correctness under the delivery conditions Kafka permits, which is a different question.

What about schema registries and Avro?

Treat decoding as part of the consumer adapter and test it with real encoded payloads captured from the registry, so the decoder’s behaviour is pinned. The contract between producer and consumer schemas is better verified with contract tests, as described in contract testing.