Wednesday, March 10, 2010

TDD Tidbits: Your unit tests suck (and so does your design)!

TDD is a design process but there are skills required outside of its discipline that you need to master as well. One such skill is writing proper unit tests. Prior to TDD I thought I was unit testing. I really wasn't for a number of reasons. The simple answer is that my design wasn't permitting unit testing and what I was calling "unit" tests were actually integration tests. Classes were tightly coupled, methods were more procedural than OO, I didn't do a good job of separating responsibilities/concerns, etc.

A unit test has some simple requirements you need to fulfill. I'll rattle off a list of them off the top of my head.
  • The test must be fast
  • The test must be repeatable
  • The test must be predictable
  • Only one assertion per test
  • The test must isolate the behavior under observation
Easy, right? You'd think so. I'd heard these in some shape or form before I began to do TDD but it never really hit home. I've seen a couple blog posts with lists like this one so I'll try to describe my thoughts on each of the items above. I'm sure I could go on for hours about them and more so I'll try to keep it brief.

DISCLAIMER: The following code examples are extremely naive and serve only to demonstrate.

The test must be fast

Unit tests are meant to be run frequently. You have them local to your development workstation and they (should) run as part of your continuous integration. Unit tests are regression tests and your first line of defense. They report to you when something is no longer behaving the way it's supposed to. You need that feedback immediately and there's absolutely no reason for them to be slow.

For any system of significant complexity you don't want to wait 30 or even 10 minutes to find out that a bug was introduced. Unit tests should execute in milliseconds and running a whole suite of them should execute within seconds. For example, a project I had at work had around 300 tests that would execute in 4 to 5 seconds. That's the type of speed you should be aiming for.

Having tests that run in the timespan of minutes immediately introduces context switching. What happens when you kick off a full system build for a project that may take a minute or two or more to complete? You open up your browser and see what's going on in the Twitterverse, Facebook, StackOverflow, etc. You don't want that to happen for your testing. It's good to keep focused on the task and to plug along without too much interruption.

The test must be repeatable

This one is simple. I can run a unit test as many times as I want and it will not fail. Take the following for example of what would not be a repeatable test. This is a domain of rabbits. You have some rabbits, you add them to a collection of rabbits. It's a cruel domain and no two rabbits may have the same name. When you run this test a second time it will fail since it's picking up state from the last run. "Thumper" will already be in the database so adding him again will cause an error.


[TestFixture]
public class RabbitFixture
{
[Test]
public void Should_create_rabbit()
{
var rabbitRepo = new RabbitRepository();

rabbitRepo.Add(new Rabbit("Thumper"));

var thumper = rabbitRepo.GetByName("Thumper");

Assert.IsNotNull(thumper);
}
}

public class Rabbit
{
public Rabbit(string name)
{
Name = name;
}

public string Name { get; private set; }
}

public class RabbitRepository
{
private readonly ISessionFactory _sessionFactory;

public void Add(Rabbit rabbit)
{
using (var session = _sessionFactory.OpenSession())
{
if(Exists(rabbit))
{
throw new Exception("Rabbit of the same name already exists!");
}

session.Save(rabbit);
}
}

private bool Exists(Rabbit rabbit)
{
return GetByName(rabbit.Name) != null;
}

public Rabbit GetByName(string name)
{
using (var session = _sessionFactory.OpenSession())
{
return session.Linq<Rabbit>()
.FirstOrDefault(r => r.Name == name);
}
}
}

There are various tricks to make this test repeatable but not without violating some of the other guidelines like test isolation and speed. Unit tests shouldn't rely on external state and likewise they should not be creating any state that will last beyond the life of the test itself. It will be hard to keep the test repeatable and is brittle.

The test must be predictable

1 + 1 will always equal 2, right? Conceptually, your test should do the same. The inputs will always yield an expected output. I don't think this needs much more explanation.

Only one assertion per test

This is one of the more misunderstood practices of unit testing. Don't mistake mapping the word "assertion" to the Assert function that is called within a unit test. In this instance, they are not the same. You may call the Assert function multiple times to assert a single behavior. Take the following example.


[TestFixture]
public class RectangleFixture
{
[Test]
public void Should_resize_rectangle()
{
Rectangle rectangle = new Rectangle(40, 20);

rectangle.ReduceSizeByPercent(50);

Assert.AreEqual(20, rectangle.Length);
Assert.AreEqual(10, rectangle.Width);
}
}

public class Rectangle
{
public Rectangle(int length, int width)
{
Width = width;
Length = length;
}

public double Length { get; private set; }

public double Width { get; private set; }

public void ReduceSizeByPercent(int percent)
{
Length *= (percent * .01);
Width *= (percent * .01);
}
}

There're two calls to Assert to verify that the rectangle's size was reduced by 50%. That's what we're talking about when we say a test makes only one assertion. It's asserting the one behavior. Don't hate yourself if you call Assert more than once and don't feel the need to create another test.

The test must isolate the behavior under observation

Now that we're warmed up let's look at the one guideline to rule them all. This is the one that will have the greatest impact on the design of your code. You have to isolate the behavior you wish to test.

You can read this another way. The unit test cannot fail for any reason other than the implementation of the behavior being tested is incorrect. I have read blogs that mandate that a unit test cannot touch the file system, a database, a network, etc. That's not just because of how slow that may be. It's because now your unit test may fail for any reason related to an external dependency that has absolutely nothing to do with what you're unit testing. When you get the red light that a unit test fails it shouldn't be because you didn't install a database, it shouldn't be because you neglected to include a configuration file, it shouldn't be because your network cable isn't plugged in. Those have nothing to do with the unit test at hand.

Following this guideline will teach you how to detach your class from its external dependencies. The external dependencies aren't just things like databases and LAN access. They can be other classes that have rules of their own that need to be satisfied. You do not want your test to fail if some rule in a class that you consume isn't satisfied. That's outside of the scope of the behavior you're testing.

This is where people start to introduce language like "mocking" or "faking" your external dependencies. These are ways of allowing you to truly isolate the behavior you're trying to test. You're replacing pieces of your class that would cause the test to fail for reasons outside of the class's cares or awareness.

When you first start to really isolate the code you're trying to unit test the first thought that may pop into your mind is that "I'm just writing it this way so that I can test it!" Yes, you are but there is a truth you do not yet realize (and may take some time to settle in). Well designed code is easily testable and easily testable code is well designed. Writing proper unit tests can expose major (or minor) issues in the your design.

For me, this is where I learned the most lessons and what made me truly appreciate things like the SOLID principles. It leads you there and, in my opinion, you would have to go way out of your way to succeed in isolating your tests but avoid good design. It puts bumpers on your bowling lane so to speak.

Summary

Unit testing in and of itself is huge to the design process. Without even engaging in TDD, it is going to highlight deficiencies in your design. There is a lot of talk that TDD isn't about testing, it's about design. Unit testing is also about design so it's a perfect marriage.

No comments: