CI/CD Testing for Microservices: Independent Pipelines, Contract Tests, and Integration Strategies

CI/CD Testing for Microservices: Independent Pipelines, Contract Tests, and Integration Strategies

Microservices CI/CD testing is a different problem from monolith testing. In a monolith, you run all the tests against all the code. In microservices, each service has its own repository, its own pipeline, and its own deploy cadence — but it depends on other services it doesn't control.

The challenge: how do you test that services work together when you can't build and deploy every service for every PR?

The Microservices Testing Pyramid Breaks Down

In monolith testing, the pyramid is clear: many unit tests, some integration tests, few E2E tests. In microservices, "integration test" is ambiguous — do you mean within a service, or across services?

A revised model for microservices:

Tier 1 — Within-service tests

  • Unit tests (business logic in isolation)
  • Service-level integration tests (service + its database, cache, etc.)

Tier 2 — Contract tests

  • Consumer-driven contract tests (verify your API consumers' expectations)
  • Provider contract tests (verify you meet published contracts)

Tier 3 — Integration tests

  • Specific, narrow cross-service integration scenarios
  • Run in shared staging environment or ephemeral environment

Tier 4 — E2E tests

  • Full user journey tests against the entire system
  • Run against staging, not per-service CI

Tier 1 runs on every commit, in the service's own CI pipeline. Tiers 2–4 run less frequently or in different contexts.

Independent Service Pipelines

Each microservice should have a fully independent CI pipeline. The pipeline must not require deploying or building other services.

Service pipeline structure

# .github/workflows/ci.yml for service-a
name: Service A CI

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Unit tests
        run: npm test

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
      redis:
        image: redis:7
    steps:
      - uses: actions/checkout@v4
      - name: Integration tests (service + dependencies)
        run: npm run test:integration
        env:
          DB_URL: postgres://postgres:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379

  contract-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Publish contracts to Pact Broker
        run: npm run test:contract
        env:
          PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}

This pipeline never references service B or service C. It tests service A in isolation with real copies of its own dependencies (PostgreSQL, Redis) but mocked versions of peer services.

The dependency seam

Where do you draw the line between "own dependencies" (real) and "peer services" (mocked)?

Own dependencies: databases, caches, message queues that the service owns and manages. Run these with real containers.

Peer services: other microservices that you call over HTTP or async messaging. Mock these via test doubles.

The distinction: you control your own dependencies' behavior; you don't control peer services' behavior. Contracts define the expected behavior at the seam.

Consumer-Driven Contract Testing

Contract testing solves the core microservices testing problem: how do you know your API still works for its consumers without deploying everything?

How it works

Consumer (caller) side:

  1. Write tests that define what the consumer expects from the provider
  2. These tests run against a mock provider and generate a contract file
  3. The contract is published to a contract broker (Pact Broker)

Provider (called) side:

  1. The provider's CI pipeline downloads contracts from the broker
  2. Runs each contract against the real provider
  3. If the provider satisfies all contracts: pass. If not: fail.

The consumer defines expectations. The provider proves it meets them. Neither side deploys the other.

Pact example (JavaScript)

Consumer test (service A calling service B's users endpoint):

// service-a/src/users.contract.test.js
const { Pact } = require('@pact-foundation/pact');

describe('Users API contract', () => {
  const provider = new Pact({
    consumer: 'service-a',
    provider: 'user-service',
    port: 4000,
  });

  before(() => provider.setup());
  after(() => provider.finalize());

  it('gets a user by ID', async () => {
    // Define what service-a expects
    await provider.addInteraction({
      state: 'user 123 exists',
      uponReceiving: 'a request for user 123',
      withRequest: {
        method: 'GET',
        path: '/users/123',
      },
      willRespondWith: {
        status: 200,
        body: {
          id: 123,
          email: Matchers.email(),
          name: Matchers.string('John Doe'),
        },
      },
    });

    const user = await getUserById(123);
    expect(user.id).toBe(123);
  });
});

Provider verification (user-service's CI):

// user-service/src/users.pact.verify.js
const { Verifier } = require('@pact-foundation/pact');

describe('Pact verification', () => {
  it('validates contracts for service-a', () => {
    return new Verifier({
      providerBaseUrl: 'http://localhost:3000',
      pactBrokerUrl: process.env.PACT_BROKER_URL,
      provider: 'user-service',
      // Set up test state before each interaction
      stateHandlers: {
        'user 123 exists': async () => {
          await db.users.create({ id: 123, email: 'test@example.com', name: 'John Doe' });
        },
      },
    }).verifyProvider();
  });
});

If user-service changes its response structure in a way that breaks service-a's expectations, the provider verification fails. The user-service team knows before deploying that they're breaking a consumer.

Can-I-deploy

Pact Broker's can-i-deploy command checks whether a specific service version is safe to deploy given current contract compatibility:

# Can we deploy user-service v2.1.0?
pact-broker can-i-deploy \
  --pacticipant user-service \
  --version 2.1.0 \
  --to production

can-i-deploy queries the broker: do all consumers of user-service v2.1.0 have verified contracts? If yes, safe to deploy. If no, there's a consumer that would break.

This is the mechanism that makes independent deployments safe at scale.

Test Doubles for Peer Services

When a service doesn't use contract tests for a peer, test doubles fill the gap for CI purposes.

WireMock for HTTP service stubs

// Service A's integration tests using WireMock for Service B
@ExtendWith(WireMockExtension.class)
class OrderServiceTest {

    @WireMockTest
    void testCreateOrder() {
        // Stub Service B (inventory)
        stubFor(post(urlEqualTo("/inventory/reserve"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"reserved\": true, \"quantity\": 5}")));

        // Test Service A's order creation
        OrderResponse response = orderService.createOrder(
            OrderRequest.builder()
                .productId("SKU-123")
                .quantity(5)
                .build()
        );

        assertThat(response.getStatus()).isEqualTo("CONFIRMED");
        verify(postRequestedFor(urlEqualTo("/inventory/reserve")));
    }
}

Behavior-based verification

Stubs verify the call was made. For more detailed verification, check the request structure:

verify(postRequestedFor(urlEqualTo("/inventory/reserve"))
    .withHeader("Authorization", equalTo("Bearer " + TEST_TOKEN))
    .withRequestBody(matchingJsonPath("$.productId", equalTo("SKU-123")))
    .withRequestBody(matchingJsonPath("$.quantity", equalTo("5")))
);

If the service under test changes how it calls the peer (wrong fields, wrong auth), the stub verification catches it.

Cross-Service Integration Testing

Contract tests verify the interface. Integration tests verify the behavior when services actually run together.

Ephemeral integration environments

For each PR, spin up an ephemeral environment with the PR's version of the service and stable versions of peer services:

# GitHub Actions: ephemeral integration environment
- name: Deploy ephemeral environment
  run: |
    NAMESPACE="pr-${{ github.event.number }}"
    kubectl create namespace $NAMESPACE
    
    # Deploy PR version of this service
    helm install service-a charts/service-a \
      --namespace $NAMESPACE \
      --set image.tag=${{ github.sha }}
    
    # Deploy stable versions of dependencies
    helm install user-service charts/user-service \
      --namespace $NAMESPACE \
      --set image.tag=stable
    
    helm install inventory-service charts/inventory-service \
      --namespace $NAMESPACE \
      --set image.tag=stable

- name: Run integration tests
  run: |
    NAMESPACE="pr-${{ github.event.number }}"
    SERVICE_URL=$(kubectl get svc service-a -n $NAMESPACE -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
    npm run test:integration -- --base-url=http://$SERVICE_URL

- name: Cleanup
  if: always()
  run: kubectl delete namespace pr-${{ github.event.number }}

Ephemeral environments are the gold standard for cross-service integration testing. They're expensive and slow to provision, so they typically run on PR review rather than every commit.

Shared staging environment

An alternative to ephemeral environments: a shared staging environment that all services deploy to on merge to main.

Trade-offs:

  • Ephemeral: isolated, no interference between PRs, expensive
  • Shared staging: cheap, but tests can interfere with each other; timing matters

For small teams (< 5 engineers), shared staging is usually sufficient. For larger teams, ephemeral environments prevent blocking each other.

Testing Async Communication

Microservices often communicate via message queues (Kafka, RabbitMQ, SQS). Async communication is harder to test than synchronous HTTP.

In-memory broker for unit tests

# Use in-memory Kafka implementation for unit tests
from confluent_kafka import Consumer
from testcontainers.kafka import KafkaContainer

def test_order_created_event():
    with KafkaContainer() as kafka:
        producer = create_producer(kafka.get_bootstrap_server())
        consumer = create_consumer(kafka.get_bootstrap_server(), "orders")
        
        # Publish event
        producer.produce("orders", value=json.dumps({
            "event_type": "ORDER_CREATED",
            "order_id": "123",
            "amount": 99.99
        }))
        producer.flush()
        
        # Consume and verify
        msg = consumer.poll(timeout=5.0)
        assert msg is not None
        data = json.loads(msg.value())
        assert data["event_type"] == "ORDER_CREATED"
        assert data["order_id"] == "123"

Pact for async (message pacts)

Pact supports message-based contracts, not just HTTP:

// Consumer: defines what message shape it expects
const messagePact = new MessageConsumerPact({
  consumer: 'notification-service',
  provider: 'order-service',
});

await messagePact
  .given('an order was created')
  .expectsToReceive('an order created event')
  .withContent({
    orderId: Matchers.uuid(),
    amount: Matchers.decimal(99.99),
    customerId: Matchers.string(),
  })
  .verify(async (message) => {
    const result = await notificationService.processOrderCreated(message);
    expect(result.notificationSent).toBe(true);
  });

Message pacts work like HTTP pacts: consumers define expected message structure, providers verify they publish messages matching that structure.

Regression Detection Across Services

In microservices, a change in service B can break service A even when service A doesn't change. Traditional CI (test your own service) misses this.

Cross-service regression detection

Approach 1: Contract tests (preferred): If service B changes in a way that breaks service A's contract, service B's CI fails during contract verification. No cross-service test run needed.

Approach 2: Dependency-triggered pipelines: When service B's main changes, trigger service A's integration test pipeline:

# service-a/.github/workflows/ci.yml
on:
  repository_dispatch:
    types: [user-service-deployed]
  pull_request:

# In user-service's CD pipeline:
- name: Notify consumers
  run: |
    curl -X POST \
      -H "Authorization: token $GITHUB_TOKEN" \
      -H "Content-Type: application/json" \
      https://api.github.com/repos/org/service-a/dispatches \
      --data '{"event_type": "user-service-deployed"}'

Approach 3: Synthetic monitoring: Deploy to staging, run smoke tests for all consumer services. Any failure creates an incident.

Summary

Microservices CI/CD testing requires a different architecture than monolith testing. The key decisions: how to test each service in isolation (independent pipelines with peer service mocks), how to verify service interfaces remain compatible (consumer-driven contract tests), and how to validate cross-service behavior (ephemeral environments or shared staging).

Contract tests are the highest-leverage investment for microservices teams. They prevent the most common failure mode — one service breaking another — without requiring expensive cross-service builds in every pipeline.

The goal: each service can be tested, verified, and deployed independently, with confidence that it won't break its consumers.

Start now free