>_ Analyst Engineering

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.

Cover for a guide to automating Kafka event validation with Postman collections, scripts, and a CI gate.

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 ProxyA test-only endpoint in your service
Who builds itNobody, it is a deployed componentA developer, about half a day
ProducePOST /topics/{topic}Whatever they expose
ConsumeConsumer instance plus GET /recordsUsually a simple filtered read
Schema handlingIntegrates with Schema RegistryWhatever they implement
Realistic forAny team already running ConfluentTeams without REST Proxy
RiskNone, it is standardBecomes 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 folder stops the current folder on failure but continues the rest, so one broken flow does not hide the results of the other three.
  • --timeout-request 15000 must 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.

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.