Contract testing when you can run the real services
Contract testing came from a real constraint. You have twelve services. Testing service A against the real service B means deploying B, and its database, and the two services B depends on — in CI, per commit. In 2016 that was somewhere between painful and impossible, so the industry found a smarter answer: record the interactions A expects from B as a contract, verify A against that contract, and separately verify that B satisfies it. Both sides test alone, and if both pass, they're compatible.
It's a genuinely clever piece of engineering. It's also a workaround for an infrastructure limitation, and that limitation has weakened considerably. So it's worth asking which parts of contract testing were solving the infrastructure problem and which parts were solving a real design problem — because the answers are different, and the second part is the part you keep.
What contract testing actually gives you
- Isolation: each service's tests run without the others, so a failure points at one team's code.
- Speed: no orchestration, no waiting for a fleet of services to become ready.
- Explicit expectations: the contract is a written artifact stating what a consumer relies on, which is genuinely valuable design documentation.
- Provider safety: the provider can verify it hasn't broken any consumer before deploying, including consumers whose teams it has never spoken to.
The first two are infrastructure workarounds. The last two are real design value, and no amount of cheap compute replaces them.
The gap contracts can't close
The honest weakness: a contract is a shared fiction, and both sides can satisfy it while the real integration fails.
The provider satisfies the contract for the states the contract describes. Production has states nobody wrote down. The contract says a 200 returns a user object with these fields; it doesn't say what happens when the user has 40,000 orders and the endpoint times out, or when a field is technically a string but contains a date in a format the consumer parses wrong, or when the provider added rate limiting last week.
- Timing and timeouts. Contracts describe payloads, not latency. A provider that got 10× slower still satisfies every contract.
- Semantic drift. The provider changes what a field means without changing its type. Every contract passes; the consumer's behaviour is now wrong.
- Statefulness. Real interactions are sequences — create, then read, then update. Contracts typically verify request-response pairs in isolation.
- Emergent behaviour. Retries amplifying under load, connection pools exhausting, a cascade when one service slows. Nothing in a contract models this.
- Contract rot. Contracts are written by consumers, and consumers write down what they think they depend on, which is not the same as what they actually depend on.
What changed about the constraint
The original assumption was that a real environment costs minutes and real money. On a microVM platform, creating an isolated machine is about 179ms at p50 through snapshot-restore. Twelve services created concurrently are live in under a second of wall clock.
That doesn't make orchestration free — you still have to build images, wire up service discovery, seed databases, and decide what to stub at the edges. But the dominant cost moves from 'provisioning is prohibitive' to 'setup is some work, once.' And bake that setup into a snapshot and it's warm on every subsequent run.
from concurrent.futures import ThreadPoolExecutor
from pandastack import Sandbox
# Each service in its own microVM: own kernel, own network namespace, own
# port space. No port collisions between services, no docker-compose
# networking to reason about, and one crashed service takes down one VM.
SERVICES = [
("users", "snap-users-warm"), # snapshots baked with deps
("orders", "snap-orders-warm"), # installed and the DB seeded,
("payments", "snap-payments-warm"), # so this is a restore, not a build
("inventory", "snap-inventory-warm"),
]
def boot(spec):
name, snapshot = spec
sbx = Sandbox.create(template=snapshot, ttl_seconds=1800,
metadata={"role": "svc", "name": name})
sbx.exec("systemctl start app.service")
return name, sbx, sbx.network.ip # each VM has its own address
# ~179ms p50 per create, run concurrently -- the fleet is live in well
# under a second, which is the change that makes this practical at all.
with ThreadPoolExecutor(max_workers=len(SERVICES)) as pool:
fleet = dict((n, (s, ip)) for n, s, ip in pool.map(boot, SERVICES))
# Wire discovery by injecting the real addresses into the service under test.
users_sbx, _ = fleet["users"]
env = " ".join(f"{n.upper()}_URL=http://{ip}:8080" for n, (_, ip) in fleet.items())
result = users_sbx.exec(f"cd /app && {env} npm run test:integration",
timeout_seconds=900)
print(result.exit_code, result.stdout[-2000:])
for _, (sbx, _) in fleet.items():
sbx.destroy() # ttl_seconds is the backstop when CI cancels the jobKeep both, for different jobs
The useful conclusion isn't 'contract testing is obsolete.' It's that the two approaches answer different questions, and cheap environments change the ratio rather than eliminating one side.
Keep contracts for the ownership problem
The strongest thing contracts do has nothing to do with test execution: they answer 'who is allowed to break whom, and how would we know?' A provider running consumer contracts before deploying gets a list of exactly which consumers a change would break — including teams it has never met. That's an organisational mechanism, and no amount of integration testing provides it, because the provider's own integration tests only cover the consumers the provider knows about.
Contracts are also the right tool across organisational boundaries. You cannot spin up a partner's service in your CI, and you shouldn't try.
Add real integration for the dimensions contracts can't describe
Run the real fleet for multi-step workflows across three or more services, for failure injection — kill a service mid-transaction and see what the others do — for anything where timing matters, and for the pre-release smoke test that catches the class of problem nobody wrote down.
This is where per-service microVMs help beyond raw speed. Each service gets its own kernel and network namespace, so there are no port collisions and no shared network stack to confuse accounting. And killing one is a genuine hard failure of one machine rather than a process disappearing from a shared host — which makes fault injection realistic instead of approximate.
A practical split
- Unit tests: business logic, mocked everything. Milliseconds. The bulk of your suite.
- Contract tests: per-service, on every commit. Fast, and the provider verification step is the gate that stops a team breaking a consumer they've never spoken to.
- Real integration: a full fleet on merge to main, plus on demand for changes touching service boundaries. Slower, and now affordable enough to run more than nightly.
- Failure injection: weekly or before a release. Kill services, add latency, partition the network, and find out what your retry policies actually do.
- Production verification: because none of the above is production. Contracts and integration tests reduce the number of surprises; they don't eliminate them.
The mistake to avoid in either direction is treating this as ideological. Teams that dropped contracts entirely because environments got cheap lost the provider-side safety net and found out when a provider broke a consumer nobody remembered existed. Teams that kept only contracts kept shipping integration failures that every contract passed. The tooling was built around a constraint that has weakened — so revisit the ratio, and keep the part that was never about infrastructure at all.
Frequently asked questions
Is contract testing still necessary if I can run real services cheaply?
Partly. Two of contract testing's four benefits — isolation and speed — were workarounds for the cost of provisioning real environments, and that cost has fallen substantially. The other two are real design value that cheap compute does not replace: an explicit written artifact stating what a consumer depends on, and provider-side verification that a change will not break any registered consumer, including consumers whose teams the provider has never spoken to. That second one is an organisational mechanism rather than a testing technique, and nothing else provides it.
What do contract tests fail to catch?
Anything in a dimension the contract does not describe. Contracts specify payloads, not latency, so a provider that became ten times slower still satisfies every contract. They do not catch semantic drift, where a field keeps its type but changes meaning. They typically verify request-response pairs in isolation rather than stateful sequences. They cannot model emergent behaviour such as retry amplification under load, connection pool exhaustion, or cascading slowdowns. And they suffer contract rot, because consumers write down what they believe they depend on, which is not the same as what they actually depend on.
How do ephemeral environments change integration testing?
They change the cost assumption the whole approach was built around. Creating an isolated machine through snapshot-restore is roughly 179 milliseconds, so a fleet of a dozen services created concurrently is live in under a second of wall clock. Orchestration is still work — images, service discovery, seeded data, deciding what to stub at the edges — but that work can be baked into snapshots once and restored warm on every run. The practical result is that full-fleet integration tests can run on every merge rather than nightly.
Why run each service in its own microVM instead of containers on one host?
Three practical reasons for testing specifically. Each service gets its own network namespace and port space, so there are no port collisions and no shared network stack complicating per-service accounting. Each has its own kernel, so a service that exhausts resources affects only itself rather than skewing every other service's behaviour on the same host. And killing one is a genuine hard failure of a whole machine rather than a process vanishing from a shared kernel, which makes fault injection resemble the failure you are actually simulating.
What is a sensible testing split for microservices?
Unit tests with everything mocked for business logic, forming the bulk of the suite. Contract tests per service on every commit, with provider-side verification acting as the gate that prevents one team breaking a consumer it has never spoken to. Full real-fleet integration tests on merge to main and on demand for changes touching service boundaries. Failure injection weekly or before a release, to find out what your retry policies actually do under partition and latency. And production verification, because none of the earlier layers is production — they reduce surprises rather than eliminating them.
49ms p50 cold start. Fork, snapshot, and scale to zero.