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")
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)
}
}
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"))
}
}
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
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"
)
}
}
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()andnumberType()for shape matching. Use exact values only when the specific value matters to your consumer logic. -
Type changes are always breaking. Changing
totalAmountfromNumbertoStringfails 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
@PactTestForport 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)