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.

A small solid core circle surrounded by a thinner ring with arrows to external icons such as a database and a cloud.
Figure 6.1 — Pure logic in the core, side effects at the edges.

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.