The Nuts And Bolts Of Writing Unit Tests
Just like unit testing there is an easy three step process to remember for TDD: Red, Green, Refactor.
The first step in TDD is writing unit tests that fail. This is the counter-intuitive part we mentioned earlier, but it’s very important. In this step we’ll take a look at the requirements and develop a general idea of how we will implement one of them. Once we have that very general idea we’ll write some tests that will fail right now.
Failing tests are important because our tests can only pass after we have some code to test. When we first get started with TDD we don’t have any code so our tests can’t possibly pass. At first our tests will probably fail because our code doesn’t compile (because we’re trying to test a function that doesn’t exist or pass the wrong number of parameters to a function). Once we create the function to test (the SUT) our test may still fail because we’re expecting the function to do something that it isn’t doing yet.
Let’s go back to the Add function in our calculator example from earlier. Perhaps our first requirement was something like this: As a user I want to see my total when I enter two numbers. As developers we read that and think of a function to add two numbers together. We make the decision to create a function called Add that accepts two numbers and returns one number. We know our organization requires logging to be included in every function so our Add function will need to log the parameters and a simple message when it is invoked. We now have a really basic idea of the structure of our code and what general steps it will take.
We write a unit test called AddShouldLogTheParametersPassedToIt and we structure that test to inject a mock object for our Logger dependency, call the Add function with 1 and 2 as the parameters, and check to see that the Logger mock was called one time with 1, 2, and a simple message as parameters. Our code doesn’t compile because there is no Add function. We have achieved a Red test.
Once we have a failing test, we write just enough code to make our test pass. We don’t want to write a single bit more code than is necessary for our test to pass. This is where we get to forget about all the best practices and performance standards we’ve learned in our professional lives and just write whatever code gets the job done. In the case of our AddShouldLogTheParametersPassedToIt test we just need an Add function with enough code to call the Log function with 1, 2, and a simple message.
Once we create an Add function that accepts two parameters our code compiles so at that point we’ve achieved a Green test. However, when we actually run the test we see that it still doesn’t pass because the Log function isn’t being called. At that point we’re back to having a Red test, and that’s completely normal and acceptable. Now we write just enough code to call the Log function with 1, 2, and a simple message, re-run our test and we see that it passes.
At this point in the process we have a single test, testing a single item, and it passes. We haven’t written any code that isn’t necessary for our test to pass and we haven’t implemented any code that isn’t covered by a test.
"As a rule, software systems do not work well until they have been used, and have failed repeatedly, in real applications." – Dave Parnas
We cannot stress enough that refactoring is the most important part of Test Driven Development. Refactoring applies to the SUT and the tests and absolutely cannot be skipped or ignored. Refactoring code means that we take a look back at what we’ve written and we see if we can do it better. This process might include breaking some code out into separate functions, making our code more performant, adhering to coding practices and standards, or just changing the code to use different methods available in the language to meet the same goal.
Each time we make a change to our code we want to make sure to re-run all of the tests that test that code to make sure that our change, however slight, didn’t break something that already worked. One of the most important benefits of writing unit tests is that we have the ability to refactor our code without worrying whether we changed how (or whether) it works.
We said that the refactor step also includes refactoring tests, which is an important – and often overlooked – part of TDD. There are times when you realize that you’ve arranged 15 tests in exactly the same way. Perhaps the setup of those tests takes 5 lines each. It would be a good practice at that point to separate the setup of those tests into a separate function and then call that function from each of the 15 tests. As long as we run our entire test suite and all the tests still pass after we make the change, we know we haven’t damaged our quality or test suite, but we have made the tests more readable and understandable.
Start Small and Build Great Things
Repeat after me: red, green, refactor. It's that simple and straightforward. It's not easy, but it's simple and you can do it.
Andrew Webster is a software extraordinaire who loves to write code that is clean, testable, and stable. He is a Certified Scrum Master, was a Co-Founder of a tech startup, and worked for one of the largest software consulting companies. You can find him on LinkedIn here.