Unit testing as a concept is simple. Write code to test code. It seems, though, that everybody forgets one fact about coding: It is really hard!
I do not think it will be a stretch to say that most production codebases lack even a decent number of unit tests. Further, I am pretty sure a lot of those unit tests tend to be flaky and fragile and at times less than useful.
Plenty of articles from people way smarter than I exist on this topic, but I would like to give some insight into the way I encourage our developers to write unit tests at NML. We did a on this topic, but could not get to as much detail in the session as I would have liked. My previous article on unit tests pointed out the importance that unit tests should play in a developer's day-to-day job. This article gives outlines essentials on how to really get past those hurdles that make unit testing hard.
The first big mistake is to test how the target code works. The key insight is to test specific behavior in the code. The what.
The simplest strategy is to start at the top, and read through your code, identifying key entry points, and specific behavior.
I define behavior here as:
A single logical execution path from a public entry point up to where it returns or throws
public class Dodo
{
private IHabitat _habitat;
public Point Postion { get; }
public Dodo(IHabitat habitat, Position initialPosition)
{
_habitat = habitat
?? throw new ArgumentNullException(nameof(habitat));
Position = position;
}
public void Fly(Direction direction)
{
if (!CanStillRun(direction))
throw new EndOfTheLineException(direction);
Move(direction);
}
private bool CanStillRun(Direction direction)
{
return _habitat.HasSpaceLeft(direction, _position);
}
public void Move(Direction direction)
{
// Don't be silly! Dodos can't fly.
// They just sit around and do nothing.
}
}
The trivial example class above will have several specific behaviors, and if we test each of those, then we will get 100% code coverage, and we will have tests that catch issues not only when somebody changes the codebase, but also when they change expect behavior.
Reading from the top, there are declarations, which by themselves are not testable. Then we hit the first entry point, which of course, is the constructor. Since we are at an entry point, we have to start identifying behaviors. The very first line in the constructor is doing something, and in fact, thanks to C# syntactic sugar it is doing two somethings.
The first thing it does is check if the habitat variable is null, and if it is it throws an exception. That is one complete behavior because the throw statement will exit the entry point. You must, therefore, write a test to cover that.
public void ConstructorThrowsWhenHabitatIsNull()
{
Action construct = () => new Dodo(null, _initialPosition);
construct.Should().Throw<ArgumentNullException>("*habitat*");
}
The above tests use the FluentAssertions testing library, which is excellent, and I highly recommend it. Ignoring the syntax though, notice that the test is short and that it checks for only one behavior, the throw clause.
After we have written and run our test, we start at the same entry point and follow the alternate path from that line onwards. The next behavior is the assignment to the _habitat
field. Execution will not stop there, so we continue reading. On the next line, we assign the Position property, which is still not exiting. Lastly, execution will hit the end brace (})
of the constructor, and the entry point is exited. That is a complete behavior and we, therefore, need a test.
public void Constructor()
{
var dodo = new Dodo(_mockHabitat.Object, _initialPosition);
dodo.Should().NotBeNull();
dodo.Position.Should().Be(_initialPosition);
}
The test above verifies that the constructor will run to completion and return a non-null instance of a Dodo, and also that any public items affected by the behavior are what we expect.
The key takeaway here is that to verify the success or failure of a behavior you have to not only check the result, but also any side-effects.
This article will be way too long if I continue with the example, but I think the above serves to show how to go about it. Using this method, the next behaviors are:
_habitat.HasSpaceLeft returns
false
_habitat.HasSpaceLeft
returns true
Unit tests should verify behavior, not implementation
If the "D" from the SOLID principles (dependency injection) is not at the very core of every class you design and write, you will not be able to write successful unit tests. It is just that simple. Your unit tests will be fragile, they will be unreliable, and they will cause massive maintenance headaches as unrelated tests to the actual code change will break in a cascading fashion.
At NML I insist on a DI mindset. Effective dependency injection requires interfaces If you keep to injecting only interfaces to all your classes, you will have a pleasant time writing unit tests for them. Yes, you will have a lot of interfaces that potentially have only one concrete in your codebase, but it is worth it just from the benefits you gain from being able to test your code in complete isolation.
If you are using a dependency from a third-party library that does not provide an interface you can mock out, wrap it with the proxy pattern. Ideally, the only classes you should be unable to test in isolation should be proxies. Ideally... ;-)
Unit testing requires Inversion of Control
The previous two tips are integral to get the most out of libraries like Moq, RhinoMocks, and FakeItEasy. The important thing to remember about mocking dependencies is that you only mock what you need when you need it.
When you follow the "read from top to bottom" approach outlined above, you will be forced to have a mock version of all the target class dependencies by the time you completed all the behavior tests for the constructor. At that stage, they should not be configured to return or respond to anything that was not required in the constructor.
In our example above, I would only add a response configuration for the IHabitat.HasSpaceLeft method once I identify that it is needed to test the behavior(s) passing that method call.
Unfortunately, a lot of developers tackle writing unit tests a lot like the tackle writing classes. They try to think of as a whole and plan out fields and methods at the start.
Another important thing to remember is that you must verify that methods and properties on dependencies were called with the expected arguments. It is part of the behavior to call a dependency with specific data, which might undergo some kind of transformation.
Unit tests should be discovered by investigating behavior
These three essential concepts, along with accepting that unit testing is your job, will improve your code coverage, improve your code quality, and prevent you from chasing down elusive problems later that would have been caught earlier with good unit test coverage.