Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / STL

I'’m hooked on test-driven development (TDD)

4.67/5 (18 votes)
28 Apr 2015CPOL8 min read 20.8K  
I'’ve only been doing TDD for a few weeks, but I’'m completely sold.  I don’t want to go back!  I’'ll be honest though, it hasn'’t been easy. 

Introduction

I've only been doing TDD for a few weeks, but I'm completely sold. I don't want to go back! I'll be honest though, it hasn't been easy. I've made mistakes, I've wasted time, but I'm really starting to reap the benefits.

I've always thought I was a good developer. I write decent code and it works mostly as expected. It took me many years into my career before I wrote my first unit test. It always fell into the category of too time consuming or expensive. Oh the irony!

As I started learning how to write to unit tests, I always found myself rewriting things I already did just to get them to be unit tested; how frustrating! A unit test that should have only took a few minutes, ended up taking a really long time because the code had to be refactored just to be tested. No better way to turn you off from unit testing.

Enter test-driven development

TDD is a discipline. It requires much smaller steps than the average person is used too. It also requires you to break rules just to get a 'green bar'. Without persistence and the ability to see the bigger picture, these rules can easily make anyone give up.

When I first started I immediately realized my pace of output got slower in my mind. It felt slow because 20 - 30 lines of unit testing setup might equal less than 10 lines of 'tangible' code. This is a hard pill to swallow as you start.

What would take me 15 minutes is now taking me 30 minutes. However, the end result is incredible. My code is so much cleaner and much more accurate. It's important to not focus on the small increases in time at the beginning, trust me it all washes out in the end.

The best analogy I've heard is the tortoise and the hare. I used to be the hare, racing through the logic and lines of code. Only to hit what I thought was the finish line to find out that I had to spend time debugging issues that arouse along the way (that I didn't catch). Unfortunately, this is where I (the hare) began to get tired and slow down. Issues became harder to detect. I found them, but the cost was high as my energy got lower.

Meanwhile, the tortoise (me while doing TDD) is moving along at a nice pace addressing much simpler, smaller pieces of logic to eventually surpass the hare and win the race (or maybe tie).

Enough of the analogies, I think it's time to - you guessed it - test-drive this article with some examples. I'll start with the first example that I ever TDD'ed - the Fizz Buzz example. This coding test was first discussed by Jeff Atwood to help determine who is a good developer.

Fizz Buzz works as follows. It receives a number as input and it outputs one of the following 4 things:

  • If the number is divisible by three, the function should output Fizz.
  • If the number is divisible by five, the function should output Buzz.
  • If the number is divisible by both three AND five, the function should output Fizz Buzz.
  • Otherwise, the number entered is simply returned as output.

I am going to write this in C#. I'm going to write this in a new Unit Test Project. For simplicities sake, I will write both the tests and the resulting function in the same class.

Now before I begin, there are a few different options of how this could be TDD'ed:

  • One test method per expectation.
  • One test method with multiple asserts.

To me both are valid and the one I choose depends on the complexity of the functionality. Because this is a blog article, it might work better with multiple small test methods instead of a constantly growing one.

Let's begin. Here is the first test method:

C#
[TestMethod] 
public void Given1Expect1() 
{ 
    var expected = 1; 
    var actual = FizzBuzz(1); 
    Assert.AreEqual(expected, actual); 
} 

As you will notice, the code currently doesn't compile because the FizzBuzz function doesn't exist. So the first rule of TDD is to get the code to compile and a green bar as fast as possible. Here is the simplest solution I can find:

C#
private object FizzBuzz(int p) 
{ 
    return 1; 
} 

There are a few interesting things to point out here. Because I wrote my tests using var and I used my IDE to automatically create the function for me (because it didn't exist) it detected the correct input type as int but wasn't sure what the output type should be, so it made it an object. Ironically this isn't a bad choice as we will see shortly.

The next thing to notice is I simply returned a constant value of 1. If I were coding this normally I wouldn't have done it, I would've returned p – the input parameter. I wanted to show the incremental steps.

There are two possible next steps that I see:

  • Remove the duplication
  • Write another simple test to force us to remove the constant

Given that I see the number 1 three times in less than 10 lines of code, I think removing the duplication makes most sense. Here is my refactoring to remove duplication of the number 1:

C#
[TestMethod] 
public void Given1Expect1() 
{ 
    var expected = 1; 
    var actual = FizzBuzz(expected); 
    Assert.AreEqual(expected, actual); 
} 

private object FizzBuzz(int p) 
{ 
    return p; 
}

After refactoring, it's a good time to run the tests again and make sure we still have a green bar. As expected we do. The nice part about this refactoring is we don't need to write another test that might be called Given2Expect2, I think we can all be confident in our previous refactoring.

Here is the next obvious test I see:

C#
[TestMethod] 
public void Given3ExpectFizz() 
{ 
    var expected = "Fizz"; 
    var actual = FizzBuzz(3); 
    Assert.AreEqual(expected, actual); 
} 

As expected, this new test fails. Time to update our function to get a green bar. Once again I will go with the simplest solution:

C#
private object FizzBuzz(int p) 
{ 
    if (p == 3) 
        return "Fizz"; 

    return p; 
} 

Running the tests shows a green bar. Now we can refactor with confidence that we have our security blanket in case something goes wrong.

Because this example is pretty simple, we have the same opportunity as before. We can write a simple test to show how the constant won't solve the long-term problem or we can refactor the solution to not use the constant conditional to 3.

C#
private object FizzBuzz(int p) 
{ 
    if (p % 3 == 0) 
        return "Fizz"; 
        
    return p; 
} 

After refactoring, it's important to run the tests and ensure we still have green.

On to the next unit test:

C#
[TestMethod] public void Given5ExpectBuzz() 
{ 
    var expected = "Buzz"; 
    var actual = FizzBuzz(5); 
    Assert.AreEqual(expected, actual); 
} 

Because of our previous refactoring, it's safe to skip the use of a constant and instead apply Obvious Implementation. I often will use this rule as much as possible as I can better control the size of steps I take during my TDD efforts:

C#
private object FizzBuzz(int p) 
{ 
    if (p % 3 == 0) 
        return "Fizz"; 
        
    if (p % 5 == 0) 
        return "Buzz"; 
    
    return p; 
} 

Once again we have green. At this point I don't see any obvious refactoring, so let's write another test:

C#
[TestMethod] public void Given15ExpectFizzBuzz() 
{ 
    var expected = "FizzBuzz"; 
    var actual = FizzBuzz(15); 
    Assert.AreEqual(expected, actual); 
} 

As expected we have a red bar:

C#
private object FizzBuzz(int p) 
{ 
    if (p % 3 == 0 && p % 5 == 0) 
        return "FizzBuzz"; 
        
    if (p % 3 == 0) 
        return "Fizz"; 
    
    if (p % 5 == 0) 
        return "Buzz"; 
    
    return p; 
} 

Once again the bar is green. But look at all that duplication! 3's and 5's splattered all over this function. Given that we have all green, it's safe to refactor this function. There really are hundreds of ways to refactor this function. The simplest one to me is leveraging one more magic number.

C#
private object FizzBuzz(int p) 
{ 
    if (p % 15 == 0) 
        return "FizzBuzz"; 
    
    if (p % 3 == 0) 
        return "Fizz"; 
    
    if (p % 5 == 0) 
        return "Buzz"; 
    
    return p; 
} 

I see one more unit test still. The minimum input value is 1. What will happen if 0 is entered? I think an exception should be thrown because it's invalid input:

C#
[TestMethod] 
[ExpectedException(typeof(IndexOutOfRangeException))] 
public void Given0ExpectException() 
{ 
    FizzBuzz(0); 
} 

Once again we have a red bar that we need to solve. Knowing that anything less than 1 is invalid, let's use obvious implementation to solve the red bar:

C#
private object FizzBuzz(int p) 
{ 
    if (p < 1) 
        throw new IndexOutOfRangeException(); 
    
    if (p % 15 == 0) 
        return "FizzBuzz"; 
    
    if (p % 3 == 0) 
        return "Fizz"; 
    
    if (p % 5 == 0) 
        return "Buzz"; 
    
    return p; 
} 

I sure feel pretty confident in this code now. More importantly, if the specifications ever changed, we have a solid foundation of tests to allow us to update with confidence.

As you can see from this very small TDD example, the steps from writing a test to writing the code feels slow and took a total of 13 steps! The final result of the FizzBuzz function could probably be written in one giant step; however, by doing TDD we saved future us two important things:

  1. If we messed up and caused a bug, it would be harder to find in 10 lines of code versus 1 or 2 lines of code that we were adding at a time.
  2. If the requirements change, we have a safety net to work from. Knowing how often requirements change in this industry, this is an important net to me.

Summary

Since starting TDD, I've made a few mistakes. Here are the lessons I've learnt from them and hopefully it will help you getting started:

  1. If you are writing a function that is to be used by another function and the tests for this function feel too simple, try stopping and think whether you should test the other function instead that will use the results. An example of this might be if you are writing a function that returns an integer offset that will be used to calculate a date offset. The function that returns the integer offset might be too small to test - unless there are important business rules happening inside this function. Instead, test the function that calculates the date offset. The date object that results from using the offset integer is probably more important.
  2. If you find yourself spending more time writing your setup functions for your unit testing, the class you are trying to test is probably doing too much. This is a good opportunity to stop yourself from spending a lot of time writing mock objects and see if there is a way you can simplify the class and reduce its responsibility.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)