Mastodon

Driving code design, through tests (II)

The mechanics of TDD are pretty easy to learn, but take special care that your tests will be readable to Future You, who has to solve any issues with the code.

Driving code design, through tests (II)

This post is part of a series on TDD:

In Part One I covered the basic idea of Test Driven Design and why you'd want to do it, and that, if left to my own devices, I'll talk about TDD with a dreamy look on my face instead of having an actual conversation. In this part I'll cover the mechanics, the how to of the whole thing.

The How of TDD

Behold:

  1. Red
  2. Green
  3. Refactor

This is your TDD mantra.

First, you write a requirement in the form of a test. Because you're describing new functionality you should run the test and it should fail (Red). In fact, is there a reason it shouldn't fail? After all, you're writing constraints that your application shouldn't already be following. It is good that the test fails. If the test passes, your application is behaving in ways you aren't expecting, which is the opposite of good ("bad").

Incidentally, if your code doesn't even compile (say, because the test references a new class or method in a language that cares about missing class or method definitions), add whatever code is necessary to get it to compile, but not so much that your new test passes. Failure is not just good at this stage, it is vital.

Next, write the dumbest, most straightforward code necessary to get the test to pass (Green). This is the step that was most offensive to me when I was learning TDD, because of how irksome this example was:

[Test]
public void shouldAddTwoNumbers() {
    Matherator matherator = new Matherator();
    int total = matherator.addTwoNumbers(4, 5);
    assertThat(total, is(equalTo(9)));
}

// implementation
public class Matherator {
    public int addTwoNumbers(int numberOne, int numberTwo) {
        return 9;
    }
}

To clarify, the test says "If you add two numbers, 4 and 5, it should equal 9", so the method always returns 9, ensuring that the test passes. How is this not a complete waste? You know that's going to change - why not write the "correct" implementation first?

I'm going to answer this but it will have to wait for a future post (part 3, I think), because we need a little more context before we get there. But there will be an answer, and it will totally make sense, assuming that mixing Benadryl and scotch isn't having a long-term impact on my ability to ... uh... wordify. things.

Finally, after the test passes, we refactor (.. uh, Refactor). That means we look for any patterns we might be able to apply (but don't apply them too early or you might pick the wrong abstraction) or any duplication you can remove (but don't remove them too early or you might ... you know what, same link).

Some advocates will encourage you to refactor your tests as well. I tend not to refactor my tests too much, if at all. I might make a Builder class if I want to construct more robust testing data more clearly, but I have found that too much abstraction in tests makes them more difficult to read, especially when looking at a single test in isolation - which you are going to do further down the road when you're revisiting code you haven't seen in months and want to know how part of it works. The "unnecessary duplication" you remove now may really hurt Future You's ability to quickly understand what's going on. Don't DRY up your tests just to remove duplication: focus on legibility.

Okay GO

You've got the basics now and are itching to get going. After all, what more do you need than Red, Green, Refactor?

Heuristics. Heuristics and a bunch of practice.

Consider driving. Like, actual driving. In a car. The "rules" (analogous to Red, Green, Refactor) are things you might have already internalized prior to your first driving session. Things like: state laws governing motor vehicle operation ("what's a stop sign?"); how to operate a car ("where's the gas pedal? what's this circular steering apparatus?"); how to parallel park with enough proficiency to pass the driving portion of your exam and then never do it again.

But actually driving involves a ton of heuristics that you tend to develop through years of experience. Questions like "how closely should you follow the car in front of you?" can have scores of answers depending on context: interstate, highway, or surface roads? size of the car in front of you? confidence in your brake pads? surface conditions (icy, rainy, grassy, etc.)? traffic density? does the car in front of you have working brake lights? are you in an area where deer tend to become overwhelmed with existential angst and hurl themselves into traffic, resulting in a bent rim that insurance won't cover because they didn't find any hair or blood on your car and think you just hit a Level Seven I465 Pothole?

I've done what I can to distill a couple of additional heuristics into a set of rules, but those rules will have to wait until Part 3, because I wrote a lot on them and I'm already way over my word limit. 900+ words? Ain't nobody got time for that.

Wrapping up

So the big takeaways from Part 2 (Red, Green, Refactor) are:

  1. Failure isn't optional
  2. Make your implementation dumb until you can't
  3. Refactor the implementation but don't DRY up your tests – err on the side of verbosity

Also, I'll take this opportunity to remind you that TDD, like damn near everything in software, is a skill. It's going to take practice to be able to apply it well. It's okay to suck at it when you start out. You might even question whether it's worth learning.

Please keep trying. It is 100% worth putting some practice in.

And remember: with TDD you're not testing your code, you're driving your code's design.