Microservices Testing Strategy: Unit vs Integration vs Contract Tests

Microservices Testing Strategy: Unit vs Integration vs Contract Tests

Testing microservices requires a layered strategy: unit tests for individual service logic, contract tests to validate service interfaces, integration tests for key interaction points, and E2E tests for critical user journeys. The classic test pyramid applies but needs adjustments for distributed systems — contract tests occupy a unique layer that doesn't exist in monolithic architectures.

Key Takeaways

Contract tests replace most integration tests between services. Instead of spinning up both services to test an interaction, contract tests verify that each service honors the API contract independently. Faster, cheaper, and more targeted.

The "microservices test pyramid" inverts slightly at the top. More contract tests and fewer E2E tests than a monolith. E2E tests in microservices are expensive (many services to spin up) and fragile (failures can come from anywhere).

Isolate integration tests by layer. Tests for database interactions are integration tests. Tests for HTTP calls to other services are better served by contract tests or mocks. Don't mix the two in one test.

Define service ownership of tests. Consumer-driven contract tests put the responsibility on the consuming service — it defines what it needs and the provider proves it delivers that.

Each service should be testable in isolation. If your service can't be tested without 5 other services running, you have a design problem, not just a testing problem.

Why Microservices Break the Standard Testing Approach

The standard testing approach — write unit tests, add some integration tests, run E2E tests in CI — breaks down in microservices architectures for predictable reasons.

Unit tests don't catch interface mismatches. Service A's unit tests verify it calls Service B with {"userId": 123}. Service B's unit tests verify it handles {"user_id": 123}. Both test suites pass. In production, every request fails because of a field naming mismatch. Neither unit test suite catches this.

Integration tests between services are expensive and fragile. To test the real interaction between Service A and Service B, you need both services running, with their dependencies. For a system with 20 services, the dependency graph makes this impractical. CI times balloon; flaky failures multiply.

E2E tests traverse multiple failure domains. A failing E2E test in a microservices system could indicate a bug in any of the 6 services the test touches. Debugging is expensive. Flake rates are high.

The answer isn't to abandon testing — it's to test differently, with a strategy designed for distributed systems.

The Microservices Test Pyramid

The test pyramid still applies in microservices, but with modifications:

          E2E Tests
         (very few — critical journeys only)

       Contract Tests
      (many — one per API interaction)

    Integration Tests (per service)
   (database, message broker, file system)

  Unit Tests (many — per service)
 (pure logic, domain rules, algorithms)

The key addition is contract tests, which live between unit and integration tests. They're faster than integration tests (no real external services) but more reliable than mocks for verifying inter-service interfaces.

Unit Testing in Microservices

Unit testing individual services is no different from unit testing any application. Domain logic, business rules, and transformations belong in unit tests.

The unit testing discipline that matters most in microservices is keeping domain logic separate from infrastructure concerns. A service that mixes HTTP client calls with business logic is harder to unit test and harder to maintain.

Easy to unit test:

def calculate_order_total(items: list[OrderItem], discount: Discount | None) -> Decimal:
    subtotal = sum(item.price * item.quantity for item in items)
    if discount:
        return subtotal * (1 - discount.percentage / 100)
    return subtotal

Hard to unit test (business logic mixed with HTTP call):

def process_order(order_id: str) -> OrderResult:
    order = requests.get(f"http://order-service/orders/{order_id}").json()
    user = requests.get(f"http://user-service/users/{order['userId']}").json()
    discount = get_discount_for_user(user['tier'])
    total = sum(item['price'] for item in order['items']) * (1 - discount)
    return OrderResult(total=total)

The second function requires two services running to test. Refactoring the HTTP calls behind injectable dependencies (constructor injection, function parameters) makes it testable.

Contract Testing

Contract tests are the most important testing concept specific to microservices. They verify that a service's API behavior matches what its consumers expect.

Consumer-Driven Contract Tests

In consumer-driven contract testing (popularized by Pact):

  1. The consumer (Service A) writes tests that describe what it expects from Service B's API: request format, response format, and behavior for specific inputs.
  2. These expectations are recorded as a "contract" — a JSON or YAML file describing the interaction.
  3. The provider (Service B) runs the contract against its real implementation to verify it can satisfy what consumers expect.

The workflow looks like this:

Consumer Service A                 Provider Service B
─────────────────                  ─────────────────
Write Pact test          →         
  describing: GET /users/123                 
  expects: { id: 123, name: "Alice" }        
                                   
Generate pact file       →  share  →  Run provider
pacts/A-B.json                        verification test
                                      against real Service B
                                      
CI: A's pact tests pass ✓          CI: B's verification passes ✓

If Service B changes its response format in a way that breaks Service A's contract, the verification step fails — before any code is deployed.

Example Pact test in JavaScript:

// consumer side (Service A)
const { Pact } = require('@pact-foundation/pact');

describe('UserService pact', () => {
  const provider = new Pact({
    consumer: 'OrderService',
    provider: 'UserService',
  });

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

  it('returns user profile for existing user', async () => {
    await provider.addInteraction({
      state: 'user 123 exists',
      uponReceiving: 'a GET request for user 123',
      withRequest: {
        method: 'GET',
        path: '/users/123',
      },
      willRespondWith: {
        status: 200,
        body: {
          id: 123,
          name: Matchers.string('Alice Johnson'),
          tier: Matchers.string('pro'),
        },
      },
    });

    const user = await userClient.getUser(123);
    expect(user.id).toBe(123);
    expect(user.tier).toBeDefined();
  });
});

The Matchers.string() usage is important — it verifies the type and presence of fields without requiring exact values. This makes contracts less brittle.

Provider Verification

On the provider side:

// provider side (Service B)
const { Verifier } = require('@pact-foundation/pact');

describe('UserService provider verification', () => {
  it('validates all consumer contracts', () => {
    return new Verifier({
      provider: 'UserService',
      providerBaseUrl: 'http://localhost:3001',
      pactUrls: ['../pacts/OrderService-UserService.json'],
      stateHandlers: {
        'user 123 exists': async () => {
          await db.users.upsert({ id: 123, name: 'Alice Johnson', tier: 'pro' });
        },
      },
    }).verifyProvider();
  });
});

The stateHandlers set up the database state required for each interaction before running the contract verification. This replaces the "spin up a complete environment" requirement with "set up specific test state."

Integration Testing for Service-Internal Dependencies

Each service has its own integration tests for its direct dependencies: databases, message brokers, caches, and file systems. These tests use real instances (typically via TestContainers) but don't involve other services.

What belongs in service integration tests:

  • Database queries (correct SQL, transactions, constraint handling)
  • Message broker producer/consumer (Kafka, RabbitMQ publishing and consuming)
  • Cache interactions (Redis get/set/invalidation)
  • File system operations (S3, local disk)

What doesn't belong in service integration tests:

  • HTTP calls to other services (use contract tests or mocks)
  • User authentication flows (too many services involved)
  • Business workflows that span services

Example integration test for a repository:

@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void savesOrderWithAllLineItems() {
        Order order = new Order(
            UUID.randomUUID(),
            "user-123",
            List.of(
                new LineItem("product-1", 2, new BigDecimal("19.99")),
                new LineItem("product-2", 1, new BigDecimal("49.99"))
            )
        );

        Order saved = orderRepository.save(order);

        assertThat(saved.getId()).isNotNull();
        assertThat(saved.getLineItems()).hasSize(2);
        assertThat(saved.getTotal()).isEqualTo(new BigDecimal("89.97"));
    }
}

The TestContainers PostgreSQL instance is real — not an in-memory H2 or SQLite. This catches issues that only appear with real PostgreSQL: specific SQL syntax, constraint behavior, index usage.

End-to-End Testing in Microservices

E2E tests in microservices are expensive. Each test requires the full environment. Reserve them for:

  • Critical user journeys (checkout, authentication, core product flow)
  • Smoke tests that verify services start and respond after deployment
  • Contract boundaries that aren't covered by contract tests

Run E2E tests less frequently than unit and contract tests — on deployment, not on every commit. Accept that they will sometimes fail for environmental reasons rather than code bugs.

Service Ownership of Tests

A microservices organization needs clear ownership for each test type:

Test Type Written By Maintained By Runs When
Unit tests Service team Service team Every commit
Contract tests (consumer side) Consuming service team Consuming service team Every commit
Contract tests (provider verification) Provider service team Provider service team When pact file changes
Service integration tests Service team Service team Every commit
E2E tests Platform/QA team Platform/QA team On deployment

The most important rule: the team that changes an API is responsible for not breaking contracts. Provider verification as a CI gate enforces this automatically.

Common Pitfalls

Skipping contract tests and using integration tests instead. Integration tests between services require both services running. Contract tests don't. The scale difference matters at 50+ services.

Testing implementation details in contract tests. Contracts should describe the API interface (inputs, outputs, behavior), not implementation details (database structure, internal event names). Contracts that test too deeply break on every internal refactor.

Shared test environments. A shared staging environment used by all teams leads to test interference, flaky tests, and slow debugging. Each team should have an isolated environment for integration testing.

No state management in contract tests. Provider verification tests need stateHandlers that set up specific database conditions. Without them, tests pass or fail based on whatever data happens to be in the database.

E2E tests as the primary quality gate. E2E tests are too slow and too flaky to be the main quality gate. If a change can't be covered by unit and contract tests, that's a design problem.

Summary

Microservices testing requires layering deliberately: unit tests per service, contract tests for inter-service interfaces, integration tests for each service's own dependencies, and minimal E2E tests for critical journeys.

The hardest part isn't writing the tests — it's the discipline to use each layer correctly and resist the temptation to use E2E tests as a substitute for lower-level tests that are harder to set up.

A service that can be tested in isolation, with its contracts verified independently, is a service that can be deployed independently. The tests and the architecture reinforce each other.

Read more