open atlas
↑ Back to track
CI/CD pipelines CICD · 02 · 03

Contract testing and taming flaky tests

Independently deployed services break each other through APIs; consumer-driven contracts catch it before deploy without a shared integration env. And flaky tests rot trust until green stops meaning "safe to merge" — make them deterministic, not retried.

CICD Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The checkout team renames a JSON field from total_cents to amount_cents and ships at 11:00. Their tests are green, their service is healthy. At 11:04 the orders service starts throwing — it still reads total_cents, now undefined, and writes NaN to the ledger. Nobody ran a test that exercised both services together, because the only one that did lived in a nightly integration suite that takes 40 minutes and fails so often everyone ignores its email. Two separate CI failures here: there was no fast check that the orders service depended on that field, and the one slow check that might have caught it had already lost all credibility to flakiness.

Why a shared integration env is the wrong tool

By the end of this lesson you’ll know how to catch cross-service API breaks before deploy — without spinning up a shared environment — and why “just re-run it” is the symptom of a failing test strategy, not a fix.

When two services deploy on the same release train, an end-to-end test that spins up both is honest: it tests exactly what ships. The moment they deploy independently — the normal state of any microservice estate — that honesty evaporates. A full integration environment must run every service at once, which makes it slow to provision, expensive to keep current, and a flake magnet: any one of N services being mid-deploy fails the suite for reasons unrelated to your change. Worse, it tests the wrong question. You don’t care whether all services are mutually compatible in some staging snapshot; you care whether the version you’re about to deploy won’t break anyone who depends on it, and won’t be broken by anyone it depends on.

Consumer-driven contract testing answers exactly that question without ever running two services in the same process. The insight: the only part of a provider’s API that matters is the part its consumers actually use. So let each consumer record, as a byproduct of its own unit tests, the concrete request/response pairs it relies on — that recording is the contract. Each request/response pair is a single interaction: “when I GET /orders/42, I expect a 200 with a body containing amount_cents as a number.” The consumer’s tests run against a mock provider that replays those responses, so they stay fast and isolated. Then the provider fetches every consumer’s contract and, in its own pipeline, verifies it can satisfy each interaction against the real handler. The checkout rename above would have failed checkout’s provider verification the instant it ran orders’ contract — in checkout’s own CI, before merge, with no orders service deployed anywhere.

The broker and can-i-deploy

The piece that turns two pipelines into a coordinated gate is the broker (the Pact Broker, or its hosted form PactFlow). The consumer publishes its contract there, tagged with its version and the branch/environment it’s heading to. The provider’s pipeline pulls the relevant contracts from the broker, verifies them, and publishes the verification result back. The broker thus holds a matrix: for every consumer version and provider version, has this pair been verified to be compatible?

That matrix is what powers can-i-deploy — the actual deploy gate. Before you release version X of a service to an environment, you ask the broker can-i-deploy --pacticipant orders --version X --to-environment production, and it answers yes only if every service orders will talk to in production has a recorded, passing verification against orders’ contract at version X (and vice versa). It is a query over already-collected results, not a fresh test run, so it is instant. This is the difference from a shared env: instead of hoping a staging snapshot reflects production, the broker knows which exact versions are compatible because each side proved it in its own fast pipeline.

Contract tests are not a replacement for schema or OpenAPI checks — they answer a narrower, sharper question. An OpenAPI diff tells you the spec changed in a breaking way; a contract tells you whether a breaking change actually breaks a real consumer. Renaming a field nobody reads is a breaking schema change but breaks zero contracts, so it deploys freely. Tightening a field three consumers parse passes the schema (still valid JSON) but fails their contracts. Use schema linting as a cheap first filter; use contracts to gate on real coupling.

Why this works

Contract testing is socio-technical, not just a tool. The contract is a negotiation between two teams, so a provider that “fixes” a failing verification by quietly editing the consumer’s contract has defeated the point — the contract must be owned by the consumer and changed only by agreement. Bi-directional contract testing (PactFlow) relaxes this by letting a provider supply its OpenAPI spec instead of running consumer-generated tests, trading some fidelity for less coupling between the teams’ test suites.

Flaky tests: the trust-killer

What does a pipeline look like when flakiness has taken hold? Exactly the hook: a nightly suite that fails so often its emails go unread, and two separate teams deploying independently with no shared signal. That state is recoverable — but only if you treat flakiness as a quality problem with an owner, not background noise.

A flaky test passes and fails on the same code with no change between runs. It is more dangerous than a consistently failing test, because a red that means nothing trains engineers to ignore red. Once “just re-run it” is the reflex, your pipeline has stopped being a gate: a real regression hides among the noise, people merge on a hunch, and the whole value of CI — that green means safe to merge — is gone. Treating flakiness as a quality problem in its own right, with a budget and an owner, is the senior move; tolerating it is how a test suite dies.

The causes are a short, recurring list, and naming the cause dictates the fix.

CauseSymptomDeterministic fix
Timing / raceFails under CI load, passes locally; sleep(100) “fixes” itWait on a condition (poll for the element/state), never a fixed sleep; control async ordering
Real clock / dateBreaks at midnight, month-end, or across DSTInject a fake clock; freeze time in the test
Shared state / order dependencePasses alone, fails when the suite is shuffled or run in parallelIsolate per-test state (fresh DB/txn rollback); randomize order to surface it
Real network / external serviceFails when a third party is slow or downStub the boundary; pull the integration to a contract test
Test pollutionTest B fails only after Test A ran (leaked global/singleton/file)Reset globals in teardown; no cross-test fixtures

The workflow around these is three moves. Detect: re-run the suite on a schedule and track pass/fail per test over time, so flakiness shows up as a dashboard, not a vibe. Quarantine: when a test flakes, mark it as quarantined so it still runs and reports but does not gate the merge — and file a ticket. This protects the pipeline’s signal while you fix the test, without the worse alternative of deleting it. Fix: drive it to determinism using the table above; quarantine is a holding pen, not a graveyard, and a quarantine list that only grows is itself a failure.

Together, detect → quarantine → fix form the only loop that actually shrinks the flake count; skipping “fix” means the quarantine list grows until you’ve quietly dropped coverage on half the codebase.

Bounded retries are the most misused tool here. Auto-retrying a failed test until it passes does not fix flakiness — it hides it, and it will hide real intermittent bugs (a genuine race in production code) just as readily. The only defensible use is a small, bounded retry (e.g., 2 attempts) on end-to-end tests only, where some environmental flakiness is irreducible, and only when every retry is logged and counted so the flake rate stays visible. Never retry a unit or integration test: those should be deterministic, and a retry there is a confession you skipped the fix.

Pick the best fit

A unit test for an order-discount calculator passes ~95% of CI runs and fails the rest, on unchanged code. What do you do?

Quiz

A provider renames a JSON field that one consumer reads. Which check is most likely to catch it before deploy, and why?

Quiz

A unit test fails ~1 in 20 runs on unchanged code. Your teammate wires up an automatic 3x retry so the build goes green. What's the senior objection?

Recall before you leave
  1. 01
    Walk through consumer-driven contract testing end to end, and say why it beats a shared integration environment.
  2. 02
    Why are flaky tests dangerous, and what is the right response — including when retries are acceptable?
Recap

Independently deployed services break each other across APIs, and a full integration environment is the wrong tool to catch it: slow, flake-prone, and it tests whether some staging snapshot is mutually compatible rather than whether the version you’re about to ship is safe. Consumer-driven contract testing answers the sharp question instead — each consumer records the request/response pairs it actually uses as a contract, runs its own tests against a mock, and publishes the contract to a broker; the provider pulls every contract and verifies it against its real handler in its own pipeline, publishing the result back. The broker’s verification matrix powers can-i-deploy, an instant query that gates a release only when every relevant pair is proven compatible. Contracts complement schema/OpenAPI linting: the schema tells you the spec changed, the contract tells you whether it breaks a real consumer. The second half is trust: a flaky test that passes and fails on the same code trains people to ignore red, and once “just re-run it” is the reflex, green no longer means “safe to merge.” Detect flakiness with scheduled re-runs and a dashboard, quarantine a flaky test so it still reports but doesn’t gate while you file a ticket and fix it, and make it deterministic by killing the cause — fake clock, isolated state, controlled ordering, stubbed boundaries. Reserve bounded retries for end-to-end tests alone, always logged; a retry on a unit test only hides a removable defect, and can hide a real bug with it. Now when you see a field rename ship and break a downstream service at 11:04, you’ll know what was missing — a contract that would have caught it in checkout’s own CI, before the merge, before the deploy.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.