DEV Community

Cover image for Contract Testing: A Simple Solution to Event Schema Chaos in Event-Driven Architectures
Francisco Barril
Francisco Barril

Posted on

Contract Testing: A Simple Solution to Event Schema Chaos in Event-Driven Architectures

Ever found yourself debugging an event system where no one is quite sure what data is being passed around? If you’re working with loosely-typed languages or event-driven systems, you know the struggle. The speed at which services evolve can lead to mismatches in event data structures, making it difficult to maintain a smooth and reliable flow. The solution? Contract testing.

In this article, I’ll walk you through how we, at Sword Health, tackled this issue by using contract testing to keep our event-driven systems aligned, especially when dealing with multiple event types on a single topic.


The Problem: Pub/Sub and Multiple Event Types

At Sword Health, we use Pub/Sub to handle events, but there’s a catch: Pub/Sub supports only one schema per topic. This creates problems when we have multiple event types within a single topic—each with different data structures. The lack of schema validation for these events made it tough to ensure that our services were communicating correctly.

As our company scales rapidly and more services interact with each other, it becomes increasingly difficult to track and validate event schemas, especially when we’re dealing with legacy code. This made it clear we needed a solution that would allow us to consistently validate these event structures without introducing unnecessary dependencies between services.

Why Not Proto or Avro?

ProtoBuf and Avro are popular tools for schema validation. However, they require the publisher to export a schema, which the consumer then imports. This creates an additional dependency between services, something we were eager to avoid as we wanted to decouple our services. We needed a solution that would allow us to validate schemas independently without introducing this kind of coupling.

The Solution: Contract Testing

After evaluating our options, we decided to implement contract testing. If you’re not familiar with the term, don't worry. By the end of this article, you'll understand exactly what contract testing is and how it solved our schema validation challenges.

What is Contract Testing?

Contract testing ensures that two systems—typically a consumer (like a client or frontend) and a provider (such as an API or microservice)—can communicate properly by adhering to a predefined “contract.” This contract defines the expected interactions and data structures, ensuring both sides are in sync. In our case, the contract is consumer-driven, meaning it’s based on what the consumer expects from the provider.

A consumer-driven contract records each interaction from the consumer’s perspective. Different consumers may have different requirements, and the provider has the obligation to fulfil all the contracts. Compared to producer-driven contracts, this is a more widely accepted service testing paradigm to evolve services while maintaining backward compatibility.

Consumer Workflow
Provider Workflow

How We Use Contract Testing

We use Pact, a popular contract testing tool, to validate event schemas. Pact allows us to define the expected structure of events and ensures that both the consumer and provider meet the agreed contract.

The Consumer Test

Let’s start with the consumer-side test, which validates the event structure that the consumer expects to receive. Here's a simple example of a function that processes a UserCreated event:

// consumer.js
function handleUserCreatedEvent(message) {
  // Validate the event type
  if (message.event !== 'UserCreated') {
    throw new Error('Unexpected event type');
  }

  // Process the event and return the relevant data
  const { id, name } = message.data;
  return { id, name };
}

module.exports = { handleUserCreatedEvent };
Enter fullscreen mode Exit fullscreen mode

Next, we write a test to define the expected event structure and verify that the consumer function behaves as expected:

// consumer.test.js
const chai = require('chai');
const expect = chai.expect;
const { MessageConsumerPact, Matchers } = require('@pact-foundation/pact');
const { handleUserCreatedEvent } = require('./consumer');

describe('Message Consumer Pact Test for UserCreated Event', () => {
  // Configure the Pact message consumer
  const messagePact = new MessageConsumerPact({
    consumer: 'MyEventConsumer',
    provider: 'MyEventProvider',
    dir: './pacts',              // directory to write the pact file
    log: './logs/message-consumer.log'
  });

  it('processes a valid UserCreated event correctly', async () => {
    // Define the expected event message using Pact Matchers
    const expectedMessage = {
      event: 'UserCreated',
      data: {
        id: Matchers.like('abc-123'),
        name: Matchers.like('Alice'),
        email: Matchers.like('alice@example.com')  // extra field that might be ignored by the consumer
      }
    };

    // Set up the Pact message expectation and verify the consumer function
    const result = await messagePact
      .expectsToReceive('a valid UserCreated event')
      .withContent(expectedMessage)
      .verify(handleUserCreatedEvent);

    // Assert that the consumer function returns the expected result
    expect(result).to.deep.equal({
      id: 'abc-123',
      name: 'Alice'
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

This test will generate a contract in JSON format that looks something like this:

{
  "consumer": {
    "name": "MyEventConsumer"
  },
  "provider": {
    "name": "MyEventProvider"
  },
  "messages": [
    {
      "description": "a valid UserCreated event",
      "contents": {
        "event": "UserCreated",
        "data": {
          "id": "abc-123",
          "name": "Alice",
          "email": "alice@example.com"
        }
      },
      "matchingRules": {
        "body": {
          "$.event": {
            "match": "equality"
          },
          "$.data.id": {
            "match": "type"
          },
          "$.data.name": {
            "match": "type"
          },
          "$.data.email": {
            "match": "type"
          }
        }
      }
    }
  ],
  "metadata": {
    "pactSpecification": {
      "version": "3.0.0"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The Provider Side

Now that we’ve defined the consumer’s expectations, we move to the producer side. The producer needs to ensure that it publishes events that conform to the agreed contract. By doing this, we prevent situations where a change in the event format could break the consumer's functionality.

// provider.js
function processUserCreatedEvent(message) {
  if (message.event !== 'UserCreated') {
    throw new Error('Unexpected event type');
  }

  // Process the event data, e.g., save user info
  const { id, name, email } = message.data;

  // In real applications, here we might store the user in a database
  return { id, name, email };
}

module.exports = { processUserCreatedEvent };
Enter fullscreen mode Exit fullscreen mode

In the test, we load the Pact contract and verify that the provider behaves correctly:

// provider.test.js
const path = require('path');
const { Pact } = require('@pact-foundation/pact');
const { processUserCreatedEvent } = require('./provider');
const chai = require('chai');
const expect = chai.expect;
const { MessageConsumerPact, Matchers } = require('@pact-foundation/pact');

describe('Pact Provider Test for UserCreated Event', () => {
  const messagePact = new MessageConsumerPact({
    consumer: 'MyEventConsumer',
    provider: 'MyEventProvider',
    dir: './pacts',               // directory where pact files are located
    log: './logs/message-provider.log'
  });

  it('should process a valid UserCreated event and return expected data', async () => {
    // Load the Pact contract for the expected event
    const pactFile = path.resolve(__dirname, 'pacts', 'myeventconsumer-myeventprovider.json');

    // Define the mock event that matches the consumer's contract
    const event = {
      event: 'UserCreated',
      data: {
        id: 'abc-123',
        name: 'Alice',
        email: 'alice@example.com'
      }
    };

    // Verify the contract by using the Pact mock provider's content
    const result = await messagePact
      .fromFile(pactFile)
      .verify(processUserCreatedEvent, event);

    // Assert that the provider correctly processes the event
    expect(result).to.deep.equal({
      id: 'abc-123',
      name: 'Alice',
      email: 'alice@example.com'
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

If the provider correctly processes the event and returns the expected data, the test will pass. If there’s any mismatch, the test will fail, preventing issues in production.

Benefits of Contract Testing for Event Validation

By adopting contract testing, we’ve achieved several key benefits:

  • Decoupling schemas: No more tightly-coupled schema definitions across services.
  • Consumer-driven contracts: Only the necessary event data is included in the contract, reducing complexity.
  • Rapid scaling: As our services grow, contract testing ensures that new event types are validated without versioning issues.
  • Clear communication: The contract makes the interaction between consumer and provider explicit, reducing ambiguity and errors.

Conclusion

In this article, we introduced the concept of contract testing and how we use it at Sword Health to maintain schema consistency in a fast-paced, event-driven architecture. By using Pact for contract testing, we can ensure that our event structures remain aligned across multiple services, even as we scale.

In a future post, I’ll dive deeper into how we leverage Pact Broker to efficiently store and manage our contracts and validation results, ensuring that all consumers and providers are always aligned. I’ll also walk you through how we integrated contract testing into our CI/CD pipeline, automating the validation process for every code change. This setup not only catches potential issues early but also ensures seamless collaboration between teams as our system evolves. Stay tuned!

Have you used contract testing in your event-driven systems? I’d love to hear how you’ve implemented it—or if you’ve faced any challenges along the way. Drop a comment below or connect with me on LinkedIn.

References

https://docs.pact.io/

https://pactflow.io/how-pact-works/?utm_source=ossdocs&utm_campaign=getting_started#slide-4

https://innovation.ebayinc.com/tech/engineering/api-evolution-with-confidence-a-case-study-of-contract-testing-adoption-at-ebay/

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs