DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Contract Testing in Kotlin: A Step-by-Step Workshop with Pact and Spring Cloud Contract

What We're Building

By the end of this workshop, you'll have a working consumer-driven contract test suite in Kotlin. You'll know how to write a consumer contract with Pact, verify it on the provider side, and wire it into your CI pipeline so broken API changes never reach production again.

Let me show you a pattern I use in every project with more than two services talking to each other.

Prerequisites

  • Kotlin project with Gradle
  • JUnit 5
  • Familiarity with Spring Boot (helpful, not required)
  • A service that calls another service over HTTP

Add these to your build.gradle.kts:

testImplementation("au.com.dius.pact.consumer:junit5:4.6.5")
testImplementation("au.com.dius.pact.provider:junit5spring:4.6.5")
Enter fullscreen mode Exit fullscreen mode

Step 1: Write the Consumer Contract

The consumer defines what it expects. This is the key insight — the service calling the API describes exactly which fields it depends on.

@ExtendWith(PactConsumerTestExt::class)
@PactTestFor(providerName = "order-service", port = "8080")
class OrderClientContractTest {

    @Pact(consumer = "payment-service")
    fun orderDetailsPact(builder: PactDslWithProvider): V4Pact {
        return builder
            .given("order 123 exists")
            .uponReceiving("a request for order details")
            .path("/api/orders/123")
            .method("GET")
            .willRespondWith()
            .status(200)
            .body(PactDslJsonBody()
                .stringType("orderId", "123")
                .numberType("totalAmount", 99.99)
                .stringType("currency", "USD")
            )
            .toPact(V4Pact::class.java)
    }

    @Test
    @PactTestFor(pactMethod = "orderDetailsPact")
    fun `should parse order response correctly`(mockServer: MockServer) {
        val client = OrderClient(mockServer.getUrl())
        val order = client.getOrder("123")
        assertThat(order.orderId).isEqualTo("123")
        assertThat(order.totalAmount).isEqualTo(99.99)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it. Pact generates a JSON contract file in build/pacts/. That file is the agreement between your two services.

Step 2: Verify on the Provider Side

Now the order-service team picks up that contract and verifies their API satisfies it:

@Provider("order-service")
@PactBroker(host = "pact-broker.internal", scheme = "https")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderProviderVerificationTest {

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider::class)
    fun verifyPact(context: PactVerificationContext) {
        context.verifyInteraction()
    }

    @State("order 123 exists")
    fun setupOrder() {
        orderRepository.save(Order(id = "123", totalAmount = 99.99, currency = "USD"))
    }
}
Enter fullscreen mode Exit fullscreen mode

The @State annotation is the setup hook. When the contract says "given order 123 exists", this method runs first. Here's the minimal setup to get this working — just match the state string exactly.

Step 3: Wire It Into CI

Add two steps to your pipelines. Consumer side publishes, provider side verifies:

# Consumer pipeline
- name: Publish contracts
  run: ./gradlew pactPublish
  env:
    PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}

# Provider pipeline
- name: Verify contracts
  run: ./gradlew pactVerify
Enter fullscreen mode Exit fullscreen mode

Use can-i-deploy from the Pact Broker as a deployment gate. This single command tells you whether your version is safe to ship.

Alternative: Spring Cloud Contract

If your whole stack is Spring and Kotlin, Spring Cloud Contract cuts out the broker entirely. Contracts live as Kotlin DSL files in the producer's repo:

contract {
    description = "should return order by id"
    request {
        method = GET
        url = url("/api/orders/123")
    }
    response {
        status = OK
        headers { contentType = APPLICATION_JSON }
        body = body(
            "orderId" to "123",
            "totalAmount" to 99.99,
            "currency" to "USD"
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Use Pact for polyglot, multi-team systems. Use Spring Cloud Contract when you're all-in on Spring within one organization. Don't fight your ecosystem.

Gotchas

Here's the gotcha that will save you hours:

  • State strings must match exactly. "order 123 exists" in the consumer and "Order 123 exists" in the provider is a silent mismatch. Copy-paste the string.
  • Don't match on exact values unless you mean it. Use stringType() and numberType() for shape matching. Use exact values only when the specific value matters to your consumer logic.
  • Type changes are always breaking. Changing totalAmount from Number to String fails verification immediately. This is a feature, not a bug.
  • Adding fields is safe. Removing is not. New fields won't break existing consumers. Before removing a field, check the Pact Broker to see who depends on it.
  • The docs don't mention this, but the @PactTestFor port must not conflict with anything running locally. Use "0" for random port assignment in CI environments.

Conclusion

Start with your most failure-prone service boundary — the integration that caused incidents in the last six months. Add one consumer contract there. You'll catch the exact category of bug that unit and integration tests miss: the gap between what a producer sends and what a consumer expects.

The full Pact JVM docs are at docs.pact.io and Spring Cloud Contract at spring.io/projects/spring-cloud-contract.

Top comments (0)