Skip to content

Dependency Injection (course exercise walkthrough)

Java. A guided exercise showing why/how to extract a hard-to-test dependency (randomness) behind an injected interface, ending in a fully mockable, 100%-coverable class.

Starting point: untestable randomness

java
public class DamageCalculator {
    private final Random random = new Random();

    // Damage is attackPower * 100
    // If the attack is a surprise attack, damage is multiplied by 2
    // If the attack is a critical attack (calculated randomly by crit chance), damage is once again multiplied by 2
    public int calculate(int attackPower, int critChanceInPercent, boolean isSurpriseAttack) {
        boolean isCriticalAttack = random.nextInt(100) < critChanceInPercent;
        int multiplier = 1;

        if (isSurpriseAttack) {
            multiplier *= 2;
        }
        if (isCriticalAttack) {
            multiplier *= 2;
        }

        return attackPower * 100 * multiplier;
    }
}

Problem: calculate's outcome depends on Random, so a test can't assert an exact expected result for the critical-hit branch — the same input can produce two different outputs from run to run.

Exercise steps

  1. Write tests for the deterministic parts first. Refactor calculate into smaller methods that isolate the random decision (isCriticalAttack) from the pure arithmetic, so the arithmetic can be tested deterministically. Keep calculate's signature unchanged. Run with coverage to see what's still untested (the random branch).
  2. Extract the randomness into its own class:
    java
    public class SimpleRandomNumberGenerator {
        private final Random random = new Random();
    
        public int nextInt(int bound) {
            return random.nextInt(bound);
        }
    }
    Remove Random from DamageCalculator entirely (delete the import too); inject a SimpleRandomNumberGenerator via DamageCalculator's constructor instead of constructing it inside the class.
  3. Extract an interface:
    java
    public interface RandomNumberGenerator {
        int nextInt(int bound);
    }
    SimpleRandomNumberGenerator implements RandomNumberGenerator. DamageCalculator now depends only on the RandomNumberGenerator interface — no mention of SimpleRandomNumberGenerator remains inside it.
  4. Discuss pros/cons of this refactor (see below).
  5. Mocking: add a MockRandomNumberGenerator implements RandomNumberGenerator with a package-visible field letting the test control exactly what "random" number it returns. Swap it in for SimpleRandomNumberGenerator in the test. Now the critical-hit branch can be tested deterministically too — coverage of DamageCalculator reaches 100%.

Pros / cons of this refactor

Pros (what became possible that wasn't before):

  • DamageCalculator becomes fully testable, including the previously-untestable random branch, by substituting a controllable fake for the real randomness.
  • DamageCalculator no longer knows or cares how randomness is produced — it only knows the RandomNumberGenerator contract, so the real implementation could change (a different algorithm, a seeded generator for reproducible runs, etc.) without touching DamageCalculator at all.

Cons (the cost):

  • More classes/files for what was originally a few lines — extra indirection and moving parts to hold in your head.
  • Not free: this is a deliberate trade of some simplicity for testability/flexibility, worth it here because the randomness was genuinely blocking test coverage, but not something to reach for reflexively on every dependency.