The Problem With Integration Tests at Scale
About two years ago, our payment platform had grown to around a dozen microservices: a checkout orchestrator, a card tokenization service, a settlement engine, a refund processor, and several provider adapters for Stripe, Adyen, and a local acquirer. We had integration tests for all of them. Hundreds of them. And they were lying to us.
The issue wasn't that the tests were wrong. Each one correctly verified behavior against a running instance of the downstream service. The issue was timing. Our checkout service would test against version 2.3 of the tokenization service in CI, but by the time we deployed on Thursday, tokenization had shipped 2.4 with a renamed field in the response body. The integration test suite was green. The 3 AM PagerDuty alert was very much red.
This happened three times in two months. Each time, a provider-side change slipped through because the consumer's integration tests were pinned to a stale version. We needed something that could tell us, before deployment, whether the services we depend on still speak the same language we expect.
Integration tests verify behavior at a point in time. They don't guarantee that the contract between two services will hold when either side deploys independently. In payment systems, that gap can mean failed charges and lost revenue.
Consumer-Driven Contracts: The Core Idea
Consumer-driven contract testing flips the usual model. Instead of the provider defining the API and consumers hoping it doesn't change, each consumer publishes a contract describing exactly what it needs from the provider. The provider then verifies that it still satisfies every consumer's expectations. If it doesn't, the build breaks — on the provider side, before it ships.
The flow looks like this:
writes test
generated
stores contract
verifies contract
yes / no
The consumer test doesn't hit the real provider. It runs against a mock that Pact spins up, and the interactions get recorded into a JSON contract file (the "pact"). That file gets published to a Pact Broker. On the provider side, a verification test pulls down every consumer's pact and replays the requests against the real provider, checking that the responses match what the consumer expects.
Setting It Up in Go
We run most of our payment services in Go, so here's roughly what the consumer side looks like. Say our checkout service calls the tokenization service to exchange a card number for a token:
// checkout_consumer_test.go
func TestCheckoutTokenizationPact(t *testing.T) {
mockProvider, err := consumer.NewV4Pact(consumer.MockHTTPProviderConfig{
Consumer: "CheckoutService",
Provider: "TokenizationService",
})
require.NoError(t, err)
err = mockProvider.
AddInteraction().
Given("a valid card number").
UponReceiving("a request to tokenize a card").
WithCompleteRequest(consumer.Request{
Method: "POST",
Path: "/v1/tokenize",
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: map[string]interface{}{
"card_number": "4111111111111111",
"exp_month": 12,
"exp_year": 2027,
},
}).
WithCompleteResponse(consumer.Response{
Status: 200,
Headers: map[string][]string{
"Content-Type": {"application/json"},
},
Body: matchers.Map{
"token": matchers.Like("tok_abc123"),
"card_brand": matchers.Like("visa"),
"last_four": matchers.Like("1111"),
},
}).
ExecuteTest(t, func(config consumer.MockServerConfig) error {
// Call your real client code against the mock
client := tokenization.NewClient(config.URL)
resp, err := client.Tokenize(context.Background(), &tokenization.Request{
CardNumber: "4111111111111111",
ExpMonth: 12,
ExpYear: 2027,
})
require.NoError(t, err)
assert.Equal(t, "visa", resp.CardBrand)
return nil
})
require.NoError(t, err)
}
When this test runs, Pact generates a JSON contract file. The key detail: we're using matchers.Like() instead of exact values. This means the contract says "I need a token field that's a string" rather than "I need the exact string tok_abc123." That flexibility is critical — you want to verify structure, not specific test data.
Provider-Side Verification
On the tokenization service side, verification pulls the pact from the broker and replays it:
// tokenization_provider_test.go
func TestPactProvider(t *testing.T) {
verifier := provider.NewVerifier()
err := verifier.VerifyProvider(t, provider.VerifyRequest{
ProviderBaseURL: "http://localhost:8080",
Provider: "TokenizationService",
BrokerURL: os.Getenv("PACT_BROKER_URL"),
BrokerToken: os.Getenv("PACT_BROKER_TOKEN"),
PublishVerificationResults: true,
ProviderVersion: os.Getenv("GIT_COMMIT"),
StateHandlers: map[string]models.StateHandler{
"a valid card number": func(setup bool, s models.ProviderState) (models.ProviderStateResponse, error) {
// Seed test data or configure mocks for this state
return nil, nil
},
},
})
require.NoError(t, err)
}
The StateHandlers map is where you set up the preconditions for each interaction. For payment services, this usually means seeding a test merchant account or configuring a mock payment processor to return specific responses.
The Pact Broker and "Can I Deploy?"
The Pact Broker is the central piece that makes this work in CI/CD. It stores every version of every contract, tracks which provider versions have verified which consumer contracts, and — most importantly — answers the question: "Can I deploy this version of my service right now?"
We added this to our deployment pipeline:
# In CI, after tests pass but before deploy
pact-broker can-i-deploy \
--pacticipant CheckoutService \
--version $(git rev-parse HEAD) \
--to-environment production
If the tokenization service hasn't verified the latest checkout contract, the deploy stops. No guessing. No "it worked in staging." The broker knows exactly which combinations of service versions are compatible.
Use --to-environment rather than --to with a tag. Environment-based tracking in the Pact Broker gives you a clearer picture of what's actually running where, especially when you have multiple staging environments.
Integration Tests vs. Contract Tests
These aren't competing approaches — they test different things. Here's how they break down in practice:
| Aspect | Integration Tests | Contract Tests |
|---|---|---|
| What it verifies | End-to-end behavior across services | API shape and expectations between pairs |
| Speed | Slow — needs running services, databases, networks | Fast — consumer tests run against a local mock |
| Flakiness | High — network timeouts, test data drift, environment issues | Low — deterministic, no real network calls |
| Catches breaking API changes | Only if both sides are tested together at the same version | Yes — provider must verify against all consumer contracts |
| Deploy confidence | Point-in-time snapshot | Continuous — broker tracks compatibility matrix |
| Setup cost | High — docker-compose, test environments, seed data | Moderate — Pact libraries + broker infrastructure |
| Best for | Validating business flows end-to-end | Preventing interface breakage between services |
We still run integration tests, but far fewer of them. The contract tests handle the "does the API still look right?" question, and integration tests focus on "does the full payment flow actually work?" — things like verifying that a charge, capture, and settlement produce the correct ledger entries.
Versioning Contracts for Payment Provider Changes
Payment providers change their APIs more often than you'd think. Stripe ships breaking changes behind API version headers. Adyen deprecates fields across major versions. We needed our contract tests to handle this gracefully.
Our approach: each provider adapter service maintains contracts versioned by the upstream API version it targets. When Stripe ships a new API version, we create a new consumer contract that reflects the updated response shape, run it against our adapter, and only switch the API version header in production once the contract is green.
// In the consumer test for our Stripe adapter
mockProvider.
AddInteraction().
Given("Stripe API version 2025-12-01").
UponReceiving("a payment intent creation").
WithCompleteRequest(consumer.Request{
Method: "POST",
Path: "/v1/payment_intents",
Headers: map[string][]string{
"Stripe-Version": {"2025-12-01"},
},
// ...
})
This gives us a clear migration path. The old contract stays in the broker until we've fully migrated, and the can-i-deploy check ensures we never deploy a consumer that expects the new response shape against a provider still returning the old one.
Contract testing doesn't replace integration testing — it fills the gap between "these services work together right now" and "these services will still work together after independent deployments." For payment systems where a broken interface means failed transactions, that gap is too expensive to leave uncovered.
Lessons From Production
After running contract tests in our payment platform for over a year, a few things stand out:
- Start with your most painful integration point. For us, that was the checkout-to-tokenization boundary. Don't try to add contracts everywhere at once.
- Invest in good
StateHandlerson the provider side. Flaky state setup is the number one reason teams abandon contract testing. Treat them like first-class test infrastructure. - Run provider verification in CI on every commit, not just on release branches. You want to catch breakage as early as possible.
- Use the Pact Broker's network diagram. When you have 12 services, being able to visualize which consumers depend on which providers — and whether they're all compatible — is invaluable during incident response.
- Don't over-specify contracts. Test the fields your consumer actually uses, not every field the provider returns. Overly strict contracts create false failures and erode trust in the system.
Since adopting contract testing, we've had zero production incidents caused by API incompatibility between our services. The integration test suite that used to take 25 minutes now takes 8, because we removed the redundant "does the API shape match?" tests. And the deploy pipeline actually tells us when something is going to break, instead of letting us find out from customers.
References
- Pact Documentation — Consumer-Driven Contract Testing
- Pact Broker — Contract Management and Can-I-Deploy
- Pact-Go — Pact for the Go Language
- Go Standard Library — testing Package
- Pact Broker — Versioning Contracts
- Martin Fowler — Consumer-Driven Contracts
Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.