I’m intending this to be the first of a series on Unit Testing. In the series, I’ll discuss the basics of unit tests, the principles behind them, what makes a good unit test, what makes a bad unit test, and the technologies that you may choose to use to help you with them. I will not be covering test-driven development - this is simply about the mechanics and the reasons, not the methodology.
In this article, we’ll talk about what a unit test is, and how you might structure one. We will not be using any external tools for this, and what we do here should be possible in just about any language.
What is a Unit Test
A unit test is any way in which a single unit of functionality can be verified: it doesn’t have to be written before the code to be a test, it doesn’t have to be written in a test framework to be a test; it just has to run the code, and have some way of telling that the code has worked (the term “worked” is filled with ambiguity, but we’ll ignore that for the minute).
There are typically three parts to a unit test: they vary based on methodology, but essentially they are that you set-up the test, run the test, and check that the test worked; this is sometimes referred to as arrange, act, and assert.
For this section, we’ll be testing the following simple method:
int AddNumbers(int a, int b) => a + b;
This is the part of the test where you configure the system under test (SUT). Given that you’re only testing a single piece of functionality, this can sometimes be quite involved, in order to get the system to place where it is ready to be tested, and actually running in a realistic manner. For our example above, this may look similar to the following:
// Arrange int a = 4; int b = 2;
Remember, we’re not using any external tools just yet - the above code could simply be in the Program.cs of a console application, or whatever the equivalent is in your language of choice; that is, just a simple program.
The next part of the test actually exercises the code. The key thing here is that this is a unit test, so you would expect this to test a single unit of functionality; i.e., this should be a single line. In our case, it might look like this:
// Act int result = AddNumbers(a, b);
We’ll come back to concepts such as mocking later in the series, but for now, let’s just agree with the comment that this part should exercise actual code; for example, there would be no advantage to the following code:
// Act int result = a + b;
Your test may pass, but all you’re really testing is you compiler / interpreter. Writing tests that don’t actually test anything that you’re interested in is one of the biggest mistakes that I’ve seen with people new to unit testing. I would argue that having no tests at all is more valuable than a test that appears to provide coverage, but does not. After all, if there is no test, then you know that you need to create a test.
The final part of the test is to validate that the test passes - arguably this is around 50% of what you get from a testing tool like XUnit or JUnit - however, the following will work:
// Assert System.Diagnostics.Debug.Assert(result == 6);
As in fact, will the more universal:
// Assert if (result != 6) throw new Exception("Fail");
Unlike with the Act section, you can check several things are true; however, the test should be geared towards a single assertion. It’s worth bearing in mind that your assertion is that the functionality works correctly, not that a specific result is produced. This means that the test that we’ve discussed in this post is too specific.
Broadening a Test
Thinking about other possible scenarios, it’s tempting to introduce a randomised element into the test; that is, given two random numbers, the function will return the same result as that which the system independently calculates. I’m not saying this is a bad approach, but it isn’t a consistent one. This kind of test often leads to tests failing on some runs, and passing on others.
I have no doubt that this has fallen out of favour somewhere, but the FIRST acronym provides some useful principles for testing:
Fast. Independent. Repeatable. Self-validating. Timely.
I won’t cover each one of these, but the essence of this principle is that when you run a test, you should have confidence that you can re-run the test with the same result (given the same inputs), and that your tests should be relevant to what you’re testing.
How to Broaden Our Test
Given our constraints, one easy way to broaden the test scope is to simply introduce multiple defined input parameters. In our case, perhaps instead of having two integers to feed in, we have an array and iterate through the array.
Naming a Test
The final thing that I want to cover in this first section is naming. There are many opinions on this, so there’s no right way; however, there probably are wrong ways. In general terms, the test should be named in a way that any person reading it could ascertain what is being tested; one popular version of this is to use the Given/When/Then form:
Another one, that I personally use, is the format: Method Name/State Under Test/Expected Result; for example:
The key here is consistency (i.e., don’t mix and match), and clarity; the following is an example of a bad test name:
In the next post in this series, we’ll talk about more complex tests, and mocking.