Contract Tests and End-to-End Test Strategy

Contract tests and end-to-end tests answer different questions in a Spring Boot system. A contract test asks whether a provider and a consumer still agree about an HTTP message, messaging payload, status code, header, schema rule, or error shape. An end-to-end test asks whether a complete user journey works when the application, database, security filter chain, and selected external dependencies are assembled. The useful outcome is not more tests; it is a test strategy that detects API drift near the commit that caused it while reserving slower full-system tests for the few workflows that prove the release can function.

In this course’s testing section, this lesson sits after unit, slice, and integration testing because contract and end-to-end tests depend on those layers. A controller contract test is weak if request validation is not understood. An end-to-end test is expensive noise if repository and service behavior are not already covered by narrower tests. The strategy is to choose the smallest test that can fail for the risk being controlled.

How Contract Testing Works in Spring Boot

A service contract is an executable description of a boundary. For a REST endpoint, the contract normally includes the method, path, query parameters, required headers, request body rules, response status, response headers, and response body rules. For messaging, it includes the destination, headers, key, payload, and sometimes ordering or idempotency expectations. Spring teams often implement this with Spring Cloud Contract, Pact, WireMock, MockMvc, WebTestClient, or generated OpenAPI validators. The tool matters less than the discipline: the contract must be specific enough to catch breaking change and flexible enough to avoid freezing irrelevant implementation detail.

Provider-side contract tests run against the service that owns the API. They verify that the controller, request mapping, validation, serialization, and exception translation can produce messages allowed by the contract. Consumer-side tests use generated stubs or a mock server so the consuming service can exercise its client code without needing the provider to be deployed. The internal mechanism is substitution: the real provider is replaced by a stub that behaves according to the contract. If the consumer sends a request outside the contract, the stub refuses or returns no match. If the provider changes its response shape, the provider verification fails before consumers discover it in a shared environment.

Contract Anatomy

The following contract describes a minimal order creation endpoint. It is intentionally small: the consumer relies on a POST to /orders, JSON input containing sku and quantity, a 201 response, and two response fields. It does not declare database tables, Java class names, logging, or internal service calls because those are not part of the HTTP contract.

description: create an order returns its identifier
request:
  method: POST
  url: /orders
  headers:
    Content-Type: application/json
  body:
    sku: "COURSE-101"
    quantity: 2
response:
  status: 201
  headers:
    Content-Type: application/json
  body:
    id: "ord-1001"
    status: "ACCEPTED"

There are several design choices inside this small file. Fixed values such as COURSE-101 and ord-1001 make the example deterministic. In a production contract, the identifier might be matched with a regular expression so providers can generate realistic IDs while consumers still know the field exists. Headers are part of the contract because content negotiation and JSON serialization failures often appear as header or media type mismatches before they appear as business failures.

Example 1: A Hand-Rolled Contract Matcher

This standalone Java example shows the core idea without any framework. The request is matched on method, path, and a minimal body rule. A matching request receives the documented status and body. A nonmatching request receives an error. Real tools do this with richer matchers, generated tests, and stub servers, but the internal shape is the same.

public class ContractMatchingDemo {
    record Request(String method, String path, String body) {}
    record Response(int status, String body) {}

    static Response handle(Request request) {
        if (!request.method().equals("POST") || !request.path().equals("/orders")) {
            return new Response(404, "not found");
        }
        if (!request.body().contains("\"sku\"") || !request.body().contains("\"quantity\":2")) {
            return new Response(400, "invalid order request");
        }
        return new Response(201, "{\"id\":\"ord-1001\",\"status\":\"ACCEPTED\"}");
    }

    public static void main(String[] args) {
        Request request = new Request("POST", "/orders", "{\"sku\":\"COURSE-101\",\"quantity\":2}");
        Response response = handle(request);
        System.out.println(response.status());
        System.out.println(response.body());
    }
}

Running it prints 201 and the JSON receipt. If you change the path to /checkout, the status becomes 404. If you remove quantity, the status becomes 400. This is the essence of consumer confidence: client code gets immediate feedback when it stops sending a request the provider promises to understand.

Example 2: Provider Verification with MockMvc

In a Spring Boot provider, a contract test normally exercises the web boundary without starting every infrastructure dependency. @WebMvcTest loads MVC infrastructure, JSON serialization, validation, filters if configured, and the selected controller. Collaborators such as an application service can be mocked so the test focuses on the HTTP contract rather than the database.

@WebMvcTest(OrderController.class)
class OrderControllerContractTest {
    @Autowired MockMvc mvc;
    @MockBean OrderService service;

    @Test
    void createOrderMatchesPublishedContract() throws Exception {
        given(service.create("COURSE-101", 2)).willReturn(new OrderReceipt("ord-1001", "ACCEPTED"));

        mvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"sku\":\"COURSE-101\",\"quantity\":2}"))
           .andExpect(status().isCreated())
           .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
           .andExpect(jsonPath("$.id").value("ord-1001"))
           .andExpect(jsonPath("$.status").value("ACCEPTED"));
    }
}

This test verifies the provider side of the same contract. The request must route to the controller, deserialize successfully, call the service with the expected values, and serialize a response with compatible JSON content type. The expected behavior is deterministic: the test passes only when the status is 201 Created, $.id is ord-1001, and $.status is ACCEPTED. If a developer renames status to state, the test fails before the API change reaches a consumer.

Example 3: A Narrow End-to-End Journey

An end-to-end test should be fewer, broader, and closer to deployment wiring. The next example starts the Spring Boot application on a random port and uses Testcontainers for a real PostgreSQL instance. It proves that HTTP routing, JSON serialization, validation, service logic, persistence, and retrieval can work together for one important workflow.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderCheckoutEndToEndTest {
    @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:latest");
    @Autowired TestRestTemplate rest;

    @Test
    void customerCanCreateAndReadOrder() {
        ResponseEntity<OrderReceipt> created = rest.postForEntity(
            "/orders", new CreateOrderRequest("COURSE-101", 2), OrderReceipt.class);

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        ResponseEntity<OrderReceipt> loaded = rest.getForEntity(
            "/orders/" + created.getBody().id(), OrderReceipt.class);
        assertThat(loaded.getBody().status()).isEqualTo("ACCEPTED");
    }
}

The expected behavior is that posting an order returns 201 Created, then reading the created resource returns a receipt whose status is ACCEPTED. This is not a substitute for all controller or repository tests. It is a release confidence test for a business journey. Keep it stable by using isolated data, deterministic clocks when time matters, explicit cleanup, and test-owned external resources.

Choosing the Test Boundary

A practical portfolio for a Spring Boot service usually has many unit tests, a healthy set of slice tests, targeted integration tests, provider and consumer contract tests for service boundaries, and a small number of end-to-end tests. Contract tests are best when independently deployed services communicate. They are faster than full environment tests and better at finding API drift. They are weaker at proving business workflows across multiple subsystems. End-to-end tests are best for proving wiring, configuration, authentication, persistence, and user-critical paths. They are slower, more brittle, and harder to diagnose when they fail.

Do not encode every response property as a contract. If a consumer does not depend on a field, freezing it increases maintenance cost without increasing confidence. Also do not make contracts too loose. A response body matched only as any JSON object will not catch a missing field. Good contracts describe consumer obligations and provider promises: required fields, valid ranges, meaningful error responses, and compatibility expectations.

Failure Modes and Troubleshooting

Symptom: a consumer stub returns no match for a request that appears correct. Cause: the method, path, media type, query parameter encoding, or body matcher differs from the contract. Diagnose: capture the exact outgoing request from the client test, compare it with the generated stub mappings, and check whether JSON numbers, strings, or optional fields changed. Correct: update the client to send the documented request, or negotiate and version a contract change when the consumer’s new behavior is intentional.

Symptom: provider verification fails after a harmless refactor. Cause: the refactor changed an externally visible detail such as status code, field name, null handling, date format, validation message, or content type. Diagnose: read the assertion diff as an API diff, not as a test annoyance. Run the failing contract alone and inspect the serialized response. Correct: restore the promised behavior, add a backward-compatible field, or publish a new contract version with consumer agreement.

Symptom: end-to-end tests pass locally but fail in CI. Cause: hidden dependency on ports, time zones, test order, leaked database state, unavailable containers, or different security configuration. Diagnose: run with random ports, print active profiles, check container readiness logs, and make the test create all data it needs. Correct: remove shared mutable fixtures, use Testcontainers wait strategies, inject a deterministic clock, and clean data by transaction rollback or container disposal.

Security, Reliability, and Performance

Contracts should include security-relevant boundary behavior. If a consumer must send a bearer token, tenant header, idempotency key, or correlation ID, the contract should verify the required header and the error for missing credentials. Avoid putting real secrets in contracts or stubs. Use representative tokens or test-specific claims. For reliability, publish contracts from the provider pipeline and consume immutable versions so teams know which API shape they tested. For performance, keep contract tests fast by avoiding full application startup unless the boundary requires it; save full startup for integration and end-to-end layers.

End-to-end tests can become the slowest part of a Spring Boot pipeline. Put them in a separate stage, run the most critical smoke journey on every merge, and run larger suites before release or nightly. The signal should be actionable: a failing contract usually names an API mismatch; a failing end-to-end journey should include enough logs, request IDs, and database state to identify the broken component quickly.

Hands-On Lab

Prerequisites: a Spring Boot service with an OrderController, JUnit, MockMvc or WebTestClient, and either Spring Cloud Contract, Pact, or WireMock available in the build. Docker is required if you run the Testcontainers end-to-end step.

  1. Create a contract for POST /orders with required JSON fields sku and quantity, expected status 201, and response fields id and status.
  2. Add a provider verification test that drives the controller through MockMvc or WebTestClient. Mock only the application service, not the controller or serializer.
  3. Generate or hand-configure a consumer stub from the same contract. Point one client test at the stub and assert that your client sends the documented request.
  4. Add one negative contract case: missing quantity returns 400 with a stable error code such as ORDER_QUANTITY_REQUIRED.
  5. Add one end-to-end smoke test that starts the application on a random port, uses an isolated database, creates an order, then reads it back.

Verification: run the provider contract tests and confirm that changing the response field status to state fails the provider verification. Run the consumer test and confirm that changing the client path to /checkout produces an unmatched stub request. Run the end-to-end test and confirm that the created order can be fetched by its returned identifier.

Cleanup: revert deliberate breaking changes, stop containers, delete generated test data, and remove any locally generated stubs that are not meant to be committed. If the contract is published to a broker or artifact repository, mark experimental versions clearly or remove them before other teams consume them.

Assessment Exercises

  1. A consumer uses only id and status, but the provider also returns createdAt. Should createdAt be fixed in the contract? Explain the compatibility trade-off.
  2. A provider wants to change 201 Created to 200 OK because both contain the same body. Which tests should fail, and why is this externally visible?
  3. Your end-to-end suite takes thirty minutes and often fails on shared data. Which journeys would you keep, and which checks would you move to contract, slice, or integration tests?
  4. Design a negative contract for an unauthorized order request. Include the status code, required headers, response body rule, and what must not be revealed.
  5. A generated consumer stub accepts a request with an extra field. When is that useful compatibility, and when could it hide a bug?

Summary

Spring Boot contract tests make service boundaries executable: they pin down HTTP or message behavior that consumers and providers rely on. End-to-end tests prove that a small number of critical journeys survive real application wiring. Use contracts to catch API drift early, use end-to-end tests to prove release confidence, and keep the boundary of each test honest so failures are fast to diagnose and worth fixing.