Lesson 16 / 25
Designing Code That Is Easy to Test
Structure code with dependency injection, pure logic and explicit time and randomness.
Testability is a design property
Code that is hard to test is usually hard to change too. A few design habits make testing easy. Inject dependencies through constructors instead of creating them inside methods with new or reaching for singletons; tests can then pass fakes or mocks. Separate pure logic from side effects: put calculations (prices, taxes, validation, state transitions) in plain methods or classes that take inputs and return outputs, and keep I/O (database, HTTP, files) in thin outer layers. Pure logic can be tested exhaustively with fast, simple tests. Make time and randomness explicit: inject a Clock, a random number generator with a seed, or an ID supplier. Avoid static mutable state and hidden global configuration. Keep classes focused (single responsibility), so each test needs only a few collaborators. Program to interfaces at boundaries (repositories, gateways). These are the same principles as good object-oriented design; tests simply make their absence painful sooner.
Functional core, imperative shell
Pure business logic sits in the middle, easy to test; thin layers around it handle I/O.
Refactoring for testability
The original reaches for globals; the refactored version takes everything it needs.
// hard to test: hidden dependencies, real time, real database
class LateFeeJob {
void run() {
var dao = new JdbcLoanDao(DataSourceHolder.get());
for (Loan l : dao.findOpen()) {
if (LocalDate.now().isAfter(l.dueDate())) {
dao.addFee(l.id(), 50 * ChronoUnit.DAYS.between(l.dueDate(), LocalDate.now()));
}
}
}
}
// testable: pure rule + injected collaborators
final class LateFeePolicy {
long feePaise(LocalDate due, LocalDate today) {
long daysLate = ChronoUnit.DAYS.between(due, today);
return daysLate > 0 ? daysLate * 5_000 : 0; // Rs 50 per day
}
}
final class LateFeeJob2 {
private final LoanRepository loans; private final Clock clock; private final LateFeePolicy policy;
LateFeeJob2(LoanRepository loans, Clock clock, LateFeePolicy policy) { this.loans = loans; this.clock = clock; this.policy = policy; }
void run() {
LocalDate today = LocalDate.now(clock);
for (Loan l : loans.findOpen()) {
long fee = policy.feePaise(l.dueDate(), today);
if (fee > 0) loans.addFee(l.id(), fee);
}
}
}
@Test
void fiveDaysLateCostsRs250() {
assertEquals(25_000, new LateFeePolicy().feePaise(LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 6)));
}If a test needs ten mocks, change the code
A long list of mocks in one test is feedback about the class, not about the test. Split responsibilities until each class has a few clear collaborators.
Quick check: Why inject a java.time.Clock instead of calling LocalDate.now() directly?
- Clock is faster
- LocalDate.now() is deprecated
- Tests can supply a fixed clock, making time-dependent behaviour deterministic
- Clock stores dates in the database
Answer
Tests can supply a fixed clock, making time-dependent behaviour deterministic — An injected Clock makes the current time a controllable dependency.