Writing Consumer Tests with Pact

9 min read

This lesson writes the consumer side of a Pact contract from scratch. By the end you will have a working consumer test that generates a real Pact file — the artefact that travels to the provider team.

Setting the scene

Order Service (consumer) calls User Service (provider) at GET /users/{id} to validate a customer before creating an order. The consumer test defines exactly what Order Service needs: an id, name, and email — nothing else. The provider is free to return additional fields, but those three must be present and match the expected types. This is the core of consumer-driven contract testing: the consumer specifies its minimum requirements, not the provider's full API shape.

The consumer test in full

Here is the complete test class. Every annotation and method is explained immediately below.

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "UserService", port = "8090")
class UserServiceConsumerPactTest {
 
    @Pact(consumer = "OrderService")
    public RequestResponsePact userExists(PactDslWithProvider builder) {
        return builder
            .given("user 42 exists")
            .uponReceiving("a GET request for user 42")
                .path("/users/42")
                .method("GET")
            .willRespondWith()
                .status(200)
                .headers(Map.of("Content-Type", "application/json"))
                .body(LambdaDsl.newJsonBody(body -> {
                    body.numberType("id", 42);
                    body.stringType("name", "Alice");
                    body.stringMatcher("email", ".+@.+\\..+", "alice@test.com");
                }).build())
            .toPact();
    }
 
    @Test
    @PactTestFor(pactMethod = "userExists")
    void shouldReturnUserWhenFound(MockServer mockServer) {
        // Point your HTTP client at the Pact mock server
        UserServiceClient client = new UserServiceClient(mockServer.getUrl());
 
        User user = client.getUser(42L);
 
        assertThat(user.getId()).isEqualTo(42L);
        assertThat(user.getName()).isEqualTo("Alice");
        assertThat(user.getEmail()).isNotBlank();
    }
}

What each piece does:

  • @PactConsumerTestExt — JUnit 5 extension that wires up the Pact mock server and handles Pact file generation after the test completes
  • @PactTestFor(providerName, port) — names the provider and sets the port for the embedded mock server
  • @Pact(consumer = "OrderService") — marks the method that builds a contract interaction; the consumer name appears in the generated filename
  • .given("user 42 exists") — provider state: a string label that tells the provider test what precondition to set up before replaying this interaction
  • .uponReceiving(...) — human-readable interaction description; appears in the Pact file and in test output to help identify failures
  • .path("/users/42").method("GET") — the exact request the consumer will send
  • .willRespondWith().status(200).body(...) — the response the consumer expects and that the mock server will return during the test

Matchers — the key to durable contracts

Matchers are what separate brittle contracts from useful ones. Without them, Pact encodes exact values into the JSON file. If the provider later returns user ID 99 instead of 42 — a completely valid response — provider verification fails because the exact value no longer matches. Matchers shift the assertion from value equality to structural compatibility.

The four matchers you will use most:

// 1. Type matcher — any number, not just 42
body.numberType("id", 42);
 
// 2. String type matcher — any string, not just "Alice"
body.stringType("name", "Alice");
 
// 3. Regex matcher — email-shaped string
body.stringMatcher("email", ".+@.+\\..+", "alice@test.com");
 
// 4. Array containing — at least one element of this shape
body.arrayContaining("tags", arr ->
    arr.stringType("type", "customer")
       .numberType("priority", 1)
);

The example value you pass to numberType, stringType, and stringMatcher is used only inside the mock server during the consumer test. It does not constrain the provider's real response. The generated Pact file records the matcher rule — type, regex, or equality — not the literal value.

Handling the "user not found" case

A contract should cover the paths your consumer actually exercises. If Order Service handles a missing user by catching an exception and returning a 404, that error path needs its own interaction:

@Pact(consumer = "OrderService")
public RequestResponsePact userNotFound(PactDslWithProvider builder) {
    return builder
        .given("user 999 does not exist")
        .uponReceiving("a GET request for non-existent user 999")
            .path("/users/999")
            .method("GET")
        .willRespondWith()
            .status(404)
            .headers(Map.of("Content-Type", "application/json"))
            .body("{\"error\": \"User not found\"}")
        .toPact();
}
 
@Test
@PactTestFor(pactMethod = "userNotFound")
void shouldThrowWhenUserNotFound(MockServer mockServer) {
    UserServiceClient client = new UserServiceClient(mockServer.getUrl());
    assertThatThrownBy(() -> client.getUser(999L))
        .isInstanceOf(UserNotFoundException.class);
}

Each interaction is its own @Pact method tied to its own @Test via pactMethod. This keeps failures unambiguous — when the 404 interaction breaks in provider verification, the failure names exactly that interaction rather than a catch-all failure across a combined method.

Where the Pact file lands

After running the test suite, Maven writes the contract to target/pacts/OrderService-UserService.json. The file structure encodes each interaction alongside its matchers:

"matchingRules": {
  "body": {
    "$.id":    { "matchers": [{ "match": "type" }] },
    "$.name":  { "matchers": [{ "match": "type" }] },
    "$.email": { "matchers": [{ "match": "regex", "regex": ".+@.+\\..+" }] }
  }
}

Once the file exists, publish it to the Pact Broker so the provider team can download and verify against it:

# Publish via Maven plugin
mvn pact:publish \
  -Dpact.broker.url=https://my-broker.pactflow.io \
  -Dpact.broker.token=$PACT_BROKER_TOKEN \
  -Dpact.consumer.version=$GIT_SHA

Tagging the publication with $GIT_SHA is essential — it lets the Pact Broker track exactly which version of the consumer produced this contract, which is the information can-i-deploy queries later.

⚠️ Common mistakes

  • Testing the mock, not the consumer's behaviour. The purpose of the @Test method is to verify that YOUR HTTP client parses the response correctly and maps it to your domain object. If your test only asserts user != null, it adds no value. Assert on individual fields — getId(), getName(), getEmail() — to confirm that your deserialisation logic actually works.
  • Writing one giant @Pact method with every interaction. Each distinct consumer-provider interaction should be its own @Pact method paired with its own @Test. Grouping them makes failures ambiguous (which interaction caused it?) and means any single change forces full regeneration of a combined method.
  • Forgetting to point your HTTP client at mockServer.getUrl(). If your client is hardcoded to a base URL set elsewhere in the Spring context, the mock server receives nothing and returns nothing — yet the test passes because your assertions run against null or stale data. Always construct your client using mockServer.getUrl() as the base URL inside the test method.

🎯 Practice task

  1. Create a UserServiceConsumerPactTest class in a sample project. Write the @Pact method for GET /users/{id} using at minimum three fields with matchers (not exact values). Run the test and confirm the Pact file appears under target/pacts/.
  2. Open the generated Pact JSON. Find the matchingRules section and verify that your matchers are present. If you used stringType, confirm the matcher entry reads "match": "type" — not "match": "equality".
  3. Add a second interaction for the 404 case — "given user 999 does not exist". Write the corresponding @Test and confirm your HTTP client throws the right exception type rather than returning null.
  4. Deliberately break the consumer test: change mockServer.getUrl() to a hardcoded URL that does not exist. Run the test. Read the error you get and explain why it confirms the client is actually being exercised, rather than being bypassed.
  5. Add the pact-jvm-provider-maven plugin to your pom.xml and configure it with a placeholder broker URL. Run mvn pact:publish and read the error output — identify what would need to change (URL, token, version tag) for the publish to succeed.

The next lesson covers the provider side: how to download that Pact file from the broker, set up provider states, and run verification against a real running service.

// tip to track lessons you complete and pick up where you left off across devices.