Lesson 10 of 31
Testability and Dependency Injection
A component is testable when a test can control everything it depends on and observe everything it does. Three kinds of dependency defeat that: time (the result depends on the wall clock), randomness (the result differs each run), and the outside world (files, databases, networks). The remedy is the same for all three: define a small interface for the capability, pass an implementation in from outside, and swap in a fake during tests. This is the dependency inversion principle from SOLID in Java applied with a purpose. It is also what makes most design problems demonstrable: an interviewer can watch a session expire without waiting.
No test library is used here. The lesson's checks are plain assert statements, which throw AssertionError when the JVM runs with assertions enabled (java -ea, which the lesson checker uses). Without -ea an assert does nothing, so a real project runs its checks with that flag or uses a test framework.
Example 1: a hidden dependency
This coupon reads the real clock inside its method. Nothing can test the expiry branch without waiting or editing the code.
import java.time.Instant;
record Coupon(String code, Instant expiresAt) {
boolean isValid() {
return Instant.now().isBefore(expiresAt);
}
}
public class Main {
public static void main(String[] args) {
var coupon = new Coupon("SAVE10", Instant.parse("2096-01-01T00:00:00Z"));
System.out.println(coupon.code() + " valid: " + coupon.isValid()); // depends on when this runs
}
}A test would pass today and fail after the year 2096, or need a coupon that expires in two seconds and a sleep. Both are bad. The dependency on time is invisible in the signature, so it cannot be replaced.
Example 2: inject a Clock
java.time.Clock is the JDK's abstraction of "what time is it". Clock.systemUTC() is the production implementation. A test needs a clock that moves only when the test says so, and Clock.fixed cannot move, so the test code defines a small subclass.
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
// Test implementation: time only moves when the test says so.
final class TestClock extends Clock {
private Instant now;
TestClock(Instant start) {
this.now = start;
}
void advance(Duration amount) {
now = now.plus(amount);
}
@Override
public Instant instant() {
return now;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
}
record Coupon(String code, Instant expiresAt) {
boolean isValid(Clock clock) {
return clock.instant().isBefore(expiresAt);
}
}
public class Main {
public static void main(String[] args) {
var clock = new TestClock(Instant.ofEpochSecond(1_000));
var coupon = new Coupon("SAVE10", Instant.ofEpochSecond(1_060));
System.out.println("t=1000 valid: " + coupon.isValid(clock));
clock.advance(Duration.ofSeconds(59));
System.out.println("t=1059 valid: " + coupon.isValid(clock));
clock.advance(Duration.ofSeconds(1));
System.out.println("t=1060 valid: " + coupon.isValid(clock));
System.out.println("real clock is after 1970: " + Clock.systemUTC().instant().isAfter(Instant.EPOCH));
}
}t=1000 valid: true
t=1059 valid: true
t=1060 valid: false
real clock is after 1970: trueThe component sees only a Clock and cannot advance time itself. The boundary is exact in the test (1059 valid, 1060 not), which is where an off-by-one bug hides. The last line reads the production clock, which Main is free to do because it builds the program. Logic that a lesson tests never calls Instant.now() itself: it asks the injected clock.
Example 3: inject randomness and storage
Randomness is a dependency too: a shuffle, a generated ID or a jitter delay makes tests flaky. java.util.random.RandomGenerator is the JDK interface that java.util.Random implements, so a component can accept it. A fake replays a scripted sequence. Storage gets an interface as well, with an in-memory implementation that serves as a fake in tests.
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Optional;
import java.util.Random;
import java.util.TreeMap;
import java.util.random.RandomGenerator;
interface Storage {
void save(String key, String value);
Optional<String> load(String key);
}
final class MemoryStorage implements Storage {
private final TreeMap<String, String> data = new TreeMap<>();
@Override
public void save(String key, String value) {
data.put(key, value);
}
@Override
public Optional<String> load(String key) {
return Optional.ofNullable(data.get(key));
}
}
// A scripted fake: returns exactly the values the test wants.
final class ScriptedRandom implements RandomGenerator {
private final Deque<Integer> script = new ArrayDeque<>();
ScriptedRandom(int... values) {
for (int value : values) {
script.add(value);
}
}
@Override
public int nextInt(int bound) {
int value = script.removeFirst();
if (value < 0 || value >= bound) {
throw new IllegalStateException("scripted value " + value + " is outside [0, " + bound + ")");
}
return value;
}
@Override
public long nextLong() {
throw new UnsupportedOperationException("this fake scripts nextInt(bound) only");
}
}
public class Main {
// Generates a 4-letter code from injected randomness and stores it.
static String issueCode(RandomGenerator random, Storage storage, String user) {
var code = new StringBuilder();
for (int i = 0; i < 4; i++) {
code.append((char) ('A' + random.nextInt(26)));
}
storage.save(user, code.toString());
return code.toString();
}
public static void main(String[] args) {
var storage = new MemoryStorage();
System.out.println(issueCode(new ScriptedRandom(0, 1, 2, 25), storage, "ana"));
System.out.println("stored: " + storage.load("ana"));
boolean same = issueCode(new Random(42), storage, "x").equals(issueCode(new Random(42), storage, "y"));
System.out.println("same seed, same code: " + same);
}
}ABCZ
stored: Optional[ABCZ]
same seed, same code: trueWith the script 0, 1, 2, 25 the code is exactly ABCZ, so a test can assert the value. A seeded Random gives reproducible runs when realism matters more than a script. The same idea covers a UUID or ID generator: pass a Supplier<String> instead of calling UUID.randomUUID() inside the logic.
Example 4: three injection styles
A dependency can enter a component in three ways. Each has a place.
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Iterator;
import java.util.List;
import java.util.function.Supplier;
// Style 1: constructor injection of an interface, for a long-lived collaborator.
final class Countdown {
private final Clock clock;
private final Instant deadline;
Countdown(Clock clock, Instant deadline) {
this.clock = clock;
this.deadline = deadline;
}
Duration remaining() {
return Duration.between(clock.instant(), deadline);
}
}
// Style 2: pass the value in. The method is a pure function and needs no fake at all.
record Token(Instant issuedAt) {
boolean isExpired(Instant now, Duration ttl) {
return !now.isBefore(issuedAt.plus(ttl));
}
}
// Style 3: a single-method dependency as a Supplier, so a lambda or method reference replaces it.
final class Stopwatch {
private final Supplier<Instant> now;
private final Instant start;
Stopwatch(Supplier<Instant> now) {
this.now = now;
this.start = now.get();
}
Duration elapsed() {
return Duration.between(start, now.get());
}
}
public class Main {
public static void main(String[] args) {
Clock fixed = Clock.fixed(Instant.ofEpochSecond(130), ZoneOffset.UTC);
System.out.println("remaining: " + new Countdown(fixed, Instant.ofEpochSecond(160)).remaining().getSeconds());
var token = new Token(Instant.ofEpochSecond(100));
System.out.println("expired at 129: " + token.isExpired(Instant.ofEpochSecond(129), Duration.ofSeconds(30)));
System.out.println("expired at 130: " + token.isExpired(Instant.ofEpochSecond(130), Duration.ofSeconds(30)));
// The method reference replays a list of readings: 100 at construction, then 130, then 250.
Iterator<Instant> readings = List.of(
Instant.ofEpochSecond(100), Instant.ofEpochSecond(130), Instant.ofEpochSecond(250)).iterator();
var stopwatch = new Stopwatch(readings::next);
System.out.println("elapsed: " + stopwatch.elapsed().getSeconds());
System.out.println("elapsed: " + stopwatch.elapsed().getSeconds());
}
}remaining: 30
expired at 129: false
expired at 130: true
elapsed: 30
elapsed: 150| Style | Use it when | Cost |
|---|---|---|
| Constructor injection of an interface | A service holds a collaborator for its whole life (clock, store, notifier) | One field per dependency, and a constructor that lists them all |
| Pass the value in | The logic needs only a value, such as the current instant | The caller must supply it |
Supplier or another functional interface |
The dependency is one method and a lambda is a clear fake | Less readable when the type name would explain the role |
All three use interfaces, so calls go through dynamic dispatch. The JIT compiler can often inline such a call when one implementation is loaded at the call site, so the cost matters only in measured hot paths. A static call to a singleton, such as Clock.systemUTC() inside the logic, is the one style to avoid: the test cannot replace it. Do not create an interface for something with no seam: injecting a wrapper around a List adds nothing.
Fakes, stubs and spies
Test doubles come in a few forms, and hand-written ones are short in Java.
| Double | What it does | Example |
|---|---|---|
| Stub | Returns canned answers | Clock.fixed(...) |
| Fake | A simple working implementation | MemoryStorage |
| Spy | Records calls so the test can inspect them | A notifier that adds every message to a list |
| Mock (framework) | Verifies interactions by expectation | Needs a third-party library, so this course does not use one |
A spy for a one-method interface is a lambda that appends to a list. The test reads the list afterwards. The next section uses one.
Example 5: session expiry with checks
This puts the pieces together in a service with two dependencies (clock and notifier) and a set of test methods that check its behavior without sleeping. The design follows errors in domain design: the result of touch is a value, so a test can compare it. The three outcomes carry no data, so an enum is enough.
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
@FunctionalInterface
interface Notifier {
void send(String user, String message);
}
enum TouchResult { OK, EXPIRED, UNKNOWN }
final class SessionService {
private record Entry(String user, Instant lastSeen) {}
private final Clock clock;
private final Notifier notifier;
private final Duration ttl;
private final Map<Integer, Entry> sessions = new TreeMap<>();
private int nextId = 1;
SessionService(Clock clock, Notifier notifier, Duration ttl) {
this.clock = Objects.requireNonNull(clock);
this.notifier = Objects.requireNonNull(notifier);
this.ttl = Objects.requireNonNull(ttl);
}
int login(String user) {
int id = nextId++;
sessions.put(id, new Entry(user, clock.instant()));
return id;
}
TouchResult touch(int id) {
Entry entry = sessions.get(id);
if (entry == null) {
return TouchResult.UNKNOWN;
}
Instant now = clock.instant();
if (!now.isBefore(entry.lastSeen().plus(ttl))) {
sessions.remove(id);
notifier.send(entry.user(), "session expired");
return TouchResult.EXPIRED;
}
sessions.put(id, new Entry(entry.user(), now));
return TouchResult.OK;
}
}
final class TestClock extends Clock {
private Instant now = Instant.EPOCH;
void setSeconds(long seconds) {
now = Instant.ofEpochSecond(seconds);
}
@Override
public Instant instant() {
return now;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
}
public class Main {
static void touchWithinTtlKeepsSessionAlive() {
var clock = new TestClock();
var log = new ArrayList<String>();
var service = new SessionService(clock, (user, message) -> log.add(user + ": " + message), Duration.ofSeconds(30));
int id = service.login("ana");
clock.setSeconds(29);
assert service.touch(id) == TouchResult.OK;
clock.setSeconds(58); // 29 seconds after the last touch: still fine
assert service.touch(id) == TouchResult.OK;
assert log.isEmpty();
}
static void touchAfterTtlExpiresAndNotifies() {
var clock = new TestClock();
var log = new ArrayList<String>();
var service = new SessionService(clock, (user, message) -> log.add(user + ": " + message), Duration.ofSeconds(30));
int id = service.login("ana");
clock.setSeconds(30); // exactly the ttl: expired
assert service.touch(id) == TouchResult.EXPIRED;
assert log.equals(List.of("ana: session expired"));
assert service.touch(id) == TouchResult.UNKNOWN; // it was removed
}
static void unknownIdIsReportedWithoutNotification() {
var log = new ArrayList<String>();
var service = new SessionService(new TestClock(), (user, message) -> log.add(message), Duration.ofSeconds(30));
assert service.touch(99) == TouchResult.UNKNOWN;
assert log.isEmpty();
}
static void run(String name, Runnable test) {
test.run();
System.out.println("ok: " + name);
}
public static void main(String[] args) {
run("touch within ttl keeps session alive", Main::touchWithinTtlKeepsSessionAlive);
run("touch after ttl expires and notifies", Main::touchAfterTtlExpiresAndNotifies);
run("unknown id is reported without notification", Main::unknownIdIsReportedWithoutNotification);
}
}ok: touch within ttl keeps session alive
ok: touch after ttl expires and notifies
ok: unknown id is reported without notificationEvery assertion is exact because nothing is random and nothing waits. The spy lambda shows the side effect (one notification, not two), the test clock moves by the test's command, and the enum lets the test compare whole results. The run helper prints a line only when the test method returns, and an AssertionError stops the program with a failure.
Design habits that keep code testable
- Push effects to the edge. Keep the core as pure functions of their inputs (
Money total(List<Item> items)) and let a thin outer layer do input and output. Pure functions need no fakes at all. - Construct in
main, not in the component. The composition root builds the real clock and store and passes them in. Components never callInstant.now(),UUID.randomUUID()orFiles.readStringthemselves. No framework is needed for this. - Keep interfaces small. A
Notifierwith one method is trivial to fake. A fifteen-methodDatabaseinterface is not (see the interface segregation section of SOLID). - Return values, do not print. A method that returns a
Stringor a result can be asserted. One that callsSystem.out.printlncannot. - Test behavior, not internals. Assert on public results and observable effects, so refactoring the private structure does not break tests. See encapsulation and invariants.
- Make concurrency deterministic. Inject an executor or a scheduler rather than starting threads inside logic, and wait for completion with
joinor a latch before asserting (see thread-safe components).
For full designs built on these ideas, see design rate limiter, which needs a clock, and design KV store with TTL, where expiry is the whole point.
Interview points
- Time, randomness and I/O are the three hidden dependencies. Inject
Clock,RandomGeneratorand a storage interface. - Constructor injection needs no framework: the object that builds the program passes the implementations.
- A fake is a working simplified implementation, a stub returns canned answers, and a spy records calls.
- Do not call
Instant.now()ornew Random()inside logic that must be tested. - An interface is justified by a seam (a fake in tests or a second implementation), not by the existence of a class.
Summary
- A component is testable when tests control its inputs and observe its effects. Time, randomness and I/O are the usual obstacles.
- Wrap each in a small interface (
Clock,RandomGenerator,Storage,Notifier), pass an implementation in, and let tests provide a fake. - Fakes are short by hand. A lambda is a spy for a one-method interface.
- Inject through the constructor for long-lived collaborators, pass values for pure logic, and use a
Supplierfor a single-method dependency. - A scripted fake gives exact assertions. A seeded
Randomgives reproducible realism. - Build real dependencies in
main(the composition root) and keep the core pure. - Do not add an interface unless a seam exists: a fake is a good reason, a hypothetical second implementation usually is not.
