JAVA / PLATFORM, BUILDS AND TESTING
Mocking boundaries and testing failure paths
Replace network, database and clock calls with hand-written doubles so you can force exceptions and prove your fallback and retry paths run.
What you will learn
- Inject a boundary as a narrow interface so a test can pass in a throwing implementation.
- Force the exact exception the real client throws so the catch branch really executes.
- Use Clock.fixed instead of Thread.sleep to test expiry edges deterministically.
- Assert call counts only when the interaction is the behaviour, like retry attempts.
Understanding Mocking boundaries and testing failure paths
A boundary is any point where your code hands work to something a test cannot command: a socket, a file, a database driver, the system clock, a random source. The reason to substitute one is not mainly speed, it is control. You cannot ask a real payment gateway to time out on request, so the catch block that handles the timeout is the part of your program that never gets executed until a customer executes it for you.
Substitution only works where there is a seam. A class that writes `new HttpClient()` inside a method has welded the boundary in place; a class that takes a `RateSource` in its constructor has a hole a test can fill. Keep that interface narrow and owned by you, declaring only the two or three calls you actually make, because a wide vendor-shaped interface forces every double to implement methods nobody uses and makes your tests encode the vendor's API instead of your behaviour.
Doubles do two different jobs and it matters which one you are using. A stub answers a question by returning a value or throwing, and you then assert on the result your code produces; a spy records the calls it received, and you assert on the calls themselves. Prefer asserting results, and reach for call counts only when the interaction is the behaviour under test: three retries and no more, one charge and not two, the connection closed on the error path. Verifying every call turns harmless refactoring into red tests while proving nothing about correctness.
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
interface RateSource {
double rateFor(String currency) throws IOException;
}
class PriceService {
private final RateSource source;
private final double fallbackRate;
private final List<String> log = new ArrayList<>();
PriceService(RateSource source, double fallbackRate) {
this.source = source;
this.fallbackRate = fallbackRate;
}
long convertCents(long cents, String currency) {
double rate;
try {
rate = source.rateFor(currency);
} catch (IOException e) {
log.add("fallback:" + e.getMessage());
rate = fallbackRate;
}
return Math.round(cents * rate);
}
List<String> log() {
return log;
}
}
public class Main {
public static void main(String[] args) {
PriceService ok = new PriceService(currency -> 1.25, 1.10);
System.out.println("happy: " + ok.convertCents(2000, "EUR"));
System.out.println("happy log: " + ok.log());
PriceService offline = new PriceService(
currency -> { throw new IOException("timeout after 2s"); },
1.10);
System.out.println("failure: " + offline.convertCents(2000, "EUR"));
System.out.println("failure log: " + offline.log());
}
}
Mock a boundary to gain control of its outcomes, above all its failures, so your own error-handling branch is actually executed by a test.
Worked examples
Spying on retries without a real network
A recording double proves the retry loop stops on the first success and gives up after the configured number of attempts.
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
interface Sender {
void send(String body) throws IOException;
}
class RecordingSender implements Sender {
final List<String> calls = new ArrayList<>();
private final int failuresBeforeSuccess;
RecordingSender(int failuresBeforeSuccess) {
this.failuresBeforeSuccess = failuresBeforeSuccess;
}
@Override
public void send(String body) throws IOException {
calls.add(body);
if (calls.size() <= failuresBeforeSuccess) {
throw new IOException("attempt " + calls.size() + " refused");
}
}
}
class Notifier {
private final Sender sender;
Notifier(Sender sender) {
this.sender = sender;
}
boolean deliver(String body, int attempts) {
for (int i = 1; i <= attempts; i++) {
try {
sender.send(body);
return true;
} catch (IOException e) {
// swallow and retry
}
}
return false;
}
}
public class Main {
public static void main(String[] args) {
RecordingSender flaky = new RecordingSender(2);
System.out.println("delivered=" + new Notifier(flaky).deliver("build failed", 4));
System.out.println("calls=" + flaky.calls.size());
RecordingSender dead = new RecordingSender(99);
System.out.println("delivered=" + new Notifier(dead).deliver("build failed", 3));
System.out.println("calls=" + dead.calls.size());
}
}
Example explained
Line 1RecordingSender is both stub and spy: it throws for the first N calls and appends every body to calls.
Line 2The condition calls.size() <= failuresBeforeSuccess makes the failure schedule deterministic, so no real flakiness is needed to reach the retry code.
Line 3calls=3 in the first case is the assertion that matters: it proves the loop stopped at the first success instead of using all four attempts.
Line 4The second case drives the give-up path, where deliver returns false after exactly three calls and no fourth.
A fixed Clock makes the expiry edge testable
Injecting java.time.Clock turns a thirty-minute timeout into a test that runs instantly and can hit the exact boundary instant.
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
class Session {
private final Instant issuedAt;
private final Duration ttl;
Session(Instant issuedAt, Duration ttl) {
this.issuedAt = issuedAt;
this.ttl = ttl;
}
boolean isExpired(Clock clock) {
return !clock.instant().isBefore(issuedAt.plus(ttl));
}
}
public class Main {
public static void main(String[] args) {
Instant issued = Instant.parse("2026-01-01T00:00:00Z");
Session session = new Session(issued, Duration.ofMinutes(30));
Clock before = Clock.fixed(issued.plus(Duration.ofMinutes(29)), ZoneOffset.UTC);
Clock atEdge = Clock.fixed(issued.plus(Duration.ofMinutes(30)), ZoneOffset.UTC);
System.out.println("at 29m expired=" + session.isExpired(before));
System.out.println("at 30m expired=" + session.isExpired(atEdge));
}
}
Example explained
Line 1The clock is the boundary here: Session asks a Clock for the current instant instead of calling Instant.now() itself, which would be unmockable.
Line 2Clock.fixed pins time to one instant, so the thirty-minute edge is reachable in microseconds rather than by waiting or sleeping.
Line 3isExpired uses !isBefore, meaning the exact expiry instant counts as expired; printing true at 30m is what pins that off-by-one decision down.
Line 4No test double class is written at all, because the JDK already ships the fake implementation.
Important notes
Mockito cannot stub a final class or a static method without the inline mock maker, and it warns about self-attaching an agent on recent JDKs; if a double is painful to build, the usual cause is a boundary that is too wide, not a mocking library that is too small.
A double proves your code handles a given response or exception; it never proves the real service produces it, so treat the double's behaviour as an assumption that a contract or integration test has to confirm.
Common mistakes
Constructing the client inside the method under test, so no seam exists: the test either hits the real service and passes only when the network is up, or it is quietly skipped, and the failure branch is never executed at all.
Making the double throw RuntimeException when the real client throws a checked SocketTimeoutException. The test goes green against a catch clause that production never reaches, so the fallback stays dead code.
Calling Thread.sleep to reach a timeout or expiry instead of injecting a Clock, which makes the suite slow and turns any loaded CI machine into a source of random failures.
Try it yourself
Change, predict, then run
Define `interface Store { String read(String key) throws IOException; }` and a `CachingReader` that returns the stored value but returns "unavailable" when the store throws, then drive it from main twice: once with a lambda returning "hello" and once with a lambda throwing `new IOException("disk gone")`. Change the class to rethrow instead of falling back and note which of your two printed results changes.
Open the Java workspaceCheck your understanding
A test injects a double that throws RuntimeException("boom") and asserts that PriceService falls back to a cached rate. The test passes, yet in production real timeouts still propagate as errors. What is the most likely explanation?
- Mockito cannot simulate network latency, so timeout handling can only ever be checked end to end.
- The double returns too fast, so the JIT eliminates the fallback branch at runtime.
- The double throws an unchecked exception while the real client throws a checked IOException, so the catch clause exercised by the test is not the one that runs in production.
- The cached rate is stale, so the fallback runs but produces the wrong converted amount.
Show answer
Which catch clause runs is decided by the thrown type, so a double must throw what the real client declares; a RuntimeException can only exercise a catch for RuntimeException or Exception, leaving the IOException path untested. Option 3 is tempting because a stale rate is a real bug, but the symptom described is the error escaping rather than a wrong number, and option 0 is wrong because throwing the real exception type is exactly how you reproduce a timeout's effect without waiting for one.