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:
- Write tests that define what the consumer expects from the provider
- These tests run against a mock provider and generate a contract file
- The contract is published to a contract broker (Pact Broker)
Provider (called) side:
- The provider's CI pipeline downloads contracts from the broker
- Runs each contract against the real provider
- 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 productioncan-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.