You suck at TDD #1: Rewrite the steps

I've been paying attention to TDD for the past few years – doing it myself, watching others doing it, reading about it, etc. - and I've been seeing a lot of variation in the level of success people are having with it. As is my usual approach, I wrote a long and tedious post about it, which I have mercifully decided not to inflict on you.

Instead, I'm going to do a series of posts about the things I've seen getting in the way of TDD success. And, in case it isn't obvious, I've engaged in the majority of the things that I'm going to be writing about, so, in the past, I sucked at TDD, and I'm sure I haven't totally fixed things, so I still suck at it now.

Welcome to "You suck at TDD"…

Rewrite the steps

The whole point of TDD is that following the process exerts design pressure on your code so that you will refactor to make it better (1). More specifically, it uses the difficulty in writing simple test code as a proxy for the design quality of the code that is being tested.

Let's walk through the TDD steps:

  1. Write a test that fails

  2. Make the test pass

  3. Refactor

How does this usually play out? Typically, we dive directly into writing the test, partly because we want to skip the silly test part and get onto the real work of writing the product code, and partly because TDD tells us to do the simplest thing that could possible work. Writing the test is a formality, and we don't put a lot of thought into it.

The only time this is not true is when it's not apparent how we can actually write the test. If, for example, a dependency is created inside a class, we need to do something to be able to inject that dependency, and that usually means some refactoring in the product code.

Now that we have the test written, we make the test pass, and then it's time to refactor, so we look at the code, make some improvements, and then repeat the process.

And we're doing TDD, right?

Well…. Not really. As I said, you suck at TDD…

Let's go back to what I wrote at the beginning of the section. I said that the point of TDD was that the state of our test code (difficult to write/ugly/etc) forced us to improve our product code. To succeed in that, that means that our test code has to either be drop-dead-simple (setup/test/assert in three lines) or it needs to be evolving to be simpler as we go. With the exception of the cases where we can't write a test, our tests typically are static. I see this all the time. 

Let's try a thought experiment. I want you to channel your mindset when you are doing TDD. You have just finished making the test pass, and you are starting the refactor set. What are you thinking about? What are you looking at?

Well, for me, I am focused on the product code that I just wrote, and I have the source staring me in the face. So, when I think of refactoring, I think about things that I might do to the product code. But that doesn't help my goal, which is to focus on what the test code is telling me, because it is the proxy for whether my product code is any good.

This is where the three-step process of TDD falls down; it's really easy to miss the fact that you should be focusing on the test code and looking for refactorings *there*. I'm not going to say that you should ignore product code refactorings, but I am saying that the test ones are much more important.

How can we change things? Well, I tried a couple of rewrites of the steps. The first is:

  1. Write a test that fails

  2. Make the test pass

  3. Refactor code

  4. Refactor test

Making the code/test split explicit is a good thing as it can remind us to focus on the tests. You can also rotate this around so that "refactor tests" is step #1 if you like. This was an improvement for me, but I was still in "product mindset" for step 4 and it didn't work that great. So, I tried something different:

  1. Write a test that fails

  2. Refactor tests

  3. Make the test pass

  4. Refactor code

Now, we're writing the test that fails, and then immediately stopping to evaluate what that test is telling us. We are looking at the test code and explicitly thinking about whether it needs to improve. That is a "good thing".

But… There's a problem with this flow. The problem is that we're going to be doing our test refactoring while we have a failing test in our test suite, which makes the refactoring a bit harder as the endpoint isn't "all green", it's "all green except for the new test".

How about this:

  1. Write a test that fails

  2. Disable the newly failed assertion

  3. Refactor tests

  4. Re-enable the previously failing assertion

  5. Make the test pass

  6. Refactor code

That is better, as we now know when we finish our test refactoring that we didn't break any existing tests.

My experience is that if you think of TDD in terms of these steps, it will help put the focus where it belongs – on the tests. Though I will admit that for simple refactorings, I often skip disabling the failing test, since it's a bit quicker and it's a tiny bit easier to remember where I was after the refactoring.