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/TestClienttests 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.,
@Commitin Spring, manual teardown in pytest). - Never use
TRUNCATEorDELETE FROMinsetUp/teardownin 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-deploycheck: Before deploying any service, the pipeline runspact-broker can-i-deployto 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¶
- Testing Philosophy
- Unit Testing Standards
- End-to-End Testing
- TestContainers documentation
- Pact documentation
Last reviewed: 2025-Q4 | Owner: Engineering Enablement