By Appropri8 Team

Testing Pyramid 2.0: Shifting from Unit Tests to Contract and Consumer-Driven Testing

testingmicroservicescontract-testingdevopsarchitecture

Remember the old testing pyramid? Unit tests at the bottom, integration tests in the middle, and UI tests at the top. It made sense when we built monoliths. But now we’re building microservices, serverless functions, and distributed systems. The old pyramid doesn’t work anymore.

Here’s the problem: when you have 20 microservices talking to each other, unit tests can’t catch the real issues. Your service might pass all its unit tests but still break when another service changes its API. You need a different approach.

Why the Old Pyramid Fails

The traditional testing pyramid assumes you control everything. In a monolith, you can test the whole system together. But microservices are independent. They deploy separately. They can change their APIs without telling you.

Think about it. Service A calls Service B. Service B updates its API and breaks Service A. Your unit tests for Service A still pass because they mock Service B. But your integration tests fail in production. This is integration hell.

The old pyramid also assumes you can test everything end-to-end. But with microservices, that’s expensive. You need to spin up 20 services just to test one feature. It’s slow, brittle, and hard to debug.

The Rise of Contract Testing

Contract testing solves this problem. Instead of testing the whole system, you test the contracts between services. A contract is an agreement about how services communicate.

There are two types of contracts:

Provider contracts - what a service promises to provide Consumer contracts - what a service expects to receive

Consumer-driven contract testing is the key. The consumer defines what it needs, and the provider must meet those expectations. This prevents breaking changes.

Tools You Can Use

Pact is the most popular tool. It’s language-agnostic and works with most programming languages. You write contracts in your consumer code, and Pact generates tests for the provider.

Spring Cloud Contract is great for Java/Spring applications. It generates stubs and tests from contract definitions.

Hoverfly is useful for service virtualization. It can record and replay API interactions.

Comparing Old vs. New Pyramid

The old pyramid looked like this:

    /\
   /  \
  / UI \
 /______\
/        \
/Integration\
/____________\
/              \
/   Unit Tests   \
/________________\

Lots of unit tests, some integration tests, few UI tests.

The new pyramid looks different:

    /\
   /  \
  / E2E \
 /______\
/        \
/Contract \
/  Tests   \
/__________\
/            \
/ Unit Tests  \
/______________\

More contract tests, fewer unit tests, minimal E2E tests.

Why Unit Tests Aren’t Enough

Unit tests are still important, but they can’t catch integration issues. They test your code in isolation, which is good for business logic. But they can’t test if your service can actually talk to other services.

Contract tests fill this gap. They test the actual communication between services without needing the full system running.

The Importance of Reliable Boundaries

In microservices, the boundaries between services are critical. If these boundaries break, your whole system breaks. Contract tests ensure these boundaries stay stable.

They also serve as documentation. When you read a contract, you know exactly what a service expects and provides. No guessing, no outdated documentation.

Shaping the Testing Pyramid 2.0

Here’s how to rebalance your testing strategy:

1. Focus on Contract Tests

Make contract tests your primary integration testing strategy. They’re faster than full integration tests and more reliable than unit tests for catching integration issues.

Write contracts from the consumer’s perspective. What does the consumer actually need? Don’t over-specify. Focus on the essential data and behavior.

2. Keep Unit Tests for Critical Logic

Unit tests are still valuable for complex business logic. But don’t write unit tests for simple CRUD operations or API wrappers. Focus on the hard stuff.

3. Rethink E2E Tests

E2E tests should be minimal and focused on critical user journeys. Don’t test every feature end-to-end. It’s too expensive and brittle.

Use E2E tests for the happy path of your most important workflows. Let contract tests handle the integration details.

Best Practices and Anti-Patterns

Do This

Keep contracts simple. Only specify what you actually need. Don’t include every field just because it exists.

Version your contracts. Use semantic versioning for your contracts. This helps with backward compatibility.

Test contracts in CI/CD. Run contract tests on every build. Catch breaking changes early.

Use contracts as documentation. Generate API docs from your contracts. Keep them in sync automatically.

Don’t Do This

Don’t over-specify contracts. If you don’t need a field, don’t include it. Over-specification leads to brittle tests.

Don’t ignore contract changes. When a contract changes, update all consumers. Don’t let them drift apart.

Don’t test implementation details. Contracts should test behavior, not implementation. Focus on what the service does, not how it does it.

Don’t skip contract validation. Always validate that your service meets its contracts before deploying.

A Real Example

Let’s say you have an e-commerce system with two services:

  • Order Service - manages orders
  • Payment Service - processes payments

The Order Service needs to call the Payment Service to charge a customer.

The Problem

Without contract testing, here’s what happens:

  1. Order Service calls Payment Service with a specific request format
  2. Payment Service updates its API (maybe changes a field name)
  3. Order Service breaks in production
  4. You find out during peak shopping hours

The Solution

With contract testing:

  1. Order Service defines what it expects from Payment Service
  2. Payment Service must meet those expectations
  3. If Payment Service changes its API, contract tests fail
  4. You catch the issue before deployment

Here’s how you’d write this with Pact:

// In Order Service (Consumer)
const { Pact } = require('@pact-foundation/pact');

const provider = new Pact({
  consumer: 'Order Service',
  provider: 'Payment Service',
  port: 1234,
  log: './logs/pact.log',
  dir: './pacts',
  logLevel: 'INFO'
});

describe('Payment Service', () => {
  beforeAll(() => provider.setup());
  afterEach(() => provider.verify());
  afterAll(() => provider.finalize());

  it('should process payment successfully', () => {
    const expectedResponse = {
      transactionId: 'txn_123',
      status: 'success',
      amount: 100.00
    };

    return provider
      .addInteraction({
        state: 'payment can be processed',
        uponReceiving: 'a payment request',
        withRequest: {
          method: 'POST',
          path: '/payments',
          headers: { 'Content-Type': 'application/json' },
          body: {
            amount: 100.00,
            currency: 'USD',
            customerId: 'cust_123'
          }
        },
        willRespondWith: {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
          body: expectedResponse
        }
      })
      .then(() => {
        // Test your Order Service code here
        return orderService.processPayment({
          amount: 100.00,
          currency: 'USD',
          customerId: 'cust_123'
        });
      })
      .then(response => {
        expect(response.transactionId).toBe('txn_123');
        expect(response.status).toBe('success');
      });
  });
});

CI/CD Integration

Here’s how to integrate contract testing into your CI/CD pipeline:

# .github/workflows/contract-tests.yml
name: Contract Tests

on:
  pull_request:
    paths:
      - 'services/**'
      - 'contracts/**'

jobs:
  consumer-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'
      - name: Install dependencies
        run: npm ci
      - name: Run consumer tests
        run: npm run test:contracts
      - name: Publish contracts
        run: npm run publish:contracts
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

  provider-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'
      - name: Install dependencies
        run: npm ci
      - name: Run provider tests
        run: npm run test:provider
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}

Service Stubbing Example

When testing your consumer, you need to stub the provider. Here’s how to do it with Pact:

// In your test setup
const { Verifier } = require('@pact-foundation/pact');

const opts = {
  provider: 'Payment Service',
  providerBaseUrl: 'http://localhost:3001',
  pactBrokerUrl: 'https://your-pact-broker.com',
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  publishVerificationResult: true,
  providerVersion: process.env.GIT_COMMIT
};

new Verifier().verifyProvider(opts).then(() => {
  console.log('Contract verification complete!');
}).catch((error) => {
  console.error('Contract verification failed:', error);
});

AI-Assisted Testing

AI is changing how we write tests. Tools like GitHub Copilot can help generate contract tests from API specifications. This makes contract testing more accessible.

But AI can’t replace human judgment. You still need to decide what to test and what not to test. AI can help with the boilerplate, but the strategy is still yours.

Contracts as Living Documentation

Contracts are becoming the single source of truth for API documentation. Tools like Pact can generate OpenAPI specs from contracts. This keeps your docs in sync with your tests.

Testing in Serverless

Serverless functions make testing even more important. You can’t debug in production like you can with traditional servers. Contract testing helps ensure your functions work together correctly.

Conclusion

The old testing pyramid doesn’t work for microservices. You need a new approach that focuses on contracts between services. Contract testing gives you confidence that your services can communicate without the cost of full integration tests.

Start small. Pick one service interaction and write a contract test. See how it feels. Then expand to other services. You’ll find that contract tests catch issues that unit tests miss and are faster than integration tests.

The goal isn’t to eliminate unit tests or E2E tests. It’s to use the right tool for the job. Unit tests for business logic, contract tests for integration, and E2E tests for critical user journeys.

This approach takes time to implement, but it pays off. You’ll deploy with more confidence, catch issues earlier, and spend less time debugging integration problems.

And that’s what testing is really about - confidence. Confidence that your code works, confidence that your services communicate correctly, and confidence that you can deploy without breaking things.

The testing pyramid 2.0 gives you that confidence. It’s time to make the shift.

Join the Discussion

Have thoughts on this article? Share your insights and engage with the community.