Resource library

QA How-To

Consumer Driven Contract Testing for Kafka With Pact (2026)

Build consumer driven contract testing Kafka Pact tests in TypeScript, verify producers, publish contracts, and add safe deployment checks in CI.

22 min read | 2,342 words

TL;DR

Consumer driven contract testing for Kafka with Pact starts at the receiving service: describe the smallest event the consumer needs, execute its handler against a generated message, and publish the pact. The producer then generates the same event in provider verification, while separate Kafka integration tests cover topics, serialization, offsets, and delivery semantics.

Key Takeaways

  • Put message handling behind a plain function so Pact can test it without starting Kafka.
  • Use Pact V4 asynchronous interactions for one-way Kafka events.
  • Match fields by type or format while keeping business-critical values exact.
  • Verify the real producer event factory against the consumer-generated pact.
  • Treat Kafka transport behavior and event contract compatibility as separate test layers.
  • Publish immutable pact versions and run can-i-deploy before release.
  • Use provider states to create deterministic producer scenarios.

Consumer driven contract testing Kafka Pact workflows let a Kafka consumer state its event requirements as an executable contract, then make the producer prove that it still emits a compatible event. Pact does not need a running Kafka cluster for this check. It replaces the broker at the application boundary, sends a generated event to the consumer handler, and records the expectation in a pact file.

This tutorial builds a TypeScript order event example from both sides. You will define the consumer's minimum needs, verify its actual handler, verify the producer's actual event factory, and prepare the contract for a Pact Broker CI workflow. For broader context, read the event-driven API testing guide and the contract testing guide.

TL;DR

Concern Pact message test Kafka integration test
JSON fields and value types Yes Possible, but slower
Consumer handler behavior Yes Yes
Producer output compatibility Yes Possible
Topic configuration and ACLs No Yes
Serialization and headers on the wire Only modeled metadata Yes
Partitions, offsets, retries, rebalancing No Yes

Use both layers. Pact gives fast compatibility feedback per consumer and producer version. A small broker-backed test proves that the transport wiring matches the assumptions represented in Pact.

What You Will Build

You will create a small billing-worker consumer and an order-service producer around an OrderCreated event. By the end, you will have:

  • A typed consumer handler that rejects malformed events and returns a useful result.
  • A Pact V4 asynchronous message interaction owned by the consumer.
  • A generated pact under pacts/ with content and Kafka-like metadata expectations.
  • A provider verification that calls the production event factory.
  • Package scripts for local checks, publication, and deployment safety.

The example uses JSON because it makes the contract mechanics visible. If your organization uses Avro, Protobuf, or JSON Schema, keep schema-registry compatibility checks too. The Avro schema compatibility tutorial covers that separate responsibility.

Prerequisites

Use Node.js 22.x LTS, npm 10.x or newer, TypeScript 5.9.x, Vitest 3.2.x, and @pact-foundation/pact 17.0.1. Pact packages include native components, so use a supported 64-bit macOS, Linux, or Windows environment. Docker and Kafka are not required for these contract tests.

Create an empty project and install exact development versions:

mkdir kafka-pact-orders
cd kafka-pact-orders
npm init -y
npm install --save-dev typescript@5.9.2 vitest@3.2.4 @types/node@22.17.0 @pact-foundation/pact@17.0.1
mkdir -p src/consumer src/producer test/consumer test/provider pacts

Add module settings in tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node", "vitest/globals"]
  },
  "include": ["src", "test"]
}

Set package.json to use ESM and define the local commands:

{
  "name": "kafka-pact-orders",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:consumer": "vitest run test/consumer",
    "test:provider": "vitest run test/provider",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@pact-foundation/pact": "17.0.1",
    "@types/node": "22.17.0",
    "typescript": "5.9.2",
    "vitest": "3.2.4"
  }
}

Verify the toolchain before adding application code:

node --version
npx tsc --version
npx vitest --version

Expect Node 22.x, TypeScript 5.9.2, and Vitest 3.2.4. Commit the generated lockfile so CI installs the same dependency graph with npm ci.

Step 1: Define the Consumer Boundary

Begin with what billing actually needs, not every field the order database contains. The handler requires an event identity for idempotency, an order identity, a customer identity, an integer amount in minor currency units, a supported currency, and an ISO timestamp.

Create src/consumer/order-created-handler.ts:

export type OrderCreated = {
  eventId: string;
  orderId: string;
  customerId: string;
  totalMinor: number;
  currency: 'USD' | 'EUR';
  occurredAt: string;
};

export type BillingResult = { invoiceKey: string; amountMinor: number };

export function handleOrderCreated(event: OrderCreated): BillingResult {
  if (!event.eventId || !event.orderId || !event.customerId) {
    throw new Error('OrderCreated identifiers are required');
  }
  if (!Number.isInteger(event.totalMinor) || event.totalMinor < 0) {
    throw new Error('totalMinor must be a non-negative integer');
  }
  if (!['USD', 'EUR'].includes(event.currency)) {
    throw new Error('currency is not supported');
  }
  if (Number.isNaN(Date.parse(event.occurredAt))) {
    throw new Error('occurredAt must be an ISO timestamp');
  }

  return {
    invoiceKey: `${event.customerId}:${event.orderId}`,
    amountMinor: event.totalMinor,
  };
}

This pure boundary is intentional. A real Kafka callback should decode the record and call this function. Database writes can sit behind injected dependencies, but contract tests should not require a live database. That keeps a message shape failure distinct from an infrastructure failure.

Verify the file compiles:

npm run typecheck

Expected result: TypeScript exits with code 0 and prints no diagnostic. If this boundary becomes difficult to call without Kafka, refactor the transport adapter before writing Pact tests.

Step 2: Write a Focused Handler Test

Before Pact, prove the handler's business behavior with a normal unit test. This gives failures a clear meaning: a unit failure describes billing logic, while a Pact failure describes an integration expectation.

Create test/consumer/order-created-handler.test.ts:

import { describe, expect, it } from 'vitest';
import { handleOrderCreated } from '../../src/consumer/order-created-handler.js';

describe('handleOrderCreated', () => {
  it('creates an invoice key from an order event', () => {
    const result = handleOrderCreated({
      eventId: 'evt-1001',
      orderId: 'ord-501',
      customerId: 'cus-77',
      totalMinor: 2599,
      currency: 'USD',
      occurredAt: '2026-08-06T10:15:00.000Z',
    });

    expect(result).toEqual({
      invoiceKey: 'cus-77:ord-501',
      amountMinor: 2599,
    });
  });

  it('rejects a fractional minor-unit amount', () => {
    expect(() =>
      handleOrderCreated({
        eventId: 'evt-1002',
        orderId: 'ord-502',
        customerId: 'cus-78',
        totalMinor: 25.5,
        currency: 'USD',
        occurredAt: '2026-08-06T10:16:00.000Z',
      }),
    ).toThrow('totalMinor must be a non-negative integer');
  });
});

Run the targeted check:

npm run test:consumer -- order-created-handler.test.ts

Expect two passing tests. Do not make the Pact interaction express every validation branch. Pact's job is to supply a representative compatible message and record matching rules. Unit tests remain the cheaper place for missing fields, negative totals, unsupported currencies, and duplicate handling.

Step 3: Create the Consumer Driven Contract Testing Kafka Pact

Now make the consumer describe the event it can process. Create test/consumer/order-created.pact.test.ts:

import { Pact, Matchers, v4SynchronousBodyHandler } from '@pact-foundation/pact';
import { describe, it } from 'vitest';
import { handleOrderCreated } from '../../src/consumer/order-created-handler.js';

const { integer, regex, string, timestamp } = Matchers;

const pact = new Pact({
  consumer: 'billing-worker',
  provider: 'order-service-events',
  dir: './pacts',
  logLevel: 'warn',
});

describe('OrderCreated message contract', () => {
  it('accepts the event needed to create an invoice', async () => {
    await pact
      .addAsynchronousInteraction()
      .given('order ord-501 has been accepted')
      .expectsToReceive('an OrderCreated event for billing', (message) => {
        message
          .withJSONContent({
            eventId: string('evt-1001'),
            orderId: string('ord-501'),
            customerId: string('cus-77'),
            totalMinor: integer(2599),
            currency: regex('^(USD|EUR)
#39;, 'USD'), occurredAt: timestamp( "yyyy-MM-dd'T'HH:mm:ss.SSSX", '2026-08-06T10:15:00.000Z', ), }) .withMetadata({ contentType: 'application/json', topic: 'orders.created.v1', }); }) .executeTest(v4SynchronousBodyHandler(handleOrderCreated)); }); });

Pact 17 exposes Pact as the V4 DSL. addAsynchronousInteraction() models a one-way event, which is the correct semantic shape for a Kafka record consumed without an application response. v4SynchronousBodyHandler unwraps the Pact message content and invokes the synchronous handler. The name sounds synchronous because the JavaScript handler returns immediately, not because the Kafka interaction is request-response.

Matchers prevent accidental overconstraint. orderId may be any string, totalMinor must be an integer, and currency must satisfy the business enum. Metadata models assumptions the teams want in the pact, but it does not prove that a Kafka client publishes to that topic.

Verify the interaction and inspect the contract:

npm run test:consumer -- order-created.pact.test.ts
node -e "const p=require('./pacts/billing-worker-order-service-events.json'); console.log(p.metadata.pactSpecification.version, p.interactions.length)"

Expect the test to pass and the inspection to print a V4 specification version followed by 1. Pact normalizes participant names in the filename. Treat the generated JSON as an artifact, not a hand-edited source file.

Step 4: Implement the Producer Event Factory

The provider verification must call the same code that production uses before serialization and publication. A hand-written test fixture can pass while the real producer silently renames a field.

Create src/producer/order-created-event.ts:

import type { OrderCreated } from '../consumer/order-created-handler.js';

export type AcceptedOrder = {
  id: string;
  customerId: string;
  totalMinor: number;
  currency: 'USD' | 'EUR';
  acceptedAt: Date;
};

export function createOrderCreatedEvent(
  order: AcceptedOrder,
  eventId: string,
): OrderCreated {
  return {
    eventId,
    orderId: order.id,
    customerId: order.customerId,
    totalMinor: order.totalMinor,
    currency: order.currency,
    occurredAt: order.acceptedAt.toISOString(),
  };
}

In a real repository, the producer and consumer would not import a shared OrderCreated type across deployable services. They are colocated here only to keep one tutorial runnable. Separate services should own independent types so compilation cannot conceal contract drift. Pact is the cross-repository agreement.

Verify the producer compiles with the earlier consumer code:

npm run typecheck

Expected result: no TypeScript errors. As a quick mutation, temporarily rename customerId in the return object. TypeScript catches it in this tutorial, but provider verification will provide that protection when producer and consumer live in different codebases. Restore the name before continuing.

Step 5: Verify the Kafka Producer With Pact

Provider verification reads the consumer-generated pact, finds each interaction description, invokes a corresponding message producer, and compares the result with the matching rules. Create test/provider/order-created.provider.test.ts:

import path from 'node:path';
import { MessageProviderPact, providerWithMetadata } from '@pact-foundation/pact';
import { describe, expect, it } from 'vitest';
import { createOrderCreatedEvent } from '../../src/producer/order-created-event.js';

const messageProviders = {
  'an OrderCreated event for billing': providerWithMetadata(
    () =>
      createOrderCreatedEvent(
        {
          id: 'ord-501',
          customerId: 'cus-77',
          totalMinor: 2599,
          currency: 'USD',
          acceptedAt: new Date('2026-08-06T10:15:00.000Z'),
        },
        'evt-1001',
      ),
    { contentType: 'application/json', topic: 'orders.created.v1' },
  ),
};

describe('order-service-events provider', () => {
  it('produces messages accepted by billing-worker', async () => {
    const verifier = new MessageProviderPact({
      provider: 'order-service-events',
      providerVersion: process.env.GIT_SHA ?? 'local',
      pactUrls: [
        path.resolve('pacts/billing-worker-order-service-events.json'),
      ],
      messageProviders,
      stateHandlers: {
        'order ord-501 has been accepted': async () => undefined,
      },
    });

    const output = await verifier.verify();
    expect(output).toBeDefined();
  });
});

The provider state makes the scenario deterministic. This in-memory example needs no setup, so the state handler resolves immediately. In production tests, use it to seed a repository stub or configure a fixture that the event factory reads. Do not publish a real Kafka record from a state handler.

Run the consumer first because it creates the local pact, then verify the provider:

npm run test:consumer -- order-created.pact.test.ts
npm run test:provider -- order-created.provider.test.ts

Expect both files to pass. To see a meaningful failure, change the producer's totalMinor to the string '2599' using a temporary cast. Provider verification reports a body mismatch because the consumer requires an integer. Restore the numeric value after observing the diagnostic.

Step 6: Separate Pact Coverage From Kafka Coverage

A passing pact proves that one producer version can create content compatible with one consumer version. It does not establish that the record reaches the consumer. Keep a thin broker-backed integration test for serialization, headers, authentication, topic existence, and adapter wiring.

Your production Kafka adapter might remain this small:

import type { OrderCreated } from '../consumer/order-created-handler.js';
import { handleOrderCreated } from '../consumer/order-created-handler.js';

export type KafkaRecord = { value: Buffer | null };

export function consumeKafkaRecord(record: KafkaRecord) {
  if (record.value === null) throw new Error('Kafka record has no value');
  const event = JSON.parse(record.value.toString('utf8')) as OrderCreated;
  return handleOrderCreated(event);
}

Add test/consumer/kafka-adapter.test.ts to verify the wire decoding boundary without inventing a broker:

import { expect, it } from 'vitest';
import { consumeKafkaRecord } from '../../src/consumer/kafka-adapter.js';

it('decodes a UTF-8 Kafka record and delegates to billing', () => {
  const value = Buffer.from(JSON.stringify({
    eventId: 'evt-1001', orderId: 'ord-501', customerId: 'cus-77',
    totalMinor: 2599, currency: 'USD',
    occurredAt: '2026-08-06T10:15:00.000Z',
  }));

  expect(consumeKafkaRecord({ value })).toEqual({
    invoiceKey: 'cus-77:ord-501', amountMinor: 2599,
  });
});

Save the adapter implementation as src/consumer/kafka-adapter.ts, then verify it:

npm run test:consumer -- kafka-adapter.test.ts

Expect one passing test. Next, add one Testcontainers or staging smoke test in your actual Kafka stack. Test a unique topic, publish one record with the real serializer, consume it with the real configuration, and assert the side effect. The step-by-step Kafka consumer contract guide and event-driven microservices testing guide help place these layers correctly.

Step 7: Publish Contracts and Gate Deployment

Local pact files connect two folders. A Pact Broker connects independent repositories and records which application versions are compatible. Add scripts after configuring PACT_BROKER_BASE_URL and either PACT_BROKER_TOKEN or the credentials supported by your broker:

{
  "scripts": {
    "pact:publish": "pact-broker publish ./pacts --consumer-app-version=$GIT_SHA --branch=$GIT_BRANCH",
    "pact:can-i-deploy": "pact-broker can-i-deploy --pacticipant billing-worker --version=$GIT_SHA --to-environment production"
  }
}

Shell variable syntax differs on Windows, so CI YAML can pass explicit expanded arguments instead. Use an immutable commit SHA as the application version. Reusing latest destroys the Broker's ability to answer which exact producer and consumer combination was verified.

A practical pipeline has four decisions:

  1. Consumer CI runs the handler and Pact interaction tests.
  2. Consumer CI publishes the generated pact with branch and commit metadata.
  3. Provider CI retrieves relevant pacts, runs MessageProviderPact, and publishes verification results.
  4. Deployment CI runs can-i-deploy for the application version and target environment, then records successful deployment.

Verify CLI availability locally before wiring CI:

npx pact-broker --version
npm test

Expect a CLI version followed by all consumer and provider tests passing. Publishing is an external write, so run it only against an approved Broker. For a conceptual comparison of artifacts and workflows, see Pact versus OpenAPI for contract testing.

Consumer Driven Contract Testing Kafka Pact Design Decisions

The hardest part is deciding what belongs in the contract. Make fields exact when the consumer branches on the literal value. Use matchers when many values are valid. A currency whitelist is meaningful to billing; an exact order ID is usually not. Avoid copying an entire production event into withJSONContent, because that turns unrelated producer additions into consumer maintenance.

Pact message tests are consumer driven, but they are not consumer-only. The producer must verify every active consumer pact. If three teams consume OrderCreated, each team can state a different minimal view. The producer gets three precise compatibility checks instead of one centrally designed schema that may not express behavioral use.

Version topics or event types when semantics change, not for every additive field. An optional producer field normally remains compatible. Removing a required field, changing integer cents to decimal dollars, or redefining a timestamp's meaning is breaking even when JSON still parses. Schema validation can catch structure; Pact links structure to observable consumer behavior.

Do not confuse the provider state with Kafka state. given('order ord-501 has been accepted') names the business precondition required to generate the event. It should prepare deterministic provider data, not wait for a topic or depend on a shared environment. That separation makes failures repeatable and fast.

Best Practices

  • Name Pact participants after independently deployable applications. Do not use topic names as both participant names because ownership becomes ambiguous.
  • Use one stable interaction description per business scenario. Provider mappings depend on exact descriptions, so casual wording changes create needless coordination.
  • Keep generated pact files immutable. Regenerate them from consumer tests and publish with a commit SHA.
  • Represent content type, event type, and topic metadata only when the consumer or delivery configuration depends on them. Verify real headers once at the Kafka integration layer.
  • Test idempotency, duplicates, out-of-order delivery, retries, dead-letter routing, and partition-key behavior outside Pact. Those properties arise from broker and application state, not a single message shape.
  • Use format matchers for timestamps and identifiers, type matchers for variable scalar data, and exact values for discriminators that control code paths.
  • Keep secrets out of scripts and pact files. Inject Broker tokens from CI secret storage and inspect generated contracts for accidental personal data.
  • Remove obsolete pacts through your Broker's lifecycle workflow instead of ignoring failing consumers. Deployment checks are only trustworthy when environments and application versions are recorded accurately.

Troubleshooting

Problem: No matched interaction found during provider verification -> Make the key in messageProviders exactly equal to the consumer's expectsToReceive description. Check whitespace and capitalization, then regenerate the pact.

Problem: the pact file is not created -> Await executeTest, confirm the test passes, and resolve dir from the process working directory used by Vitest. A rejected handler prevents Pact from writing a successful interaction.

Problem: provider verification reports an integer as the wrong type -> Inspect the object returned by the real event factory before Kafka serialization. Do not stringify numeric fields merely because the eventual record value is a byte buffer.

Problem: metadata mismatches although JSON content matches -> Return metadata with providerWithMetadata, using the same keys and value types defined by the consumer. Then add a separate adapter test to prove those modeled values become actual Kafka headers or configuration.

Problem: native Pact binaries fail in CI -> Use a supported Node 22 image and standard glibc or a documented supported platform. Cache npm downloads, not copied node_modules from another OS or CPU architecture. Enable Pact debug logging only while diagnosing because it is verbose.

Problem: tests pass but production consumption fails -> Run the broker-backed smoke test. Check topic ACLs, serializer configuration, compression, consumer group, offset reset policy, partition key, and dead-letter behavior, none of which a Pact message contract exercises.

Interview Questions and Answers

The JSON interview section below contains model answers for six common questions. The central distinction is simple: Pact checks that producer-generated messages satisfy each consumer's executable expectations, while Kafka tests check transport and operational behavior. A strong interview answer should also mention independent versioning, provider verification, and deployment gating rather than describing Pact as schema validation alone.

Where To Go Next

Run the complete example, deliberately break one producer field, and read Pact's mismatch before adding Broker automation. Then expand coverage with one second interaction only when it represents a distinct consumer behavior, such as a supported currency branch or an order cancellation event.

Deepen the design with the API contract testing with Pact tutorial. Add a real-transport layer using the Kafka consumer contract testing walkthrough, and protect serialized formats with Avro compatibility checks in CI. When preparing for interviews, review microservices contract testing questions.

Conclusion

Consumer driven contract testing for Kafka with Pact works best at the message-handler seam. Let the consumer define the smallest useful event, generate a V4 asynchronous pact, and make the producer's real event factory satisfy it. Publish versioned results and ask the Broker whether a release is safe.

Keep one important boundary in view: Pact proves compatibility of message content and modeled metadata. Kafka integration checks prove delivery, serialization, security, partitions, offsets, and failure handling. Together, these tests catch breaking event changes early without turning every pull request into a slow end-to-end exercise.

Interview Questions and Answers

How does consumer-driven contract testing apply to Kafka?

The Kafka consumer defines an executable expectation for message content and relevant metadata. Pact runs the consumer handler with a generated example and writes a pact, then the producer verifies its real event factory against that pact. Kafka itself is outside this loop, so transport behavior needs separate integration coverage.

Why is a Pact message test not an end-to-end Kafka test?

Pact replaces the message broker at the application boundary and compares generated messages. It does not create topics, assign partitions, commit offsets, rebalance groups, or enforce ACLs. That narrower scope makes contract feedback fast and deterministic.

What is the difference between an asynchronous Pact interaction and a synchronous one?

An asynchronous interaction represents a one-way message such as a Kafka event, with no application response in the contract. A synchronous message interaction has a request and one or more responses, which fits protocols such as gRPC or request-response messaging. The JavaScript handler wrapper's name does not change the interaction type.

How do you avoid brittle Kafka Pact contracts?

Contract only the fields the consumer observes and use matchers for values that legitimately vary. Keep discriminators exact when they drive branches, and do not copy the producer's complete event fixture into every consumer test. Put validation edge cases in unit tests.

What does provider verification execute in a message Pact?

It maps each interaction description to a function that generates the provider's real message content. Pact compares that result and supplied metadata with the consumer's matching rules. Provider states arrange deterministic prerequisites for each scenario.

How would you gate deployments with Kafka Pact tests?

Publish the consumer pact with an immutable commit version, verify it in provider CI, and publish verification results. Before release, run the Broker's can-i-deploy command for the exact application version and target environment. Record deployments so future compatibility queries reflect reality.

When do you still need schema compatibility testing?

Use schema checks when Kafka payloads are governed by Avro, Protobuf, or JSON Schema and the serializer or registry enforces those formats. Schema compatibility protects the wire model broadly, while Pact demonstrates that individual consumer behaviors remain supported. Neither replaces broker integration tests.

Frequently Asked Questions

Can Pact test Kafka messages without running Kafka?

Yes. Pact invokes the consumer handler with generated message content and records the expectation, then invokes the provider's message factory during verification. Use a separate integration test for Kafka topics, serialization, ACLs, partitions, and offsets.

Should I use Pact or a schema registry for Kafka contracts?

Use both when you have Avro, Protobuf, or JSON Schema. A registry enforces structural and compatibility rules for schemas, while Pact verifies the subset and values a particular consumer behavior depends on.

Who writes a Kafka Pact contract?

The consuming team writes the interaction because it knows which fields and semantics its handler requires. The producing team runs provider verification against every active consumer pact before deployment.

Does Pact verify Kafka topics and message headers?

Pact can model metadata such as a topic or content type and compare provider metadata with that expectation. It does not connect to Kafka, so a broker-backed test must verify actual topic configuration, record headers, authentication, and serialization.

How should Kafka event versions be handled with Pact?

Use immutable application versions in the Pact Broker and version event types or topics when semantics become incompatible. Additive fields often remain compatible because consumer matchers describe only required content, but verify all active consumer pacts before release.

Can Pact test duplicate or out-of-order Kafka events?

Not as a broker delivery guarantee. Unit tests can call the handler repeatedly or in different sequences, and integration tests can exercise stored offsets and idempotency, while Pact checks each message contract independently.

What should a Pact provider state do for a message test?

It should arrange deterministic business data required for the producer to generate the named event. Keep it fast and isolated, avoid publishing to Kafka, and clean up any state it creates.

Related Guides