While specific methodologies such as TDD still attract a lot of controversy, I think it’s fair to say that unit testing itself is a lot closer to a consensus. We’re clearly not there yet, but we’re getting closer and closer.
With that in mind, what can a software developer who is still a unit testing novice do to ensure their unit tests are good, maintainable, and reliable? If you match the description above, then today’s post is for you. We’ll show you five simple tips you can follow to improve the quality of your tests.
They Have Good Names
Some people say you should think of a unit test not just as a test but also as a kind of specification for the software. I wholeheartedly agree with this statement and one of its most important consequences is that a good, maintainable unit test should be very easy to read.
And that starts with its name. The name of the test should be written in a way that is extremely easy for the developer to quickly understand what exactly went wrong or when the test fails. OK, we agree that naming is important. But how do we go about that, since there are so many different conventions?
One of the Kata’s requirements states that the “Add” method should return 0 if you provide it an empty string. Using the above-mentioned convention, you could name the test “Add_WhenGivenEmptyString_ReturnsZero” or something even simpler.
One of the possible drawbacks of this convention is that it will require you to rename the tests when you rename a production method. It’s a valid criticism, but you should also have in mind that constantly renaming methods in your public API isn’t such a good thing to do.
Anyway, this is just one of the many possible naming conventions out there. You could use it as is, maybe tweak it a bit to fit your needs, or even use another convention entirely. What really matters is that you name your tests in such a way that it’s as easy as possible to understand what went wrong when they break.
They Follow the “Arrange, Act, Assert” Structure
The “Arrange, Act, Assert” pattern is a widely known way to structure the code in a unit test. It consists of breaking up the code inside a unit test into three clearly divided groups, each one representing a phase in the test:
- Arrange. In this phase, you do whatever preparation you need in order to run your test. Instantiation of the System Under Test will usually happen in this phase.
- Act. The name pretty much says it all. In this phase, you generally do whatever action you want to test.
- Assert. Finally, it’s time to verify if we’ve got the desired results.
What does that look like in practice? Basically, write your tests in such a way that the phases are clearly recognizable and then respect each part. Don’t do assertions on the Act phase, don’t arrange in the Assert phase, and so on.
Some people will even insist that you add comments indicating each phase. I don’t think that’s entirely necessary, but if it makes things easier for you, then go for it! The following code shows an example of unit testing with comments delimiting each phase:
public void Construction_Properties()
LocalDate start = new LocalDate(2000, 1, 1);
LocalDate end = new LocalDate(2001, 6, 19);
var interval = new DateInterval(start, end);
The example above was taken from the Noda Time project and edited to add comments. Whether you use comments, blank lines, or even another type of formatting strategy, the only thing that really matters is that the phases are easily recognizable.
They Don’t Duplicate Production Code
You know what’s worse than having no tests at all? To have tests that lie, give you a false sense of security, or most of all— to have tests that pass when they should be breaking. And a great way to achieve this awful outcome is to duplicate logic from the production code in the tests.
What does that mean? To answer that, let’s insist on using the String Calculator Kata. Let’s say you have a test like the following one:
[TestCase("1, 2", 3)]
[TestCase("5, 2", 7)]
[TestCase("4, 9", 13)]
public void Add_PassingTwoNumbers_ReturnsTheirSum(string numbers, int expectedResult)
Assert.AreEqual(expectedResult, StringCalculator.Add(numbers, expectedResult));
The test passes and all is fine with the world. But you’re a developer, which means that you’re pretty much guaranteed to, eventually, have the following thought:
It’s kind of ugly to hardcode the expected values like this. Maybe there’s a better way?
And then you create something like this:
public void Add_PassingTwoNumbers_ReturnsTheirSum(string numbers)
var expectedResult = numbers.Split(',').Select(int.Parse).Sum();
Assert.AreEqual(expectedResult, StringCalculator.Add(numbers, expectedResult));
What’s wrong with the code above? As far as I can tell, nothing. I’ve just run the test on my machine and it worked just fine. So what’s the matter?
By trying to automate the generation of the expected value, you’re potentially duplicating the production code in the test (in this particular case, I’m definitely doing it since I just copied the code from the “Add” method). But again: what’s the matter?
The matter is that this is very dangerous. In the eventuality that the production code is wrong, the test would also be wrong. Not only that, though: of all the infinite ways it could be wrong, it’ll be wrong in the exact way that will make the test pass.
This is particularly dangerous when doing TDD since both the production and test code are developed concomitantly and generally by the same person (if they’re not pair-programming).
And then you’ll have one of the worst possible scenarios: the code is wrong but the test passes, which can cause you to deploy buggy software to your users but also can undermine the team confidence in the discipline of unit testing itself.
They Don’t Contain Loops and/or Conditional Logic
This is related and sort of a continuation of the previous point. Your unit tests shouldn’t contain loops or decision structures. The reasoning behind this documentation is two-fold:
- First, tests with conditionals and loops become harder to read (which calls back to the first point.
- But more importantly, tests containing these constructs might contain bugs themselves.
Think of it this way: how can you trust a test that’s so complicated to the point of needing to be tested itself?
OK, I swear I can even hear you arguing: “But man, I really need to iterate over this list in my test because yada yada.” In this case, here’s what you should do: move the code that performs the looping/conditional logic for a dedicated, utility class in your testing assembly. From your main test, you call the utility class.
And of course: write tests for the utility class itself!
They are Truly Independent
Your tests should be truly independent. “Independent from what?” you may ask. Everything, everyone. What do I mean by this?
For starters, each unit test should be independent of the other unit tests. If your test suite requires that the tests should be run in a certain order, then they’re not really unit tests. If you have tests A, B, and C, then each one of the following scenarios should result in the tests passing:
- You run only A.
- You run only B.
- You run only C.
- You run all of them, from C to A.
- You run all of them, from A to C.
- You run all of them, 100 times each.
- Any other combination you can think of.
The tests should also be totally independent of the outside world. They shouldn’t rely on a database or some file. Nor should they rely on specific things about the context of the machine they’re being executed on, such as the system’s clock or the system’s current culture.
Is That All There is to it?
Is that all there is to writing good unit tests? Of course not. Entire books have been written about the subject. There are plenty of courses online—both paid and for free—offering to teach you strategies and techniques to master unit testing. It’s obvious that I could never exhaust the subject in a humble blog post.
That being said, I do believe that the tips we’ve provided are a great starting point. With this framework we just gave you, you now have a very solid foundation on which to build your knowledge of unit testing techniques.
As they say, “Practice makes perfect.” Continue working, never stop practicing. Happy testing!
Help us improve this page!
What problem are you trying to solve?