Java Arena
Step 5 of 6

Test-driven development

So far, the code came first and its tests afterwards. Test-driven development, or TDD for short, turns that around: before you write a piece of code, you write a test for it. The work goes in small rounds:

  1. Write one test for something the class doesn't do yet.
  2. Run the tests and see the new one fail. A test that passes before the code even exists isn't testing anything.
  3. Write just enough code to make it pass, and no more.
  4. Run all the tests. They should all pass, the old ones too.
  5. Tidy up the code if it needs it, running the tests after each change. Then start the next round.

Here's a countdown for a kitchen timer, built this way.

Round 1: the starting time

The first test says that a new countdown of 90 seconds has 90 seconds left. For the test to compile, Countdown needs a constructor and a method secondsLeft(), so they're written first as empty shells, with secondsLeft() returning 0 for now:

Countdown.java
public class Countdown {
    public Countdown(int seconds) {
    }

    public int secondsLeft() {
        return 0;
    }
}
CountdownTest.java
import static org.junit.Assert.*;

import org.junit.Test;

public class CountdownTest {
    @Test
    public void startsWithItsSeconds() {
        Countdown countdown = new Countdown(90);
        assertEquals(90, countdown.secondsLeft());
    }
}
Output
CountdownTest: 1 test
  startsWithItsSeconds: FAILED
    java.lang.AssertionError: expected:<90> but was:<0>
    at CountdownTest.startsWithItsSeconds(CountdownTest.java:9)
0 of 1 test passed

The test fails, as it should. Now just enough code to pass it: the countdown keeps its seconds in an instance variable, and returns them.

private int seconds;

public Countdown(int seconds) {
    this.seconds = seconds;
}

public int secondsLeft() {
    return this.seconds;
}

Run the tests again, and startsWithItsSeconds passes.

Round 2: a tick

The next test: after one tick(), there's a second less.

@Test
public void tickTakesOneSecond() {
    Countdown countdown = new Countdown(90);
    countdown.tick();
    assertEquals(89, countdown.secondsLeft());
}

It doesn't even compile, since there's no tick yet. That counts as failing too. An empty public void tick() { } makes it compile, and the test fails with expected:<89> but was:<90>. Then this.seconds--; inside tick makes it pass, and the first test still passes too.

Round 3: never below zero

A timer that has run out stays at zero. That's the next test, and it fails:

Countdown.java
public class Countdown {
    private int seconds;

    public Countdown(int seconds) {
        this.seconds = seconds;
    }

    public void tick() {
        this.seconds--;
    }

    public int secondsLeft() {
        return this.seconds;
    }
}
CountdownTest.java
import static org.junit.Assert.*;

import org.junit.Test;

public class CountdownTest {
    @Test
    public void startsWithItsSeconds() {
        Countdown countdown = new Countdown(90);
        assertEquals(90, countdown.secondsLeft());
    }

    @Test
    public void tickTakesOneSecond() {
        Countdown countdown = new Countdown(90);
        countdown.tick();
        assertEquals(89, countdown.secondsLeft());
    }

    @Test
    public void stopsAtZero() {
        Countdown countdown = new Countdown(1);
        countdown.tick();
        countdown.tick();
        assertEquals(0, countdown.secondsLeft());
    }
}
Output
CountdownTest: 3 tests
  startsWithItsSeconds: passed
  stopsAtZero: FAILED
    java.lang.AssertionError: expected:<0> but was:<-1>
    at CountdownTest.stopsAtZero(CountdownTest.java:24)
  tickTakesOneSecond: passed
2 of 3 tests passed

Just enough code to pass it is one if:

public void tick() {
    if (this.seconds > 0) {
        this.seconds--;
    }
}

Now all three tests pass. The next round could add isDone(), then a way to add a minute, and so on: one test, then the code for it.

Why work this way

  • You decide what the code should do before you decide how. The test is the class's first user, so an awkward method name or parameter shows up early.
  • Every piece of code has a test from the start, and the tests stay. When a later change breaks something that used to work, a test fails right away.
  • The rounds are small. At any moment at most one test fails, and you know that the problem is in the few lines you just wrote.

Not everyone works test first. Many programmers write the tests right after the code, and that works too. What matters is that the tests get written, and that you have seen each one fail at least once, so you know it can.

Your task

Challenge 1 of 3

A coffee machine has a water tank that holds a number of milliliters. CoffeeMachineTest is written already, and your job is to make its tests pass. CoffeeMachine.java has empty methods, so the tests compile, and three of the four fail at first. cannotUseMoreThanIsLeft passes by luck, because left() returns 0 for now. It starts failing once brew takes water without checking how much is left: that's the mistake it's there to catch. Run the tests, pick one failing test, write just enough code for it, and run them again, until all of them pass:

  • A new machine's tank is full: left() returns its capacity.
  • brew(int ml) uses that much water, but never more than is left.
  • refill() fills the tank back up.

The checks run the tests of CoffeeMachineTest as they are given here (changing them won't help), and more tests of their own.

Challenge 2

The countdown from this step gets a new method, reset(), which starts it over from the seconds it was made with. It's in Countdown.java as an empty shell. Work test first:

  1. In CountdownTest, write a new test that makes a countdown, ticks a few times, calls reset() and checks secondsLeft(). Run the tests: your new test should fail.
  2. Then write reset(), and run the tests again: now all of them should pass.

The checks run your tests on your Countdown, where all should pass, and on the Countdown with the empty reset(), where your new test should fail. Then they run tests of their own on your reset().

Challenge 3

A carpool shares a car to work, and keeps the names of its riders, up to the number of seats. CarpoolTest is written, and your job is to write Carpool so its tests pass, one failing test at a time:

  • new Carpool(seats) makes an empty carpool with that many seats.
  • join(String name) adds the rider and returns true. It returns false and adds nobody when every seat is taken, or when the same name is in the carpool already.
  • leave(String name) takes the rider out of the carpool, if they're in it.
  • riders() returns how many riders there are, and isFull() whether every seat is taken.

The checks run the tests of CarpoolTest as they are given here (changing them won't help), and more tests of their own.