Event-driven systems are powerful — but testing them can feel heavy.
In event-driven systems, the contract between producers and consumers is everything. A field gets renamed. A schema evolves. A new required property sneaks in. Any of those can silently break a downstream service, and you won't find out until something blows up in production.
That's the problem I built Mokapi to solve. Define your Kafka topics in an AsyncAPI spec, and Mokapi enforces that contract automatically. Every message gets validated against the schema, no matter where it comes from. Change the spec and run your tests. If anything is out of line, you'll know immediately.
Because Mokapi acts as a real Kafka broker on localhost:9092, your service doesn't need to change at all. No special test mode. No stubbed clients. Just your actual code running against a mock that holds it to the contract.
The Idea Behind Mokapi
Mokapi lets you define Kafka topics with an AsyncAPI spec and interact with them over a simple REST API. No broker. No Zookeeper. Your service connects to localhost:9092 exactly like it does in production, but Mokapi is answering. The application code doesn't change at all.
In this article I'm using Mokapi's HTTP API to inject test messages, but Mokapi actually supports several other ways to produce messages too, including a JavaScript API and OpenAPI-backed endpoints with custom handlers.
And in all cases, Mokapi validates every message against your AsyncAPI schema by default. You're not just checking that a message arrived, you're checking it's valid according to the contract.
A Real Workflow Test
Here's the scenario: a backend consumes a command from document.send-command, processes it, and publishes a result to document.send-event. The test acts as the foreign system that sends the command and verifies the outcome.
I use Playwright as the test runner (yes, the browser tool, but it's great for async workflows even without a browser).
test('Kafka document send workflow', async () => {
const documentId = 'doc-' + Date.now();
let startOffset = -1;
await test.step('Record current offset', async () => {
startOffset = await getPartitionOffset(TOPIC_EVENT, 0);
});
await test.step('Produce command', async () => {
await produce(TOPIC_COMMAND, {
key: documentId,
value: { documentId, recipient: 'alice@mokapi.io', ... }
});
});
await test.step('Wait for event and assert', async () => {
let record;
const timeout = Date.now() + 5000;
while (Date.now() < timeout && !record) {
const records = await read(TOPIC_EVENT, 0, startOffset);
record = records.find(x => x.value.documentId === documentId);
if (!record) {
startOffset += records.length;
await new Promise(res => setTimeout(res, 200));
}
}
expect(record).not.toBeNull();
expect(record.value.status).toBe('SENT');
});
});
The offset tracking is the thing people miss the first time. Without it, you might pick up a stale message from a previous run and get a false positive. Record the offset before you produce, then only read from there.
The backend itself runs completely unchanged, connecting to localhost:9092 as normal. It doesn't know Mokapi is there. That's kind of the whole point.
Why I Think This Matters
The backend isn't mocked. Your actual consumer and producer code runs against a spec-validated broker. You're testing your logic, not simulating it. And because Mokapi starts in seconds with no infrastructure dependencies, this works fine in CI without any special setup.
Full Walkthrough
This is the short version. I wrote a complete guide on my site covering the full AsyncAPI spec, the Node.js backend, all the Playwright helpers, and how to extend this pattern to more complex workflows.
Working repo: mokapi-kafka-workflow
Happy to answer questions in the comments if you run into anything.
Top comments (0)