Automating Kafka Validation in Postman: Collections, Scripts, and a CI Gate
Written by Ahmed at Analyst Engineering, a Senior Technical Business Analyst with 10+ years in banking and payments delivery.
Key takeaways
- Postman speaks HTTP and Kafka does not, so the whole technique rests on one bridge: a REST Proxy or a thin test endpoint that lets you produce and consume over HTTP like any other API.
- An event test is asynchronous, which means every assertion needs a poll with backoff and a timeout. A test that checks once immediately after producing will pass on a fast day and fail on a slow one, and a flaky suite gets ignored.
- Assert four things on every event, not just the payload: the schema, the message key, the headers that carry correlation, and the partition, because a correct payload on the wrong key breaks ordering for everything downstream.
- Correlate on a unique id you generated in the test, never on the latest message in the topic. A suite that reads the newest message passes cheerfully while consuming somebody else's event.
- The point of automating this is regression. A manual Kafka check proves the flow worked once; a collection in CI proves it still works after every merge, which is the only version that protects you.
Postman cannot speak Kafka, so every automated Kafka collection starts with a bridge: Confluent REST Proxy or a thin test endpoint that exposes produce and consume over HTTP. Once that exists, the technique is ordinary API testing with one difference that matters. Events are asynchronous, so every assertion is a poll with backoff and a timeout, and every correlation is on an id you generated rather than on whatever message happened to arrive last.
The manual version of this is familiar. You trigger something in the UI, open a console consumer, squint at the JSON scrolling past, find your message, and say it works. That proves the flow worked once, on your machine, at 15:40. It does not survive the next merge, which is the only thing a test is for.
Testing Kafka covers what to test and why event testing is harder than API testing. This article is the automation: turning those checks into a collection that runs unattended on every push. If you want the full worked build with the collection files, it is in Automate Kafka Validation with Postman.
What is the bridge, and which one should you ask for?
Postman sends HTTP. Kafka uses a binary protocol over TCP. Something has to translate, and you have two realistic options.
| Confluent REST Proxy | A test-only endpoint in your service | |
|---|---|---|
| Who builds it | Nobody, it is a deployed component | A developer, about half a day |
| Produce | POST /topics/{topic} | Whatever they expose |
| Consume | Consumer instance plus GET /records | Usually a simple filtered read |
| Schema handling | Integrates with Schema Registry | Whatever they implement |
| Realistic for | Any team already running Confluent | Teams without REST Proxy |
| Risk | None, it is standard | Becomes a production endpoint by accident |
Ask for REST Proxy first, since it exists precisely for this. If the answer is no, the test endpoint is a reasonable request and worth making early, because “we could not automate the event tests” is a cost the team pays every sprint afterwards. Insist it is deployed only to non-production and requires auth.
One thing to settle before you write a line: you need a fresh consumer group per run. Reusing a group means this run inherits the previous run’s offsets, and a test that passed yesterday reads nothing today. Generate the group name from the run id.
The collection structure
Four folders, in this order. The order is the point: setup creates the state the assertions depend on.
kafka-validation/
00-setup/
Create consumer instance POST /consumers/{group}
Subscribe to topics POST /consumers/{group}/instances/{id}/subscription
01-produce/
Publish payment.received POST /topics/np.payments.received
02-assert/
Poll for payment.received GET /consumers/.../records (retry loop)
Poll for payment.settled GET /consumers/.../records (retry loop)
Check consumer side effect GET /payments/{id} (the real API)
99-teardown/
Delete consumer instance DELETE /consumers/{group}/instances/{id}
Teardown matters more than it looks. A leaked consumer instance holds a partition assignment, and after twenty CI runs the test cluster has twenty zombie consumers and rebalancing takes longer than your timeout.
Producing an event with a correlation id you control
The single most important line in the whole collection is the one that generates a unique id. Everything else hangs off it.
// 01-produce Pre-request script
const uetr = require('uuid').v4();
pm.collectionVariables.set('uetr', uetr);
pm.collectionVariables.set('runId', pm.collectionVariables.get('runId') || uetr);
pm.collectionVariables.set('pollAttempt', 0);
// 01-produce Body, Content-Type: application/vnd.kafka.json.v2+json
{
"records": [
{
"key": "{{customerId}}",
"value": {
"uetr": "{{uetr}}",
"customerId": "{{customerId}}",
"instructedAmount": { "amount": "5000.00", "currency": "EUR" },
"createdAt": "{{$isoTimestamp}}"
},
"headers": [
{ "key": "eventType", "value": "cGF5bWVudC5yZWNlaXZlZA==" },
{ "key": "correlationId", "value": "{{uetrBase64}}" }
]
}
]
}
Note the key. It is the customer id, not the payment id, and that is a deliberate decision with consequences: all events for one customer land on the same partition and are therefore ordered relative to each other. If your requirement says payments for a customer must be processed in order, the key is the requirement, and a test that does not assert it is not testing the requirement. This is exactly the kind of rule event-driven requirements exists to make explicit.
// 01-produce Post-response script
pm.test('Broker accepted the record', () => {
pm.response.to.have.status(200);
const offsets = pm.response.json().offsets;
pm.expect(offsets).to.have.lengthOf(1);
pm.expect(offsets[0]).to.not.have.property('error_code');
pm.collectionVariables.set('producedPartition', offsets[0].partition);
pm.collectionVariables.set('producedOffset', offsets[0].offset);
});
Capturing the partition and offset gives you something to assert against later and something concrete to quote in a defect. “Produced to partition 3 at offset 88142, never consumed” is an investigable statement; “the event did not arrive” is not.
The poll loop: the part that makes it reliable
This is where most homemade Kafka collections fail. They produce, then immediately check, and pass on a quiet machine.
// 02-assert Poll for payment.received Post-response script
const MAX_ATTEMPTS = 20; // 20 x 500ms = 10s budget
const INTERVAL_MS = 500;
const uetr = pm.collectionVariables.get('uetr');
let attempt = Number(pm.collectionVariables.get('pollAttempt')) + 1;
pm.collectionVariables.set('pollAttempt', attempt);
const records = pm.response.code === 200 ? pm.response.json() : [];
const match = records.find(r => r.value && r.value.uetr === uetr);
if (match) {
pm.collectionVariables.set('pollAttempt', 0);
pm.collectionVariables.set('matched', JSON.stringify(match));
pm.test('Event arrived on the expected partition', () => {
pm.expect(match.partition).to.eql(
Number(pm.collectionVariables.get('producedPartition')));
});
pm.test('Message key is the customer id, so ordering holds', () => {
pm.expect(match.key).to.eql(pm.collectionVariables.get('customerId'));
});
pm.test('Payload matches the published schema', () => {
pm.response.to.have.jsonSchema(
JSON.parse(pm.collectionVariables.get('paymentReceivedSchema')), match.value);
});
pm.test('Amount survived the round trip exactly', () => {
// string comparison: 5000.00 must not become 5000 or 5000.0000001
pm.expect(match.value.instructedAmount.amount).to.eql('5000.00');
});
} else if (attempt < MAX_ATTEMPTS) {
setTimeout(() => {}, INTERVAL_MS);
pm.execution.setNextRequest('Poll for payment.received');
} else {
pm.test(`Event ${uetr} arrived within ${MAX_ATTEMPTS * INTERVAL_MS}ms`, () => {
pm.expect.fail(
`No record with uetr ${uetr} after ${attempt} polls. ` +
`Produced to partition ${pm.collectionVariables.get('producedPartition')} ` +
`offset ${pm.collectionVariables.get('producedOffset')}.`);
});
}
Three details make this worth copying.
The filter is on uetr, not on position. Reading “the latest message” is the most common defect in event test suites, because on a shared test topic the latest message belongs to someone else and your test passes on their data.
The failure message carries the partition and offset. A failing test that hands the investigator a starting point is worth several that merely say false is not true.
The amount is compared as a string. Serialisation round trips are where decimal precision quietly dies, and 5000.00 becoming 5000 is a real defect in a payments flow that a loose comparison hides. Same family of problem as the traps in ISO 20022 amounts and FX.
Asserting the thing that actually matters
A published event proves the producer works. It does not prove anything happened. The test is not finished until you assert the side effect, on the real API or the database.
// 02-assert Check consumer side effect Post-response script
pm.test('The consumer processed the event into a payment record', () => {
pm.response.to.have.status(200);
const body = pm.response.json();
pm.expect(body.uetr).to.eql(pm.collectionVariables.get('uetr'));
pm.expect(body.status).to.be.oneOf(['ACCP', 'ACSP']);
});
pm.test('Exactly one record exists, so the event was not double-processed', () => {
pm.expect(pm.response.json().matches).to.eql(1);
});
The second assertion is the idempotency check, and on an at-least-once broker it is not optional. Kafka guarantees delivery at least once, so a redelivery will happen eventually, and a consumer without idempotent handling turns that into a duplicate payment. Add a request that publishes the same uetr twice and asserts the record count is still one; that single test is worth more than the rest of the collection. The reasoning is in idempotency testing.
Then the failure path. Publish a malformed event and assert it reaches the dead letter topic rather than vanishing or stalling the partition:
pm.test('Malformed event is routed to the DLQ, not silently dropped', () => {
const dlq = pm.response.json().find(r => r.value.uetr === pm.collectionVariables.get('badUetr'));
pm.expect(dlq, 'no DLQ record found').to.not.be.undefined;
pm.expect(dlq.value.error).to.include('schema');
});
A consumer that stalls on a poison message stops processing everything behind it, which is the outage nobody diagnoses quickly. Dead letter queues covers the design; this is the assertion that proves it exists.
Running it in CI
Export the collection and environment, and run with newman. Nothing exotic.
# .github/workflows/kafka-validation.yml
name: kafka-validation
on:
push:
branches: [main]
schedule:
- cron: "0 3 * * *"
jobs:
events:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm install -g newman newman-reporter-junitfull
- name: Run the Kafka validation collection
run: |
newman run collections/kafka-validation.json \
--environment environments/test.json \
--env-var "consumerGroup=ci-${GITHUB_RUN_ID}" \
--env-var "restProxy=${{ secrets.REST_PROXY_URL }}" \
--env-var "proxyToken=${{ secrets.REST_PROXY_TOKEN }}" \
--reporters cli,junitfull \
--reporter-junitfull-export build/junit-kafka.xml \
--timeout-request 15000 \
--bail folder
- uses: actions/upload-artifact@v4
if: always()
with: { name: junit-kafka, path: build/junit-kafka.xml }
Four choices worth explaining:
consumerGroup=ci-${GITHUB_RUN_ID}gives every run a fresh group, so offsets from the last run cannot affect this one. This single flag removes the most common source of nightly flakiness.--bail folderstops the current folder on failure but continues the rest, so one broken flow does not hide the results of the other three.--timeout-request 15000must exceed your poll budget, or newman kills a request the script was still retrying.- JUnit output makes the results test results rather than log lines, which is what lets them join back to requirement ids exactly as in the execution pipeline.
Where this fits in the overall suite: event validation belongs in the integration layer, running on merge to main and nightly. It needs a real cluster, so it must never block a commit. The layering argument is in API tests in CI.
If your team uses Bruno instead
Everything above transfers. Bruno stores collections as plain files in a folder you own, which suits git and regulated environments better than a cloud workspace, and its CLI emits JUnit the same way. The differences are mechanical: assertions live in a script:post-response block, the retry uses bru.setNextRequest, and variables are bru.setVar rather than pm.collectionVariables.set. The comparison is in Bruno vs Postman for analysts.
What this suite does not prove
Keep the claims honest when you report it.
- It does not prove your real consumers work. REST Proxy is not how your services connect. The side-effect assertion is what covers that gap, so never drop it.
- It does not prove ordering under load. Producing five events in sequence from one test says nothing about what happens with three producers and a rebalance.
- It does not prove schema evolution is safe. A collection tests today’s schema. Backward compatibility needs contract testing against the registry.
- It does not prove throughput. Functional correctness and performance are different suites, and a passing collection says nothing about the second.
The takeaway
Automating Kafka validation in Postman comes down to four things. Get a bridge, REST Proxy or a test endpoint, so HTTP can reach the topic. Generate a unique correlation id in the test and filter on it, never on the newest message. Poll with backoff and a real timeout, and put the partition and offset in the failure message. Assert the schema, the key, the headers, and the consumer’s side effect, then prove a redelivery does not double-process.
Then run it with newman on every merge with a fresh consumer group per run, emit JUnit, and the check that used to be a person squinting at a console becomes a gate that holds after you stop watching. For the full build with the collection files, see Automate Kafka Validation with Postman, or browse everything at The Tech BA Toolkit.
Ahmed is a Senior Technical Business Analyst with 10+ years in banking and payments. He builds practical guides and tools for analysts at The Tech BA Toolkit.
Tags: QA, Kafka, Event-Driven, Postman, Automation
About the author
Analyst Engineering is written by Ahmed, a Senior Technical Business Analyst with 10+ years of banking and payments delivery experience: ISO 20022 and SWIFT messaging, payments API integration, Kafka event validation, and production support. Every article comes from real delivery work, and each one is reviewed and updated as tools and standards change.
Related articles
- How to Test Kafka: Validating Events You Cannot See A practitioner guide to testing Kafka: consuming events in a test, asserting schema and key, verifying ordering, duplicates, and the consumer side effects that matter.
- Event-Driven Requirements: Specifying Systems That Talk in Events How to write requirements for event-driven systems: event schemas, ordering, idempotency, retries, and consistency. A practitioner guide with a Kafka example.
- Dead-Letter Queues: Where Failed Messages Go What a dead-letter queue is, why event-driven systems need one, and how an analyst specifies DLQ behavior: retries, routing, monitoring, and recovery. With examples.
- Running API Tests in CI: Bruno CLI and Newman in GitHub Actions Run API test collections in CI: Bruno CLI and Newman in GitHub Actions, secrets, tags, JUnit and HTML reports, private networks, and flaky-test rules.
Free account
Practice on the Labs, keep your progress
A free account, no password: an email link signs you in. It saves your steps and self-assessments on the Labs, shows your missions on a dashboard, unlocks the solutions, and, if you tick the box, sends you new missions and articles when they ship.
Your email is used to sign you in. Nothing else, unless you ask. Privacy.