TDD for Microservices: Outside-In TDD with Contract Tests

TDD for Microservices: Outside-In TDD with Contract Tests

Outside-in TDD starts with a failing acceptance test at the service boundary, drives implementation inward to integration and unit tests, then locks the consumer–provider contract with Pact. You ship working microservices whose boundaries are automatically verified on every build — without a running integration environment.

Microservices promise independent deployability. They rarely deliver it. The reason is almost always the same: teams integrate late, discover mismatches at the seam between services, and spend days debugging JSON fields that changed shape without notice.

Outside-in TDD combined with consumer-driven contract tests fixes this at the source. Instead of hoping services agree, you encode that agreement as an executable contract that runs in CI before deployment.

What Outside-In TDD Means for Microservices

Classic TDD starts at the bottom — write a unit test, make it pass, repeat, eventually wire things together. Outside-in TDD (also called "London school" TDD) reverses the order: start from the outermost observable behavior, let the design emerge inward.

For a microservice, the layers look like this:

Acceptance test (HTTP level) — what the consumer of this service expects
    ↓
Controller / API layer test — routes, request/response mapping
    ↓
Service layer test — business logic, mocked infrastructure
    ↓
Repository / adapter test — data access, external calls

You write a failing test at the acceptance layer first. It fails because nothing exists. Then you write the controller test, which also fails. You implement the controller, which satisfies the controller test but not yet the acceptance test. You keep driving inward until the acceptance test goes green.

Starting from the Acceptance Test

Imagine you are building an order-service that exposes POST /orders. A consumer service will call it and expect a specific response shape. Before writing any implementation, write this:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase
class CreateOrderAcceptanceTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateOrderAndReturnOrderId() {
        var request = new CreateOrderRequest("customer-42", List.of(
            new OrderItem("SKU-001", 2, new BigDecimal("19.99"))
        ));

        var response = restTemplate.postForEntity("/orders", request, CreateOrderResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().orderId()).isNotBlank();
        assertThat(response.getBody().status()).isEqualTo("PENDING");
        assertThat(response.getBody().totalAmount()).isEqualByComparingTo("39.98");
    }
}

This test fails immediately — there is no controller, no service, nothing. That is the correct starting state. The test is your specification.

Driving Inward: Controller Tests

With the acceptance test red, write a controller-layer test next. Use MockMvc to stay fast:

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn201WithOrderIdOnSuccess() throws Exception {
        var createdOrder = new Order("order-xyz", "customer-42", OrderStatus.PENDING, new BigDecimal("39.98"));
        when(orderService.createOrder(any())).thenReturn(createdOrder);

        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {
                        "customerId": "customer-42",
                        "items": [{"sku": "SKU-001", "quantity": 2, "unitPrice": 19.99}]
                    }
                    """))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.orderId").value("order-xyz"))
            .andExpect(jsonPath("$.status").value("PENDING"))
            .andExpect(jsonPath("$.totalAmount").value(39.98));
    }

    @Test
    void shouldReturn400WhenCustomerIdIsMissing() throws Exception {
        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""{"items": [{"sku": "SKU-001", "quantity": 1, "unitPrice": 9.99}]}"""))
            .andExpect(status().isBadRequest());
    }
}

Now implement the OrderController to make these pass. The acceptance test is still red — the controller exists but the service does not. That is fine. Keep going inward.

Service Layer and Repository Tests

The service test mocks the repository:

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @InjectMocks
    private OrderService orderService;

    @Test
    void shouldCalculateTotalAmountFromItems() {
        var request = new CreateOrderRequest("customer-42", List.of(
            new OrderItem("SKU-001", 2, new BigDecimal("19.99")),
            new OrderItem("SKU-002", 1, new BigDecimal("5.50"))
        ));

        var savedOrder = new Order("order-abc", "customer-42", OrderStatus.PENDING, new BigDecimal("45.48"));
        when(orderRepository.save(any())).thenReturn(savedOrder);

        var result = orderService.createOrder(request);

        assertThat(result.totalAmount()).isEqualByComparingTo("45.48");
        verify(orderRepository).save(argThat(order ->
            order.totalAmount().compareTo(new BigDecimal("45.48")) == 0
        ));
    }
}

Implement the service. Now the acceptance test goes green. You have driven the implementation from the outside in, and every layer has explicit test coverage with a clear boundary.

Locking Service Boundaries with Pact

The acceptance test proves the service works in isolation. It does not prove the consumer and provider agree on the contract. That is where Pact comes in.

Pact is a consumer-driven contract testing tool. The consumer team writes a test that records what it expects from the provider. That recording — the "pact" — is verified against the real provider service.

Consumer side (e.g., shipping-service calling order-service):

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "order-service")
class OrderServiceConsumerTest {

    @Pact(consumer = "shipping-service")
    public RequestResponsePact createOrderPact(PactDslWithProvider builder) {
        return builder
            .given("order service is available")
            .uponReceiving("a request to create an order")
                .path("/orders")
                .method("POST")
                .body(new PactDslJsonBody()
                    .stringValue("customerId", "customer-42")
                    .array("items")
                        .object()
                            .stringValue("sku", "SKU-001")
                            .integerType("quantity", 2)
                            .decimalType("unitPrice", 19.99)
                        .closeObject()
                    .closeArray())
            .willRespondWith()
                .status(201)
                .body(new PactDslJsonBody()
                    .stringType("orderId")
                    .stringValue("status", "PENDING")
                    .decimalType("totalAmount"))
            .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "createOrderPact")
    void shouldCreateOrderViaOrderService(MockServer mockServer) {
        var client = new OrderServiceClient(mockServer.getUrl());
        var response = client.createOrder("customer-42", List.of(new OrderItem("SKU-001", 2, 19.99)));

        assertThat(response.orderId()).isNotBlank();
        assertThat(response.status()).isEqualTo("PENDING");
    }
}

Running this test generates a pact file (JSON). That file is the contract.

Provider side — verifying the contract against the real order-service:

@Provider("order-service")
@PactFolder("pacts")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderServiceProviderTest {

    @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 service is available")
    void orderServiceAvailable() {
        // nothing needed — service starts clean
    }
}

When the provider test runs, it replays every interaction recorded in the pact file against the live service. If the order-service team changes the response shape, the provider verification fails before deployment. The consumer team finds out without running a shared integration environment.

Consumer-Driven Contracts in Practice

The workflow with a Pact Broker (or PactFlow) looks like this:

  1. Consumer writes a Pact test → generates a pact file
  2. Consumer CI publishes the pact file to the broker: ./gradlew pactPublish
  3. Provider CI pulls pacts from the broker and runs provider verification: ./gradlew pactVerify
  4. Neither service deploys unless the "can-i-deploy" check passes
# In provider CI pipeline
./gradlew pactVerify -Ppact.broker.url=https://your-broker \
    -Ppact.verifier.publishResults=true \
    -Ppact.provider.version=${GIT_SHA}

# Before deploying either service
pact-broker can-i-deploy \
    --pacticipant order-service \
    --version ${GIT_SHA} \
    --to-environment production

This creates a hard gate: the provider cannot deploy a breaking change, and the consumer cannot deploy until the provider satisfies its contract.

Putting It Together: The Full Workflow

1. Consumer team writes a failing acceptance test for their feature
2. They write a Pact consumer test defining what they need from order-service
3. They implement their feature — Pact consumer test passes, pact file published
4. Provider team picks up the new pact
5. Provider team writes a failing acceptance test for the new capability
6. They drive implementation outside-in (acceptance → controller → service → repo)
7. Provider verification passes, published to broker
8. Both teams run can-i-deploy — it passes
9. Both services deploy independently

The key insight: at no point did either team need a running instance of the other service. The contract test is the integration environment.

Common Pitfalls

Writing provider states that are too specific. A state like "order order-123 exists with status PENDING for customer-42" is fragile. Keep states behavioral: "at least one pending order exists".

Skipping the acceptance layer. Some teams write Pact tests but skip the outside-in acceptance tests inside their own service. You end up with a verified contract but a service that misroutes or mismaps internally.

One giant pact. Each consumer interaction should be its own pact test method. Mixing concerns into one interaction makes failures hard to diagnose.

Not running provider verification in CI. Pact tests on the consumer side that never get verified against the real provider are theater. The broker integration and the can-i-deploy check are not optional.

Conclusion

Outside-in TDD gives you design pressure from the consumer's perspective, which is exactly where pressure should come from in a microservices architecture. Consumer-driven contract tests turn that design pressure into a CI gate that prevents integration failures before they reach a shared environment.

The combination — acceptance tests driving implementation inward, Pact locking the service boundary — means you can deploy microservices independently with confidence, which is the entire point of having microservices in the first place.

Start with one consumer–provider pair. Write the consumer test, publish the pact, make the provider verify it. That first green verification across a real CI pipeline is the moment the approach clicks.

Read more

Start now free