Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 45

    Microservices Testing Strategies

    The order module used to be a package. A unit test called 'OrderService' and a '@SpringBootTest' booted the only process there was. Extracting payment and fulfillment, which [Micro

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Microservices Testing Strategies

    Advanced System Design & Software Architecture · Lesson 45 · Microservices and Enterprise Architecture

    Lesson Overview

    The order module used to be a package. A unit test called OrderService and a @SpringBootTest booted the only process there was. Extracting payment and fulfillment, which Microservices Architecture Principles treats as the point of the split, multiplies the things a test can mean. “The order service works” can mean the domain object rejects a negative amount, the HTTP contract checkout relies on still returns 201, a real PostgreSQL unique index still rejects a duplicate orderId, or a browser can buy a course in a cluster that includes a stub acquirer. Those are four different tests. Teams that keep only the last one learn about a broken total through a forty-minute pipeline and a screenshot. Teams that keep only the first one ship a JSON field rename that passes every unit test and fails checkout in production.

    The useful shape is still a pyramid, with one extra band that the monolith did not need. Most tests are unit tests of domain rules, with no Spring context and no network. Slice tests (@WebMvcTest, @DataJpaTest) check one Spring layer with the others replaced. Integration tests boot the service against real infrastructure you start with Testcontainers: PostgreSQL, and Kafka when the assertion is about a record you produced. Contract tests, consumer-driven when the consumer is the one who will break, pin the interaction between two teams without requiring both deployables to be up. A few end-to-end journeys prove the deployed path, including the security rules from Microservices Security and Zero Trust. The pyramid is a budget. It is not a moral claim that unit tests are nobler. It is the observation that a test which needs a cluster fails more often for reasons you are not testing, and you cannot afford thousands of those.

    This lesson uses JUnit 5, Spring Boot’s test slices, Testcontainers’ PostgreSQLContainer with @ServiceConnection or @DynamicPropertySource, and Pact JVM’s consumer and provider JUnit 5 extensions (@Pact, @PactTestFor, @Provider, @State). Those are real tools with real failure modes. A green Pact run means the interactions you wrote still match, not that the business will make money. Module 5’s case studies, especially the e-commerce platform and the notification fan-out, need a test strategy in the design, not a box labeled “QA.” The strategy says which bugs each layer is allowed to catch.

    Learning Objectives

    By the end of this lesson, you should be able to:

    1. Place a proposed test on the pyramid — unit, slice, integration, contract, or end-to-end — and justify the cost of the layer you chose.
    2. Write a domain unit test that does not start Spring, and a @WebMvcTest that locks status codes and JSON fields the contract cares about.
    3. Run a repository test against PostgreSQL in Testcontainers so a unique constraint and a transaction rollback are real, not mocked.
    4. Explain consumer-driven contracts: the consumer publishes the expectations, the provider verifies them in its pipeline, and a broker answers whether this version may deploy.
    5. Use a Pact provider state (@State) to set up data, and match types where the value is generated and exact values where the value is an enum.
    6. Decide what an end-to-end journey must cover (one happy path, one payment decline, one auth rejection) and what it must not cover (every branch of pricing).
    7. Keep tests deterministic: no sleep-as-synchronization, no shared mutable environment, no order dependence between classes.
    8. Attach this strategy to a Module 5 design so “how do you test the saga?” has a layer-by-layer answer.

    Prerequisites

    • Lesson 38, Microservices Architecture Principles: independent deployability is why a shared end-to-end suite cannot be the only gate.
    • Lesson 40, API Design, Versioning and Contracts: an OpenAPI document describes the provider’s intent. A consumer-driven pact records what a real consumer requires. They are not duplicates.
    • Lesson 33, Message Queues and Event Streaming: a test that publishes OrderAuthorized must assert the key, the payload, and the fact that a second consumer group would see the same record. It must not assume the broker deletes on ack.
    • Lesson 44, Microservices Security and Zero Trust: 401 on a bad audience is a test, not a runbook surprise.
    • JUnit 5 (@Test, @BeforeEach, assertions). You do not need to have run a Pact Broker.

    Detailed Core Concepts

    What each layer is allowed to catch

    A unit test constructs the objects it needs. OrderDraft rejects an empty SKU. A pricing function rounds half-up in the currency’s scale. Mockito stubs a port you own when the test is about the caller’s reaction to an exception, not about the collaborator’s behavior. The test runs in milliseconds. It does not prove that Jackson will spell the field orderId or that PostgreSQL will reject a duplicate. Pretending it does is how the pyramid collapses into a pile of mocks that restate the implementation.

    A slice test starts the slice of Spring that has a history of configuration bugs. @WebMvcTest(OrderController.class) loads the web layer, not the database and not your OrderService if you replace it with @MockitoBean (the current Spring Framework test annotation; @MockBean is the older Spring Boot annotation that does the same job on Boot 3.3 and earlier). You assert HTTP status, content type, and the JSON fields a client parses. @DataJpaTest loads JPA and an embedded or containerized database and rolls the transaction back. Slices are where a missing @Valid, a wrong status code, or a query that does not match the entity mapping shows up. They are still not the contract with another team, because the other team’s client is not in the process.

    An integration test boots enough of the real service to cross a process boundary you do not control: SQL against PostgreSQL, a record against Kafka, an HTTP call against the service’s own listening port with the security filter chain turned on. Testcontainers starts the dependency in Docker for the test JVM and tears it down afterward. That is the difference between “H2 accepted this SQL” and “the index you will have in production rejected the duplicate.” Use integration tests for behavior that lives in the infrastructure: unique constraints, transaction isolation you depend on, the outbox row committing with the order, the traceparent header on a Kafka record. Do not use them to re-check every pricing branch. Those branches belong in unit tests, and repeating them against a container is how the suite becomes a twenty-minute tax.

    A contract test pins an interaction between a consumer and a provider. Checkout is the consumer of POST /orders. The pact names the request the consumer will send and the response it must understand. The consumer test runs against a mock server Pact starts from that pact, so checkout’s client code is executed, not imagined. The provider test runs later, in the order service pipeline, and replays those requests against the real controller. If order renames status to state, order’s build fails even though checkout has not been redeployed. That is the property you cannot get from each side’s unit tests.

    An end-to-end test deploys the system and performs a journey a learner would recognize: buy a course, see the entitlement, or buy a course and receive a decline when the acquirer says no. It catches wiring the other layers cannot see: a gateway route to the wrong Service, a NetworkPolicy that blocks payment, a missing key in the Secret, a broken OIDC discovery URL. It is slow, it is shared, and it is flaky when it depends on clocks and other people’s data. Keep a handful. Debug them when they fail. Do not grow them into a second product.

    Consumer-driven means the consumer’s need is the artifact

    There are two directions a contract can be written. In a provider-driven workflow the order team publishes OpenAPI or a Spring Cloud Contract definition, generates stubs, and consumers test against the stubs. That works when one provider has many consumers and a staffed API review. It fails when the provider’s document drifts from any single consumer’s actual calls, because nothing in the provider build executes checkout’s parser.

    Consumer-driven contracts reverse the authorship of the expectation. Checkout writes the pact: given this provider state, when I post this body, I need a 201 and a status field I can read. The pact is published to a Pact Broker with the consumer version. The provider verifies the pacts that name it. The broker’s can-i-deploy question — can consumer version X and provider version Y go to production together — is a compatibility check, not a deploy button. Pact’s official CLI uses that name. The discipline is: a breaking response fails the provider before deploy, and a consumer that requires a field the provider never promised fails the consumer when it tries to publish a pact the verification cannot satisfy.

    Consumer-driven does not mean the consumer dictates the business. The provider owns the state and can reject a pact that demands a field it will not support. The negotiation is the point. An undocumented chat message is not a pact. The file in the broker is.

    Match what is stable. stringValue("status", "AUTHORIZED") demands that exact enum. stringType("orderId", "ord-1") demands a string and uses ord-1 as the example the mock returns. Matching the example order id as an exact value on every response couples you to a fixture. Matching an entire JSON document with equals couples you to every new field. Pact’s default body matcher, used carelessly, becomes a change detector. Prefer type matchers for generated identifiers and exact matchers for enumerations and status codes. Ignore headers you do not read, and pin headers you do, such as Content-Type.

    Provider states are the setup hook. The pact says given("sku JAVA-101 is stocked"). The provider test method annotated @State("sku JAVA-101 is stocked") inserts the row, or points a fake inventory port at “in stock.” The state is part of the contract. A state named “default” that sometimes has stock is a flaky verification. States should be deterministic and independent.

    Message contracts exist because checkout’s other collaborator is Kafka. A message pact describes the payload and metadata the consumer expects to receive, and the provider verification publishes or hands that message to the same serializer the production KafkaTemplate uses. Do not limit contracts to HTTP and then let the topic schema drift. The topic name, the key, and the JSON fields are the contract Lesson 33 already said would replace a shared database. A schema registry with a compatibility mode (backward) is a second, complementary gate. The pact checks that this consumer can still read. The registry checks that a new schema is compatible with the configured policy. Neither one asserts that fulfillment’s business rule is correct.

    What Testcontainers is doing, and what a shared dev database is not

    PostgreSQLContainer from Testcontainers starts a real Postgres and exposes a JDBC URL. @Testcontainers manages the lifecycle. JUnit starts the container before the tests that need it. In Spring Boot 3.1 and later, @ServiceConnection on the container registers connection details so you do not copy the URL into spring.datasource.url by hand. @DynamicPropertySource is the explicit form when you want the property names visible in the test:

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

    The image tag should be pinned (postgres:16-alpine or whatever minor you run in production), not latest. A container per test class is isolation. A container started once per suite (static field with @Container) is the usual speed compromise, with each test cleaning the rows it wrote or running in a rolled-back transaction. A shared company-wide “test database” on a VM is not this. It leaks rows, it drifts from the migration head, and two pipelines corrupt each other’s fixtures.

    Kafka in a container is the same idea for the outbox relay. Point spring.kafka.bootstrap-servers at getBootstrapServers(). Assert that one record landed on order.authorized, that the key is the order id, and that the payload contains status=AUTHORIZED. Assert a negative: a failed transaction did not publish. That negative is the outbox test. A mock KafkaTemplate that captures a send in a list does not start a broker and does not prove the transaction boundary. Use the mock when you are testing the caller’s branching, and the container when you are testing the publish.

    Testcontainers need a Docker daemon in CI. If the runner cannot start Docker, the suite does not exist, and someone will “temporarily” point tests at H2. H2 is a different database. A unique index, a partial index, FOR UPDATE, and JSONB operators will not behave the same. For repository tests that exist to catch SQL, H2 is the wrong double. For a pure JPA mapping smoke test it is a convenience. Name which one you are writing.

    End-to-end tests are a sample, not a net

    An end-to-end journey for checkout: a client with a real (test-issuer) JWT posts to the gateway, the order is authorized against a stub acquirer, the entitlement appears, and the OrderAuthorized record is on the topic. A second journey: the stub acquirer declines, the learner receives a business rejection, and no entitlement is created. A third: a token with the wrong audience receives 401 and no row. That third test is the security regression from Lesson 44, and it belongs here because only the deployed gateway and order service together prove the token is not stripped.

    What does not belong: one journey per SKU, per country, per card brand. Those are unit tests of pricing and contract tests of the acquirer adapter. End-to-end tests that loop over catalogs are how suites get quarantined. A quarantined test is a test you deleted without the honesty of deleting it.

    Flakes have causes you can name. A Thread.sleep until Kafka “probably” has the record will fail under load. Poll with a timeout and a clear assertion failure. A test that depends on another test’s row will fail when the runner parallelizes. A test that uses “the first order in the database” will fail when another pipeline shares the environment. Prefer an ephemeral namespace or a Testcontainers-based compose of the few services the journey needs. If you must use a long-lived staging cluster, own the data prefix (a run id on the learner) and clean it in finally.

    How this meets the saga and the modular monolith

    A saga from Distributed Transactions and the Saga Pattern is painful to test end-to-end and straightforward to test in layers. Unit-test the state machine: given PaymentCaptured after InventoryReserved, the next command is UnlockCourse. Contract-test each command and event payload. Integration-test the outbox. End-to-end one happy path and one compensation (payment fails, reservation is released). If compensation is only tested in production, it is not a design, it is a hope.

    Inside the modular monolith, Spring Modulith’s @ApplicationModuleTest can publish an event and observe the downstream module without extracting a process. Those tests stay valuable after extraction only for the modules that remain. The extracted boundary switches from an in-process event to a pact or a message contract. Do not keep a Modulith test that calls a bean you have moved to another deployable. It is testing a ghost.

    Architecture and Design Diagrams

    Who writes the artifact, who runs it, and which failure it is supposed to catch. The broker is the only place the two pipelines must both reach. They do not need a shared Kubernetes cluster to know the JSON still matches.

    mermaid
    flowchart TD
    subgraph consumerCI [Checkout pipeline]
    U1[Unit tests of the client mapping]
    P1[Pact consumer test against mock server]
    U1 --> P1
    P1 --> Pub[Publish pact + consumer version]
    end
    subgraph providerCI [Order pipeline]
    U2[Domain unit tests]
    S[@WebMvcTest and Testcontainers]
    P2[Pact provider verification]
    U2 --> S
    S --> P2
    end
    Pub --> Broker[(Pact Broker)]
    Broker --> P2
    P2 --> Can[can-i-deploy]
    Can --> E2E[Few deployed journeys]

    A provider verification is not a unit test of pricing. The state arranges stock. The HTTP call is the one checkout already recorded. The assertion is Pact’s, against the live controller and security chain if the pact includes the headers the consumer will send.

    mermaid
    sequenceDiagram
    participant B as Pact Broker
    participant V as Provider test
    participant O as Order service on random port
    participant DB as PostgreSQL container
    B->>V: pact for checkout, version N
    V->>DB: @State sku JAVA-101 is stocked
    V->>O: POST /orders as the pact describes
    O->>DB: insert order
    O-->>V: 201 and body
    V->>B: verification result

    The pyramid with the contract band called out. Width is how many tests you should expect to own, not a quota.

    mermaid
    flowchart BT
    E2E[End-to-end journeys: a few]
    Contract[Consumer and provider pacts]
    Int[Testcontainers integration]
    Slice[Web and data slices]
    Unit[Domain unit tests]
    Unit --> Slice --> Int --> Contract --> E2E

    Technical Explanation

    A domain unit test has no infrastructure

    The rule “money cannot be negative and the currency is ISO” does not need a port. The test constructs Money and expects an exception. If you cannot write that test without @SpringBootTest, the rule is trapped in a controller and you will end up testing it through Docker. That is a design smell the test is correctly making expensive. Move the rule. Then the unit test is the one that runs on every commit without a daemon.

    Mockito is for ports you own: “when payment throws PaymentUnavailableException, the order is not stored and the exception propagates.” Verify the interaction you care about once. A test that verifies every call in order is a transcript of the method. It fails on every refactor that preserves behavior. Prefer asserting the outcome (no row, exception type) over verify(mock, times(1)) on every collaborator.

    @WebMvcTest locks the edge of the process

    java
    @WebMvcTest(OrderController.class)
    class OrderControllerTest {
    @Autowired
    MockMvc mockMvc;
    @MockitoBean
    OrderService orders;
    @Test
    void returnsAuthorized() throws Exception {
    when(orders.authorize(any())).thenReturn(new Authorization("ord-1", "AUTHORIZED"));
    mockMvc.perform(post("/orders")
    .contentType(MediaType.APPLICATION_JSON)
    .content("{\"orderId\":\"ord-1\",\"sku\":\"JAVA-101\"}"))
    .andExpect(status().isCreated())
    .andExpect(jsonPath("$.status").value("AUTHORIZED"));
    }
    }

    This test does not prove the service writes to PostgreSQL. It proves the controller’s JSON and status, which is what a contract also cares about. Keep both only if they fail for different reasons. If the pact provider test already hits the real controller, a slice test still helps when you want a fast check that validation returns 400 for a missing SKU without publishing a pact for every invalid body. Do not encode twenty invalid bodies as twenty pacts. Invalid bodies are your validation, tested here. The pact covers the success and the business rejections the consumer handles, such as 409 when the order id was already used, because the consumer’s retry behavior depends on that code. That dependency is exactly what Idempotency and Exactly-Once Processing needs locked.

    Security belongs in a slice or a full @SpringBootTest with spring-security-test. SecurityMockMvcRequestPostProcessors.jwt() builds a JWT authentication:

    java
    mockMvc.perform(post("/orders")
    .with(jwt().jwt(token -> token
    .claim("scope", "orders.read")
    .audience(List.of("order-service"))))
    .contentType(MediaType.APPLICATION_JSON)
    .content("{\"orderId\":\"ord-1\",\"sku\":\"JAVA-101\"}"))
    .andExpect(status().isForbidden());

    orders.read must not create an order. Wrong audience is a decoder test with a signed token or a decoder unit test against JwtClaimValidator. Do not turn security off in @SpringBootTest with a profile that permits all and then claim the suite represents production. A profile named test that disables the resource server is a different application.

    The consumer pact executes the client

    java
    @ExtendWith(PactConsumerTestExt.class)
    @PactTestFor(providerName = "order-service")
    class CheckoutOrderPactTest {
    @Pact(consumer = "checkout")
    RequestResponsePact authorize(PactDslWithProvider builder) {
    return builder
    .given("sku JAVA-101 is stocked")
    .uponReceiving("authorize a new order")
    .path("/orders")
    .method("POST")
    .headers("Content-Type", "application/json")
    .body(new PactDslJsonBody()
    .stringType("orderId", "ord-1")
    .stringValue("sku", "JAVA-101"))
    .willRespondWith()
    .status(201)
    .body(new PactDslJsonBody()
    .stringType("orderId", "ord-1")
    .stringValue("status", "AUTHORIZED"))
    .toPact();
    }
    @Test
    @PactTestFor(pactMethod = "authorize")
    void clientReadsStatus(MockServer server) {
    CheckoutOrderClient client = new CheckoutOrderClient(server.getUrl());
    Authorization result = client.authorize(new OrderDraft("ord-1", "JAVA-101"));
    assertThat(result.status()).isEqualTo("AUTHORIZED");
    }
    }

    The important line is the call to CheckoutOrderClient. If the test only builds a pact and never runs the client, you have a document, not a consumer test. When the client uses RestClient and expects status, a provider rename fails this test on the consumer side the next time the pact is refreshed, and it fails the provider side at verification. Publish the pact from CI with the consumer’s version. A laptop publish to the production broker is how a half-written expectation blocks a provider deploy.

    Provider verification and states

    java
    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    @Provider("order-service")
    @PactBroker(url = "https://pact.techlearningpro.example")
    class OrderProviderPactTest {
    @LocalServerPort
    int port;
    @Autowired
    InventorySeed inventory;
    @BeforeEach
    void target(PactVerificationContext context) {
    context.setTarget(new HttpTestTarget("localhost", port));
    }
    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verify(PactVerificationContext context) {
    context.verifyInteraction();
    }
    @State("sku JAVA-101 is stocked")
    void stocked() {
    inventory.givenSku("JAVA-101", 10);
    }
    }

    @PactBroker pulls the pacts consumers have published. @PactFolder is acceptable for a local exercise and wrong for two teams, because the folder will drift from what checkout published. The state method must leave the database in the condition the pact names, and it must not depend on leftover rows from the previous state. If verification shares a Spring context and a database, clean inside the state or use a transaction you control. Pact will issue the HTTP call itself. Your @TestTemplate method’s job is to hand it a target.

    can-i-deploy before production answers: is there a verification result that says this consumer version and this provider version are compatible? Shipping without that check means the pact is a report, not a gate. The command lives in the Pact Broker CLI and takes the participant and the version you just built.

    Testcontainers for the constraint you mean

    java
    @Testcontainers
    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    class OrderRepositoryTest {
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
    @Autowired
    OrderRepository repository;
    @Test
    void rejectsDuplicateOrderId() {
    repository.save(Order.authorized("ord-1", "JAVA-101"));
    assertThatThrownBy(() -> repository.save(Order.authorized("ord-1", "JAVA-101")))
    .isInstanceOf(DataIntegrityViolationException.class);
    }
    }

    @AutoConfigureTestDatabase(replace = NONE) stops @DataJpaTest from swapping in an embedded database and ignoring the container. Without that line the annotation and the container disagree, and the test passes on H2 while production’s unique index is a different shape. This is a common silent miss. If @ServiceConnection is not available on the Boot version you are on, use @DynamicPropertySource as shown earlier. Pin the image. Let Flyway or Liquibase run the same migrations production runs. A test that builds the schema with ddl-auto=create is testing Hibernate’s guess.

    Practical Implementation or Design Walkthrough

    Problem

    Checkout and the order service are separate teams. Orders are stored in PostgreSQL. Successful authorization publishes OrderAuthorized for fulfillment. Payment is a third service, stubbed at the boundary except in one end-to-end journey. You need a strategy that catches a renamed JSON field, a missing unique index, a security regression, and a broken happy path, without a two-hour suite.

    The matrix

    RiskLayerNot this layer
    Negative amount, unknown currencyUnit test of Money and OrderEnd-to-end
    400 on missing sku, 403 on orders.read@WebMvcTest with jwt()Pact for every invalid body
    Duplicate orderId rejected by PostgreSQLTestcontainers repository testMockito of JdbcTemplate
    Outbox row and Kafka record commit togetherIntegration test with PostgreSQL and KafkaContainer, spring.kafka.bootstrap-servers from getBootstrapServers()Mock KafkaTemplate
    Checkout can parse statusPact consumer test that calls CheckoutOrderClientA wiki example
    Order still returns what checkout needsProvider verification in order’s pipeline, @State for stockA shared staging cluster as the only check
    Gateway, order, stub acquirer, entitlementOne or two deployed journeysA journey per SKU

    Steps you can run in a pipeline

    1. Unit tests on every commit, no Docker.
    2. Slice tests on every commit.
    3. Testcontainers integration on every commit if the runners have Docker. If they do not, fix the runners before you “temporarily” use H2 for the unique-index test.
    4. Consumer pact tests publish to the broker with --consumer-app-version set to the git SHA. Fail the job if publish fails.
    5. Provider verification pulls pacts for order-service. A failure names the consumer and the interaction, and the order team does not merge the breaking change until the consumer has a version that tolerates it, or the change is additive.
    6. can-i-deploy in the deploy pipeline for both participants.
    7. End-to-end after deploy to a staging namespace: happy path, decline, bad audience. They page a human when they fail. They are not skipped to keep a dashboard green.

    Failure handling

    A pact verification failure is a merge blocker for the provider, not a Slack FYI. A flaky Testcontainers test is a bug: inspect port clashes, unpinned images, and tests that assume a clean table without enforcing one. A flaky end-to-end test is also a bug. Quarantine has an expiry date and an owner. An ignored test with @Disabled("flaky") from last year is not a strategy.

    Test data uses synthetic ids. No production dumps of learner email in CI logs. The PAN rule from Lesson 43 applies to fixtures: use an acquirer test card number the acquirer documents, not a real one a developer pasted.

    Expected result

    Renaming status fails the provider verification and the consumer client test. Dropping the unique index fails OrderRepositoryTest against PostgreSQL. A token with orders.read fails the MVC security test. The staging journey still buys one course. The suite’s slow part is a few containers and two journeys, not a combinatorial end-to-end matrix. You can explain this table in a design review for the e-commerce case study without inventing a fifth kind of test.

    Enterprise Real-World Scenarios

    An order service, a broker, and a fulfillment consumer

    The scenario the brief names is the core of Design a Large-Scale E-Commerce Platform. Checkout consumes HTTP. Fulfillment consumes OrderAuthorized. Two pacts, one HTTP and one message, hang off the same provider. The HTTP pact’s state seeds stock. The message pact’s verification uses the production serializer so a Jackson annotation change is visible. Fulfillment’s consumer test runs against the Pact message mock and asserts that its handler is idempotent on orderId, which is the lesson from the log-versus-queue material: the test publishes the same message twice and expects one shipment row. That double delivery is a unit or integration test of fulfillment, not a pact. The pact says the bytes are readable. The idempotency test says the bytes are safe. End-to-end covers one purchase that creates one shipment. It does not inject a crash between commit and offset. That crash is an integration test you can force by failing the process after the write in a test double of the commit step, or by asserting the unique key under two sequential deliveries.

    A notification platform that cannot end-to-end every provider

    Design a Notification Platform will talk to email and push providers you do not control. Contract tests against those providers are not yours to write as pacts; they do not run your broker. You record their documented webhook and their error codes in adapter tests with a stub server (WireMock or the JDK HTTP server) that returns the status codes you handle. Testcontainers is the wrong tool for someone else’s cloud API. One synthetic end-to-end uses the provider’s sandbox if they offer one, and it is allowed to be nightly rather than per commit. The per-commit gate is: the template renderer’s unit tests, the pact or schema of your own accept API, the Kafka message contract to the worker, and an integration test that a bounce status updates the attempt row. The case study’s test section should say this out loud so a reviewer does not demand a live email on every pull request.

    Best Practices

    • Put business rules in unit tests that do not start Spring. If a rule needs a container, it is probably stuck in the wrong class.
    • Let consumer-driven pacts cover the interactions a real client parses, with type matchers for generated ids and exact matchers for enums and status codes.
    • Verify provider pacts in the provider pipeline against the real controller, with deterministic @State setup.
    • Use can-i-deploy as a release gate. A pact that does not block a bad pair is documentation.
    • Use Testcontainers with pinned images and the same migrations as production for constraints, transactions, and outbox behavior. Do not let @DataJpaTest silently replace the container with an embedded database.
    • Keep end-to-end journeys few, data-isolated, and free of Thread.sleep. Poll with a timeout.
    • Include one security journey and one negative security slice. Do not disable the resource server in the profile the integration tests use.
    • Publish pacts from CI as the application version you will deploy, not from a laptop.
    • Delete or rewrite tests that verify mock call order instead of observable behavior.
    • When a Module 5 design includes a saga, map each transition to a layer. Compensation that exists only in a diagram has no test.

    Common Mistakes and Anti-Patterns

    Only end-to-end tests. Every failure is a cluster investigation. The suite is too slow to run per commit, so people stop running it. The replacement is the pyramid above, with two journeys left at the top.

    Only unit tests with mocks of the HTTP client. The mock returns whatever the test just specified. A provider rename is invisible. The replacement is a pact that executes the real client against the mock server generated from the file, plus provider verification.

    Pacts that equal-match entire bodies. Any additive field breaks every consumer. Match the fields you read. Additive changes stay compatible, which is the same compatibility story as API versioning.

    H2 standing in for PostgreSQL on a test whose point is SQL. Partial indexes, conflict targets, and lock behavior differ. Use the engine you ship.

    @SpringBootTest for every class. The context takes seconds to minutes and couples unrelated tests to the full configuration. Use it when you need the full filter chain or the full outbox. Use slices and unit tests otherwise.

    Shared mutable staging data. “The order we created yesterday” disappears. Prefix ids with the run id or use an ephemeral namespace.

    Disabling security in tests and calling that integration. The production bug is a missing matcher. A profile that permitAlls will not see it. Use jwt() and at least one deployed 401.

    Provider states with side effects outside the test. A state that calls a real acquirer or sends a real email will do that in CI. States set local rows and stubs only.

    Troubleshooting and Design Review

    Symptom: provider verification fails with “state not found.” Cause: the @State string does not exactly match the pact’s given, including spaces and capitalization. Fix: copy the string. Do not fuzzy-match states.

    Symptom: consumer test passes and production checkout cannot parse the body. Cause: the consumer test never called CheckoutOrderClient, or it parsed JSON inside the test instead of through the client. Fix: the assertion goes through the production client class.

    Symptom: @DataJpaTest passes locally and production rejects a duplicate. Cause: embedded database replacement is still on, or the test schema is ddl-auto=create while production is missing the migration. Fix: Replace.NONE, pinned Postgres, Flyway migrations applied in the test.

    Symptom: Testcontainers fails with a Docker error in CI only. Cause: the runner has no daemon, or the image pull is blocked. Fix: provide Docker or a supported remote runtime. Do not swap in H2 for the constraint test and leave a comment.

    Symptom: end-to-end fails one run in five at the Kafka assertion. Cause: a fixed sleep. Fix: poll the effect (entitlement row or a test consumer) until a timeout, then fail with the last observed state.

    Symptom: can-i-deploy always succeeds. Cause: verification results are not published, or the deploy step uses a version string that does not match the verified version. Fix: one version identifier from build to broker to deploy.

    Design review. Which bugs are unit tests, which are pacts, which need a real database, which need a cluster? Is there a consumer test that executes the client? Is H2 asked to prove PostgreSQL? How many end-to-end journeys are there, and which security negative is included? What happens to a breaking JSON rename before production?

    Interview Questions and Answers

    Q1. Why is a test pyramid still the right budget after you split a monolith?

    The cost of a test grows with the number of moving parts, and the feedback slows with it. Domain rules fail the same way in a monolith or a service, so they stay unit tests. What the split adds is the interaction between deployables, which unit tests with mocks do not see. You pay for that with a smaller number of contract tests and a still smaller number of deployed journeys. Replacing the pyramid with “we only trust end-to-end” makes every rule change wait on a cluster. Replacing it with “we only trust unit tests” lets the JSON contract rot. The pyramid is how you spend the slow tests on the risks that need them.

    Q2. What does consumer-driven mean, in contrast to publishing OpenAPI and hoping?

    The consumer writes the expectations it actually codes against, and publishes them. The provider’s pipeline replays those expectations against its running service. OpenAPI is still valuable as the provider’s published description and as a review artifact. It does not, by itself, fail the provider build when checkout’s parser stops matching. Spring Cloud Contract is the provider-driven cousin: the provider authors the contract and publishes stubs. Use that when the provider team is the authority and consumers are numerous. Use Pact-style consumer-driven contracts when a specific consumer’s required fields are the compatibility risk you keep missing.

    Q3. What is the difference between a Pact consumer test and a provider verification?

    The consumer test generates or checks a pact and runs the consumer’s client against Pact’s mock server, so the client’s parsing is real. The provider verification loads that pact from a broker or a file, sets up a @State, and sends the recorded request to the real provider. Both sides should fail on a breaking change, at different times: the consumer when its client diverges from its own pact, the provider when its implementation diverges from the published pacts. A pact file that neither side executes is a PDF.

    Q4. Why does the consumer test have to call the real client?

    Because the bug you are buying the test for is in the client: the path, the header, the field name, the type. If the test builds a PactDslJsonBody and asserts on the builder, the production RestClient call can still read state while the pact says status. The mock server is only useful if the code under test is the code you will deploy.

    Q5. When is Testcontainers the right double, and when is it waste?

    Use it when the behavior under test belongs to the real system: a unique constraint, a transaction plus outbox, a Kafka record’s key, a migration. Do not use it to check that a negative price throws, or to explore fifty controller branches. Those do not need a container and they make the suite slow enough that people skip it. Pin the image, run your real migrations, and stop @DataJpaTest from replacing Postgres with an embedded database.

    Q6. What is wrong with H2 in a repository test for a PostgreSQL service?

    H2 does not implement the same indexes, isolation quirks, or SQL dialect. A test can pass because H2 coerced a type or ignored a clause PostgreSQL will reject, or it can fail on a PostgreSQL-only statement you then delete, taking the production migration with it. H2 is acceptable for a mapping smoke test you have labeled as such. It is not evidence that the unique constraint you will rely on for idempotency exists.

    Q7. How many end-to-end tests should checkout have, and which ones?

    A small set of journeys: a successful purchase through the gateway that creates the entitlement, a business decline that does not, and an authentication or authorization failure that writes no order. Add a journey only when a class of wiring bug has escaped the lower layers more than once. Do not add one per SKU or per country. Those multiply runtime and flake rate without multiplying the wiring you are proving. Lower layers own the branch coverage.

    Q8. How do you test a saga without a forty-minute suite?

    Unit-test the state machine transitions, including compensation. Contract-test each event and command payload, including the message pact for the topic. Integration-test the outbox so a rollback does not publish. End-to-end one happy path and one forced compensation. A test that requires every downstream service to be deployed for every transition will be skipped the first time a sandbox is down, which is the week the compensation bug ships.

    Q9. A provider verification passes and production still breaks. Where do you look?

    The production route may not be the controller the test hit (gateway rewrite, a different profile, security off in the test). The pact may not include the header the consumer sends, so the test called an unauthenticated path and production does not. The consumer in production may be an older version than the pact you verified, which is a can-i-deploy failure if you had asked. Or the break is not the contract: a timeout, a network policy, a bad secret. Those are the end-to-end layer. A green pact does not claim the cluster is wired.

    Q10. How should a Module 5 e-commerce or notification design state its test strategy?

    Name the layers and the risks. E-commerce: domain rules as unit tests, HTTP pact between storefront and order, message pact for OrderAuthorized, Testcontainers for the idempotency unique key and the outbox, two or three deployed journeys. Notification: unit tests for template rendering, a contract on your accept API, a message contract to the worker, stub-server tests for provider status codes, and a sandbox send that is not on the per-commit path. A case study that says “we will have integration tests” has not chosen. The reviewer should be able to point at a renamed field and say which pipeline goes red.

    Hands-On Exercises

    Exercise 1: Strategy for order, checkout, and the broker

    Problem. Write the test strategy for checkout calling POST /orders and fulfillment consuming OrderAuthorized.

    Tasks.

    1. List five risks and assign each to a layer. Include one risk that must not be an end-to-end test.
    2. Draft the consumer pact interactions: success and one business conflict the client handles. Mark which fields are exact and which are typed.
    3. Name the provider state and what it inserts. State how leftover rows are avoided.
    4. Specify the Testcontainers test that proves a duplicate order id fails in PostgreSQL, including how you stop an embedded database from taking over.
    5. Specify one message assertion: topic, key, and “no record when the transaction rolls back.”
    6. List the end-to-end journeys and the one security case.
    7. Say when can-i-deploy runs and what version string you pass.

    Deliverables. The risk table, the pact sketch, the container plan, and the journey list.

    Acceptance. A renamed status field fails a provider or consumer test you named, without a cluster. A missing unique index fails a PostgreSQL test you named. The end-to-end list has fewer than five journeys.

    Exercise 2: Review a suite proposal

    The proposal deletes unit tests “because only E2E is real,” mocks KafkaTemplate and calls that the outbox test, matches full JSON bodies in every pact, runs repository tests on H2, and disables Spring Security in the test profile. Write the review. For each item name the bug that will escape and the replacement from this lesson.

    Rubric.

    Assessment areaWeight
    Layer choice and cost25%
    Consumer-driven contract shape25%
    Testcontainers and real SQL or broker behavior20%
    End-to-end scope and flakes15%
    Security and negative cases15%

    Lesson Summary

    Split tests by the bug they can catch. Domain rules stay in unit tests. HTTP status, JSON, and matcher security stay in slices. Unique constraints, outbox atomicity, and Kafka keys stay in Testcontainers against pinned engines and real migrations. The interaction between checkout and order is a consumer-driven pact: the consumer runs its client against Pact’s mock, publishes the pact, and the provider verifies it with a deterministic @State before can-i-deploy. End-to-end journeys are few: a success, a decline, a bad token. H2, full-body equality matchers, mocks that never execute the client, and a security profile that permits all are how green suites ship broken contracts. The same map is what an e-commerce or notification case study owes its reviewer. A saga’s compensation is a unit test of the state machine plus one deployed journey, not a hope that the cluster will be up.

    Further Reading

    Ready to mark this lesson complete?Track your journey across the entire course.