Distributed Systems Testing
Purpose
Make the system's failure behaviour something that has been observed rather than configured.
Timeouts, retry budgets, circuit breakers, fallbacks, idempotency keys and readiness probes
are all claims; until each has been exercised against the failure it exists for, the system's
resilience is a set of YAML values that have never executed.
The gap this closes is specific. Functional tests exercise the happy path against a fast,
available dependency. Load tests exercise a healthy system at volume. Neither produces the
condition that actually causes outages: a dependency that is slow rather than down, a
duplicate delivered after a broker reconnect, a node that vanishes mid-transaction.
The two failures this exists to prevent: resilience settings that provably do nothing —
a retry budget exhausted by its first attempt, a breaker that never records the failures it
was meant to count; and chaos experiments run without a
hypothesis or a blast-radius limit, which produce an incident rather than a finding.
Workflow
- Write the claim down first. "A payment gateway timing out returns 503 within 2 s and
does not double-charge." An untestable claim is a configuration you do not understand yet.
- Pick the cheapest level that can falsify it. Most claims fall at the component level
with one faulty dependency; very few need a whole environment.
- Select faults from the dependency contract and incident evidence. Include slow,
unavailable, duplicated, lost and partial outcomes where relevant; do not assume a
universal frequency ranking or that connection refusal covers a blackhole.
- Assert the observable outcome, not the mechanism: the status code, the elapsed time,
the number of times the downstream was called, the number of rows written. Asserting that
a breaker library was invoked tests the library.
- Assert the budget, not just the behaviour. Retry counts and timeouts compose across
hops; the property that matters is the total, and it is where retry storms come from.
- Promote to production only with a hypothesis and a limit — expected outcome, blast
radius, abort condition, and a way to stop.
The failure taxonomy to test against
Use this coverage menu according to the workload and failure model, not as a frequency ranking:
SLOW Dependency responds, eventually. Threads/connections
pile up behind it. Tests: does the timeout fire, is the
pool bounded, does the caller shed rather than queue?
(cascading-failures).
DUPLICATED The same message or request arrives twice. Tests: is
the effect applied once (idempotency, delivery-semantics)?
PARTIAL One call in a fan-out fails; one write of two succeeds.
Tests: is the outcome consistent, is compensation
triggered (distributed-transactions-and-sagas)?
REORDERED Messages arrive out of order across partitions.
Tests: does the consumer tolerate it, or silently
corrupt (message-ordering-and-partitioning)?
ERRORING 5xx, connection reset, malformed body. Tests: is the
classification right — retryable vs permanent?
DOWN Connection refusal, unavailable endpoints or silent drops.
Distinguish fast errors from timeout-driven detection.
PARTITIONED Both sides alive, cannot see each other. Tests: split
brain, duplicate leaders, lock expiry
(distributed-locks-and-leases, leader-election).
DEAD MID-FLIGHT Process dies between the write and the acknowledgement.
Tests: is the work lost, duplicated, or recovered?
Decision rules
The claim is about how a response is classified or a policy decides
→ unit test the pure policy. No network needed, and every edge
case is a one-line test (humble-objects-and-functional-core).
The claim is about the client's behaviour — timeout fires, retry count,
connection released
→ component test against a stub server that can delay, reset and
return errors. This is the highest-value level and where most
resilience claims belong.
The claim is about consumer idempotency
→ deliver the same message twice in a test and assert the effect
once. This is cheap and almost never done.
The claim is about behaviour under a slow dependency at load
→ load test with latency injected into the dependency. Neither a
plain load test nor a plain fault test finds this
(load-testing, littles-law-and-queueing).
The claim is about the system surviving a node or pod dying
→ kill it in a real environment. No stub reproduces the
combination of in-flight work, connection draining and probe
timing (kubernetes-service-lifecycle).
The claim is about a partition between two stateful components
→ a network-level fault injector between real instances.
Application-level stubs cannot produce a partition.
The proposal is "let us run chaos experiments"
→ require the hypothesis, the steady-state metric, the blast
radius and the abort condition first. Without those it is an
outage with better branding.
The system has no monitoring for the failure being injected
→ fix the observability first. An experiment you cannot observe
produces no finding (slo-and-alerting, metrics-and-cardinality).
Rules
- Test slow before down. A dependency returning in 30 s exhausts the caller's threads and
connections and takes down healthy services; a dependency refusing connections fails fast
and is usually survived. Every timeout in the system deserves one test that it actually
fires.
- Assert timing, not only outcome. "Returns an error" passes whether the timeout fired at
2 s or at 60 s. The elapsed time is the assertion that matters.
- Do not mock the dependency you are testing the failure of. A mocked client returns the
exception you told it to and proves nothing about connection handling, pool exhaustion or
socket timeouts. Use a stub server that can genuinely hang and reset
(
architecture-testing).
- Retry and timeout budgets compose across hops and must be tested end to end. Three
layers making three total attempts each permit up to twenty-seven deepest calls; three
retries plus the initial attempt at each layer permit sixty-four. This multiplication is
the mechanism of most retry storms, and it is invisible in any single service's tests
(
retries-and-backoff, cascading-failures).
- A circuit breaker's history usually spans multiple logical calls. Check its scope, window,
minimum sample count, recorded outcomes and timeout/retry ordering. A caller's shorter
deadline does not imply that the shared breaker can never open
(
circuit-breakers).
- Idempotency is a claim about duplicates, so test with duplicates. Send the same request
or message twice, concurrently as well as sequentially, and assert one effect. Concurrent
duplicates find the missing unique constraint that sequential ones miss (
idempotency).
- Kill the process at the awkward moment — between the database write and the acknowledgement,
between two writes, mid-batch. This is where at-least-once semantics stop being theoretical
and where the outbox either works or does not.
- Fault injection needs a seam. A gateway behind an interface, a proxy, or a service mesh
can be made to fail; a static call buried in business logic cannot. Testability of failure
is an argument for the adapter boundary, independent of portability
(
framework-coupling-and-independence).
- Use controlled fault points and bounded execution for regression tests. Seeded property
tests and deterministic simulations can run in CI; retain seeds, traces and failing inputs.
A seed alone does not reproduce uncontrolled network or thread scheduling.
- Run experiments in production only with a hypothesis, a steady-state metric, a bounded blast
radius and an abort condition — and only where the failure is already observable. Anything
else is not an experiment.
- Every finding becomes a regression test at the cheapest level that reproduces it. The
value of an experiment is the test it leaves behind, not the incident it simulated.
References
Before implementing Java tests, inspect compiler/runtime and resolved test, client and resilience
libraries. The virtual-thread example requires Java 21; preserve lower targets using their
existing executors. No upgrade or dependency change is implied. Examples are partial fixture
shapes, not standalone suites. A passing run establishes only the exercised fault/interleaving.
Failure scenario audit — read when checking missing coverage
or defining the invariant and an assertion that would otherwise hide failure.
Techniques and controlled time — read when selecting infrastructure,
implementing concurrent duplicate tests, or controlling local time.
Injecting failure in a Java system — the tooling ladder
from a stub server through a TCP-level proxy to mesh and node-level faults; what each can
and cannot produce; concrete test shapes for timeout, retry, breaker, duplicate delivery and
mid-flight death; and asserting budgets across hops. Read when writing a specific failure
test.
Experiments in a real environment — turning a resilience
claim into a hypothesis with a steady-state metric, choosing blast radius and abort
conditions, the readiness checklist a system must pass before an experiment is worth running,
game days, and what to do with a finding. Read before proposing or running chaos engineering.
1---2name: distributed-systems-testing3description: Testing the failure behaviour a distributed system claims: injecting latency, errors, partitions and process death; verifying that timeouts, retries, breakers and fallbacks do what their configuration says; proving idempotency against duplicate delivery; and running a controlled experiment in production rather than a chaos tool. Use when resilience configuration exists but has never been exercised, when a timeout or retry budget is being chosen, when an incident was caused by a dependency being slow rather than down, when a consumer is assumed idempotent, when a rollout is protected by a probe nobody has failed on purpose, or when chaos engineering is proposed without a hypothesis. Does not cover the in-process test pyramid and architecture rules (architecture-testing), thread-level race testing (concurrency-testing), throughput and saturation measurement (load-testing), or the remedies themselves (retries-and-backoff, circuit-breakers, timeouts-and-deadlines).4---56# Distributed Systems Testing78## Purpose910Make the system's failure behaviour something that has been observed rather than configured.11Timeouts, retry budgets, circuit breakers, fallbacks, idempotency keys and readiness probes12are all claims; until each has been exercised against the failure it exists for, the system's13resilience is a set of YAML values that have never executed.1415The gap this closes is specific. Functional tests exercise the happy path against a fast,16available dependency. Load tests exercise a healthy system at volume. Neither produces the17condition that actually causes outages: **a dependency that is slow rather than down**, a18duplicate delivered after a broker reconnect, a node that vanishes mid-transaction.1920The two failures this exists to prevent: resilience settings that provably do nothing —21a retry budget exhausted by its first attempt, a breaker that never records the failures it22was meant to count; and chaos experiments run without a23hypothesis or a blast-radius limit, which produce an incident rather than a finding.2425## Workflow26271. **Write the claim down first.** "A payment gateway timing out returns 503 within 2 s and28 does not double-charge." An untestable claim is a configuration you do not understand yet.292. **Pick the cheapest level that can falsify it.** Most claims fall at the component level30 with one faulty dependency; very few need a whole environment.313. **Select faults from the dependency contract and incident evidence.** Include slow,32 unavailable, duplicated, lost and partial outcomes where relevant; do not assume a33 universal frequency ranking or that connection refusal covers a blackhole.344. **Assert the observable outcome**, not the mechanism: the status code, the elapsed time,35 the number of times the downstream was called, the number of rows written. Asserting that36 a breaker library was invoked tests the library.375. **Assert the budget, not just the behaviour.** Retry counts and timeouts compose across38 hops; the property that matters is the total, and it is where retry storms come from.396. **Promote to production only with a hypothesis and a limit** — expected outcome, blast40 radius, abort condition, and a way to stop.4142## The failure taxonomy to test against4344Use this coverage menu according to the workload and failure model, not as a frequency ranking:4546```text47SLOW Dependency responds, eventually. Threads/connections48 pile up behind it. Tests: does the timeout fire, is the49 pool bounded, does the caller shed rather than queue?50 (cascading-failures).5152DUPLICATED The same message or request arrives twice. Tests: is53 the effect applied once (idempotency, delivery-semantics)?5455PARTIAL One call in a fan-out fails; one write of two succeeds.56 Tests: is the outcome consistent, is compensation57 triggered (distributed-transactions-and-sagas)?5859REORDERED Messages arrive out of order across partitions.60 Tests: does the consumer tolerate it, or silently61 corrupt (message-ordering-and-partitioning)?6263ERRORING 5xx, connection reset, malformed body. Tests: is the64 classification right — retryable vs permanent?6566DOWN Connection refusal, unavailable endpoints or silent drops.67 Distinguish fast errors from timeout-driven detection.6869PARTITIONED Both sides alive, cannot see each other. Tests: split70 brain, duplicate leaders, lock expiry71 (distributed-locks-and-leases, leader-election).7273DEAD MID-FLIGHT Process dies between the write and the acknowledgement.74 Tests: is the work lost, duplicated, or recovered?75```7677## Decision rules7879```text80The claim is about how a response is classified or a policy decides81 → unit test the pure policy. No network needed, and every edge82 case is a one-line test (humble-objects-and-functional-core).8384The claim is about the client's behaviour — timeout fires, retry count,85connection released86 → component test against a stub server that can delay, reset and87 return errors. This is the highest-value level and where most88 resilience claims belong.8990The claim is about consumer idempotency91 → deliver the same message twice in a test and assert the effect92 once. This is cheap and almost never done.9394The claim is about behaviour under a slow dependency at load95 → load test with latency injected into the dependency. Neither a96 plain load test nor a plain fault test finds this97 (load-testing, littles-law-and-queueing).9899The claim is about the system surviving a node or pod dying100 → kill it in a real environment. No stub reproduces the101 combination of in-flight work, connection draining and probe102 timing (kubernetes-service-lifecycle).103104The claim is about a partition between two stateful components105 → a network-level fault injector between real instances.106 Application-level stubs cannot produce a partition.107108The proposal is "let us run chaos experiments"109 → require the hypothesis, the steady-state metric, the blast110 radius and the abort condition first. Without those it is an111 outage with better branding.112113The system has no monitoring for the failure being injected114 → fix the observability first. An experiment you cannot observe115 produces no finding (slo-and-alerting, metrics-and-cardinality).116```117118## Rules119120- **Test slow before down.** A dependency returning in 30 s exhausts the caller's threads and121 connections and takes down healthy services; a dependency refusing connections fails fast122 and is usually survived. Every timeout in the system deserves one test that it actually123 fires.124- **Assert timing, not only outcome.** "Returns an error" passes whether the timeout fired at125 2 s or at 60 s. The elapsed time is the assertion that matters.126- **Do not mock the dependency you are testing the failure of.** A mocked client returns the127 exception you told it to and proves nothing about connection handling, pool exhaustion or128 socket timeouts. Use a stub server that can genuinely hang and reset129 (`architecture-testing`).130- **Retry and timeout budgets compose across hops and must be tested end to end.** Three131 layers making three total attempts each permit up to twenty-seven deepest calls; three132 retries plus the initial attempt at each layer permit sixty-four. This multiplication is133 the mechanism of most retry storms, and it is invisible in any single service's tests134 (`retries-and-backoff`, `cascading-failures`).135- A circuit breaker's history usually spans multiple logical calls. Check its scope, window,136 minimum sample count, recorded outcomes and timeout/retry ordering. A caller's shorter137 deadline does not imply that the shared breaker can never open138 (`circuit-breakers`).139- **Idempotency is a claim about duplicates, so test with duplicates.** Send the same request140 or message twice, concurrently as well as sequentially, and assert one effect. Concurrent141 duplicates find the missing unique constraint that sequential ones miss (`idempotency`).142- Kill the process at the awkward moment — between the database write and the acknowledgement,143 between two writes, mid-batch. This is where at-least-once semantics stop being theoretical144 and where the outbox either works or does not.145- **Fault injection needs a seam.** A gateway behind an interface, a proxy, or a service mesh146 can be made to fail; a static call buried in business logic cannot. Testability of failure147 is an argument for the adapter boundary, independent of portability148 (`framework-coupling-and-independence`).149- Use controlled fault points and bounded execution for regression tests. Seeded property150 tests and deterministic simulations can run in CI; retain seeds, traces and failing inputs.151 A seed alone does not reproduce uncontrolled network or thread scheduling.152- Run experiments in production only with a hypothesis, a steady-state metric, a bounded blast153 radius and an abort condition — and only where the failure is already observable. Anything154 else is not an experiment.155- **Every finding becomes a regression test at the cheapest level that reproduces it.** The156 value of an experiment is the test it leaves behind, not the incident it simulated.157158## References159160Before implementing Java tests, inspect compiler/runtime and resolved test, client and resilience161libraries. The virtual-thread example requires Java 21; preserve lower targets using their162existing executors. No upgrade or dependency change is implied. Examples are partial fixture163shapes, not standalone suites. A passing run establishes only the exercised fault/interleaving.164165- [Failure scenario audit](references/failure-scenarios.md) — read when checking missing coverage166 or defining the invariant and an assertion that would otherwise hide failure.167- [Techniques and controlled time](references/techniques.md) — read when selecting infrastructure,168 implementing concurrent duplicate tests, or controlling local time.169170- [Injecting failure in a Java system](references/fault-injection.md) — the tooling ladder171 from a stub server through a TCP-level proxy to mesh and node-level faults; what each can172 and cannot produce; concrete test shapes for timeout, retry, breaker, duplicate delivery and173 mid-flight death; and asserting budgets across hops. Read when writing a specific failure174 test.175- [Experiments in a real environment](references/chaos-experiments.md) — turning a resilience176 claim into a hypothesis with a steady-state metric, choosing blast radius and abort177 conditions, the readiness checklist a system must pass before an experiment is worth running,178 game days, and what to do with a finding. Read before proposing or running chaos engineering.