Skip to content

Integration & Contract Testing

Status: 🟢 Active  |  Owner: Engineering Enablement  |  Last Reviewed: 2025-Q4


Overview

Integration tests verify that different parts of the system work correctly together. Unlike unit tests that isolate a single class, integration tests verify the interaction between a class and its real dependencies — typically a database, a message broker, or an external service.

Contract tests are a specialised form of integration testing that verify that a service's API or message interface matches what its consumers expect. They prevent breaking changes from reaching consumers before they're detected.


Integration Testing

What to Integration Test

Integration tests sit in the middle of the testing pyramid — there should be fewer of them than unit tests, but they cover critical integration paths that unit tests cannot:

  • Repository implementations — test that SQL queries, JPA mappings, and database constraints work correctly against a real database.
  • Message producers and consumers — test that Kafka/RabbitMQ adapters correctly serialise, publish, consume, and deserialise messages.
  • HTTP clients — test that external API adapters correctly handle responses, errors, and retries.
  • Caching — test that cache invalidation and TTL behaviour work correctly.
  • Full application stack — @SpringBootTest / TestClient tests that exercise the full request/response cycle.

TestContainers

All integration tests use TestContainers to start real infrastructure (databases, message brokers) in Docker containers for the duration of the test suite. Never use H2 or other in-memory substitutes for integration tests — they have different query behaviour from production databases and will miss production bugs.

@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("orders_test")
        .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 should_persist_and_retrieve_order_with_all_fields() {
        Order order = OrderTestFactory.standardOrder();

        Order saved = orderRepository.save(order);
        Order retrieved = orderRepository.findById(saved.getId()).orElseThrow();

        assertThat(retrieved).usingRecursiveComparison().isEqualTo(saved);
    }

    @Test
    void should_find_orders_by_customer_id() {
        Order order1 = orderRepository.save(OrderTestFactory.orderForCustomer("cust-1"));
        Order order2 = orderRepository.save(OrderTestFactory.orderForCustomer("cust-1"));
        orderRepository.save(OrderTestFactory.orderForCustomer("cust-2")); // different customer

        List<Order> customerOrders = orderRepository.findByCustomerId("cust-1");

        assertThat(customerOrders).hasSize(2)
            .extracting(Order::getId)
            .containsExactlyInAnyOrder(order1.getId(), order2.getId());
    }
}
# Python — pytest + testcontainers
import pytest
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="module")
def postgres():
    with PostgresContainer("postgres:16") as pg:
        yield pg

@pytest.fixture(scope="module")
def order_repository(postgres):
    engine = create_engine(postgres.get_connection_url())
    Base.metadata.create_all(engine)
    return SqlOrderRepository(engine)

def test_should_persist_and_retrieve_order(order_repository):
    order = build_standard_order()
    saved = order_repository.save(order)
    retrieved = order_repository.find_by_id(saved.id)
    assert retrieved == saved

Database Test Isolation

  • Each test runs in a transaction that is rolled back after the test completes. This ensures tests do not affect each other.
  • For tests that explicitly test transaction commit behaviour, use a separate mechanism (e.g., @Commit in Spring, manual teardown in pytest).
  • Never use TRUNCATE or DELETE FROM in setUp/teardown in CI — it is slow and error-prone. Use transaction rollback.

Contract Testing with Pact

Consumer-Driven Contract Testing (CDCT) ensures that service consumers and providers agree on the interface between them. When a provider changes its API in a way that breaks a consumer, the contract test catches it before deployment — not in production.

How Pact Works

Consumer (Service A)                    Provider (Service B)
       │                                        │
       │ 1. Define expected interactions         │
       │    (what Consumer expects from Provider)│
       │ 2. Run consumer tests                  │
       │    → generates Pact file               │
       │ 3. Publish Pact to Pact Broker         │
       │                                        │
       │                    4. Provider verifies │
       │                       against the Pact │
       │                       (no Consumer     │
       │                        required)       │

Consumer Side (Defining the Contract)

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "order-service", port = "8080")
class OrderServiceContractTest {

    @Pact(consumer = "checkout-service")
    public RequestResponsePact getOrderById(PactDslWithProvider builder) {
        return builder
            .given("order 'ord-123' exists")
            .uponReceiving("a request for order ord-123")
                .path("/orders/ord-123")
                .method("GET")
                .headers(Map.of("Accept", "application/json"))
            .willRespondWith()
                .status(200)
                .headers(Map.of("Content-Type", "application/json"))
                .body(new PactDslJsonBody()
                    .stringType("id", "ord-123")
                    .stringMatcher("status", "PENDING|CONFIRMED|SHIPPED", "CONFIRMED")
                    .decimalType("total", 99.99)
                )
            .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "getOrderById")
    void should_retrieve_order_details(MockServer mockServer) {
        OrderServiceClient client = new OrderServiceClient(mockServer.getUrl());
        OrderDto order = client.getOrder("ord-123");

        assertThat(order.getId()).isEqualTo("ord-123");
        assertThat(order.getStatus()).isEqualTo("CONFIRMED");
    }
}

Provider Side (Verifying the Contract)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("order-service")
@PactBroker(url = "${PACT_BROKER_URL}")
class OrderServiceContractVerificationTest {

    @LocalServerPort
    private int port;

    @BeforeEach
    void setUp(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", port));
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPact(PactVerificationContext context) {
        context.verifyInteraction();
    }

    @State("order 'ord-123' exists")
    void setupOrderExists() {
        // Insert test data to satisfy the provider state
        orderRepository.save(OrderTestFactory.orderWithId("ord-123"));
    }
}

Pact Broker

All Pacts are published to the central Pact Broker instance (see internal infrastructure documentation for the URL).

  • Consumer pipeline step: Publish Pact after consumer tests pass.
  • Provider pipeline step: Run Pact verification and publish results.
  • can-i-deploy check: Before deploying any service, the pipeline runs pact-broker can-i-deploy to confirm all compatible versions are deployed together.

Test Scope Summary

Test Type Scope Speed Infra Required
Unit Single class Very fast (< 1ms) None
Integration Class + real infra Moderate (10ms–1s) TestContainers
Contract (consumer) Service boundary Fast (mocked provider) Pact Broker
Contract (provider) Service boundary Moderate Pact Broker + app
E2E Full system Slow Full environment

References


Last reviewed: 2025-Q4  |  Owner: Engineering Enablement