Menu

Story Code

Designing APIs is hard. One technique that helps me is to tell a story with the code: I’ll make a sequence of calls to show how things fit together. I find this helps me to discover what methods I need and what I can leave out. Using features together shows me how they integrate and reveals inconsistencies in naming and behavior.

Where does this exploration live? Usually I just sketch it out in IntelliJ as a main method or test case. For example, to build the API for a parking meter I might start with this:

public final class ParkingMeterTest {  
  FakeClock clock = new FakeClock();
  ParkingMeter parkingMeter = new ParkingMeter(clock);

  @Test public void testParkingMeter() {
    // This meter charges 25 cents for 15 minutes.
    parkingMeter.setRate(25, 15);
    parkingMeter.setMaxTime(60);

    // Initially the parking meter is expired.
    assertEquals("00:00", parkingMeter.display());
    assertTrue(parkingMeter.isExpired());

    // A customer puts in 50 cents to get 30 minutes.
    parkingMeter.addMoney(50);
    assertEquals("00:30", parkingMeter.display());
    assertFalse(parkingMeter.isExpired());

    // After 29 minutes elapses there's still one minute left.
    clock.advance(29, TimeUnit.MINUTES);
    assertEquals("00:01", parkingMeter.display());
    assertFalse(parkingMeter.isExpired());

    // After 1 more minute the meter is expired.
    clock.advance(1, TimeUnit.MINUTES);
    assertEquals("00:00", parkingMeter.display());
    assertTrue(parkingMeter.isExpired());

    // It stays expired as more time elapses.
    clock.advance(60, TimeUnit.MINUTES);
    assertEquals("00:00", parkingMeter.display());
    assertTrue(parkingMeter.isExpired());

    // If a customer adds too much money it doesn't get more time.
    parkingMeter.addMoney(125);
    assertEquals("60:00", parkingMeter.display());
    assertFalse(parkingMeter.isExpired());
    clock.advance(60, TimeUnit.MINUTES);
    assertEquals("00:00", parkingMeter.display());
    assertTrue(parkingMeter.isExpired());
  }
}

Writing stories like this is great! But although this unit test tells a good story, it doesn’t make a good test case.

Good tests isolate behavior

This story exercises lots of behavior. But it’s completely haphazard which of my parking meter’s behaviors are covered by the test. Story tests don’t give confidence that the code will work in general.

Story tests are also fragile to behavior changes. Because they don’t really call out which invariants they cover, fixing a broken story test can be hasty and shallow.

Instead, replace the story test with separate tests for each invariant:

public final class ParkingMeterTest {  
  FakeClock clock = new FakeClock();

  /** This meter charges 25 cents for 15 minutes. */
  ParkingMeter parkingMeter = new ParkingMeter(clock)
      .setRate(25, 15)
      .setMaxTime(60);

  @Test public void initiallyMeterIsExpired() {
    assertEquals("00:00", parkingMeter.display());
    assertTrue(parkingMeter.isExpired());
  }

  @Test public void addingMoneyAddsTime() {
    parkingMeter.addMoney(50);
    assertEquals("00:30", parkingMeter.display());
    assertFalse(parkingMeter.isExpired());
  }

  @Test public void timeElapsingUpdatesDisplay() {
    parkingMeter.addMoney(50);
    clock.advance(29, TimeUnit.MINUTES);
    assertEquals("00:01", parkingMeter.display());
    assertFalse(parkingMeter.isExpired());
  }

  @Test public void timeElapsingToZeroExpiresMeter() {
    parkingMeter.addMoney(50);
    clock.advance(30, TimeUnit.MINUTES);
    assertEquals("00:00", parkingMeter.display());
    assertTrue(parkingMeter.isExpired());
  }

  ...
}

I can go from the story to an initial set of invariants. By considering each invariant, it’s not too tough to discover other cases to exercise.

Stories are a good start

I think in stories and narratives. I like to use that to drive the design of an API and its implementation.

  1. Start with a story.
  2. Rewrite the story as code, using made-up types and methods where necessary.
  3. Use IntelliJ quick fixes to turn made-up types into skeleton classes.
  4. Extract isolated test cases from the story code.
  5. Get the test cases to pass.

Through this process the story disappears: it is replaced with focused test cases. If it was a good story it can come back in the documentation.