5 Principles of Unit Testing

Unit testing takes time to learn to do effectively. Learning the basic mechanics of unit testing is the first step, which you can learn in the previous post "how to write a unit test in java". After that, it's helpful to get some heuristics for writing clean test cases so you know when you're on the right track. Here is a list of said principles, while not exhaustive, useful to help you get started:

  1. Keep it Short
  2. Tell a story
  3. Make it run fast
  4. Make it deterministic
  5. The less dependencies, the better

1. Keep it short

When you write your test, it should be as short as possible. With more complicated code, your setup tends to take more effort, the amount of data you need to pass in can grow, and your assertions get more complicated.

One way you can mitigate this is to create what is called an "assertion method". Here's an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void assertWithoutHelperMethod() {
    Customer createdCustomer = querySkyMilesSenior();
    assertEquals(SKY_MILES, createdCustomer.getAccountType());
    assertTrue(createdCustomer.getAge() > 50);
    assertEquals(32, createdCustomer.getRewardPoints());
}

@Test
public void assertWithHelperMethod() {
    Customer createdCustomer = querySkyMilesSenior();
    assertValidSkyMilesSenior(createdCustomer);
}

private void assertValidSkyMilesSenior(Customer customer) {
    assertEquals(SKY_MILES, customer.getAccountType());
    assertTrue(customer.getAge() > 50);
    assertEquals(32, customer.getRewardPoints());
}

With the second test, notice that we created a regular method that does several related assertions at once. This way, you can give a name to the group of operations, and reuse it for other cases where you want to verify that information.

2. Tell a story

You want your tests to be as close to documentation as possible. If someone reads your test name, it should make sense to them why you are writing the test.

When they read each line of test code, it should walk the reader through the steps needed to verify this particular aspect of the code. This means methods should be named well, unrelated setup/work should be avoided, and assertions should make sense.

One aspect that frequently hurts assertion readability is what's called a "magic number". A magic number is just a number in the code that has no obvious meaning in the code, but nevertheless is clearly necessary for the test to work.

If the reader can't tell why that particular number is desired, it's a "magic number".

For example:

1
2
3
4
5
@Test
public void checkThatBirthdateIsCorrect() {
    Child betty = Child.bornOn("2010-07-24");
    assertEquals(1437696000, betty.fifthBirthday());
}

That's really hard to understand! This is a timestamp value - can you tell just by looking at it that it's 5 years from betty's birthday? A better way to assert this value is by using a date class to convert for you:

1
2
3
4
5
6
7
8
9
@Test
public void checkThatBirthdateIsCorrect() {
    Child betty = Child.bornOn("2010-07-24");
    assertEquals(utcTimestampOf(2015, 7, 24), betty.fifthBirthday());
}

private long utcTimestampOf(int year, int month, int day) {
    return LocalDateTime.of(year, month, day, 0, 0, 0).toEpochSecond(ZoneOffset.UTC);
}

Now it's much easier to tell what's going on. We've done the calculation ourselves in the test that we think is correct, and a human reader can determine if that makes sense, or if it's off, what needs to be fixed.

3. Make it run fast

Your unit tests should run really fast. And by fast, I mean that it should take less than a second to run 100 tests. Why? Because when they are that fast, it means you can run them all the time to verify each change you are making the the code, and never feel like you're wasting time waiting for them to finish.

What this means in practice, is that your tests should usually just be code and values being passed around. They should avoid making HTTP requests, talking to files or a database, or getting user interaction.

Of course, some of your code will inevitably need to talk to a database or external HTTP service. Therefore, you should strive to separate as much code from the actual external effects as possible, by avoiding having side-effects in your business logic, and then stubbing or mocking the situations where you need to.

4. Make it deterministic

Having a deterministic test means you want the test to always pass or always fail for a given set of code. Of course you want your tests to pass, but if they fail, you don't want them to only fail on saturdays, or randomly 1/100th of the time. It's really hard to track down bugs that way.

One source of non-determinism is an external effect as discussed above; things like HTTP calls, filesystem or database access, etc. An HTTP call can fail if you have a hiccup in your internet connection, or run it from a train. Even filesystems can sometimes fail if your disk space fills up.

Another source of non-determinism is a random number. Sometimes we deliberately put randomness into our code when we want something to seem more organic. That's fine, but we need a way to assert things and get the same thing every time.

So if you want to test code that returns a random number, you could have a method to pass in the RNG's seed value so it always has the same sequence during tests. You could pass in a Random class into the constructor and stub it out.

Last but not least, non-determinism can come from dates. In tests, it can be easy to use "today" as a value in tests, since it's easy to write with code like Instant.now(). However, this can cause subtle bugs that only break on special occasions!

For instance, say you have code that counts the number of days in a month and returns a number based on it. Then, when you run the test, all 31-day months will come out the same, but if you run it for a 30-day or 28-day month, the test might fail! The way around this is to always pick a static date value when asserting things.

5. The less dependencies, the better

The best dependency is no dependency. Even if you smartly make each dependency in your code an interface and mock/stub them out for tests, each one causes a cognitive overhead to the person reading the code.

Any time you mock another object, you're basically making assumptions about the behavior of that object. If the behavior of that object changes, your mocks won't know to update themselves, and your tests wouldn't start breaking to let you know!

Therefore, it can be both a maintenance burden and a dangerous source of bugs to have too many mocks.

Here's an example of how you could remove a dependency from some code - this code decides which way a drone should fly given the nearest wall to it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void droneFlyer(Wall nearestWall) {
  float minDistance = min(nearestWall.distanceToTop,
    nearestWall.distanceToLeft, nearestWall.distanceToRight);

  switch (minDistance) {
    case nearestWall.distanceToTop:
      drone.flyUp(); break;

    case nearestWall.distanceToLeft:
      drone.flyLeft(); break;

    case nearestWall.distanceToRight:
      drone.flyRight(); break;
  }
}

You could simply mock out the drone object, and in this simple scenario it might be just fine. Imagine you have other dependencies on the class as well.

The way you could eliminate the dependency entirely would be to return a regular data structure that represents the effect instead:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DroneAction droneFlyer(Wall nearestWall) {
  float minDistance = min(nearestWall.distanceToTop,
    nearestWall.distanceToLeft, nearestWall.distanceToRight);

  switch (minDistance) {
    case nearestWall.distanceToTop:
      return DroneAction.FLY_UP;

    case nearestWall.distanceToLeft:
      return DroneAction.FLY_LEFT;

    case nearestWall.distanceToRight:
      return DroneAction.FLY_RIGHT;
  }
}

Now this is a pure function, and it's easier to assert on the return value here to make sure it's correct, than mock out the drone dependency and catch the method calls.

Conclusion

There you have it! A few principles/heuristics that keep you on the right track while you're writing your unit tests. It's not easy to balance all the considerations at once, but the more you can follow consistently, the easier your tests will be to run and maintain.