|
From Unit Testing, Principles, Practices, and Patterns by Vladimir Khorikov In this article, we’ll talk about naming unit tests. Quite a few competing pieces of advice can be found on this topic. Unfortunately, most of them don’t do a good enough job improving your unit tests. I’ll describe some of these naming practices and show why they’re usually not the best choice. I’ll also give you an alternative — a simple set of guidelines for naming tests in a way that makes them readable to not only the programmer who wrote them, but to any other person familiar with the problem domain. |
Take 37% off Unit Testing: Principles, Practices, and Patterns by entering fcckhorikov into the discount code box at checkout at manning.com.
Check out part 1 here.
Naming a unit test
How should you name a unit test? I saw and tried a lot of naming conventions over the past decade. One of the most prominent and, probably, one of the least helpful is the following convention:
[MethodUnderTest]_[Scenario]_[ExpectedResult]
Where:
MethodUnderTest
is the name of the method you’re testing.Scenario
is the condition under which you test the method.ExpectedResult
is what you expect the method under test to do in the current scenario.
It’s unhelpful specifically because it encourages you to focus on implementation details instead of the behavior. On the contrary, simple phrases in plain English do a much better job: they’re more expressive and don’t box you into a rigid naming structure. They allow you to describe the system behavior in a way which is meaningful to a customer or a domain expert.
To give you an example of a test entitled in plain English, here’s the test from listing 4 once again:
public class CalculatorTests { [Fact] public void Sum_of_two_numbers() { double first = 10; double second = 20; var sut = new Calculator(); double result = sut.Sum(first, second); Assert.Equal(30, result); } }
How could the test’s name be re-written using the [MethodUnderTest]_[Scenario]_[ExpectedResult]
convention? Probably something like this:
public void Sum_TwoNumbers_ReturnsSum()
This is because the method under test is Sum
, the scenario includes two numbers, and the expected result is a sum of those two numbers.
The new name looks logical to a programmer’s eye, but does it help the test readability? Not at all. The latter version is all Greek to an uninformed person. Think about it. Why are there two Sum
words in the name of the test? And what’s this Returns
phrasing all about? Where’s the sum returned to? No one but the person who wrote it will have any idea what’s going on here.
You might think that it doesn’t matter what a non-programmer thinks of this name. After all, unit tests are written by programmers, not domain experts. And programmers are good at deciphering cryptic names, it’s their job!
This is true but only to a certain degree. Cryptic names impose a cognitive tax on everyone, programmers or not. They require additional brain capacity to figure out what exactly the test verifies and how it relates to business requirements. It may not seem like much but the mental burden adds up over time. It slowly but surely increases the maintenance cost for the entire test suite. It’s noticeable if you return to the test if you forgot about the feature’s specifics, or try to understand a test written by a colleague. Reading someone else’s code is already difficult enough. Any help understanding it is of much use.
Here are the two versions again:
public void Sum_of_two_numbers() public void Sum_TwoNumbers_ReturnsSum()
The initial name written in plain English is much simpler to read. It is a down-to-earth description of the behavior under test.
Unit test naming guidelines
Here are basic rules for naming tests:
- No rigid naming policy. You can’t fit a high-level description of a complex behavior into a narrow box of such a policy. Allow freedom of expression.
- Name the test as if you were describing the scenario to a non-programmer person who is familiar with the problem domain. A domain expert or a business analyst are good examples.
- Separate words by underscores. It helps improve readability of long names.
Notice that I didn’t use underscores when naming the test class, CalculatorTests
. Classes’ names are normally not long and they read fine without underscores.
Also notice that although I use the pattern [ClassName]Tests
when naming test classes, it doesn’t mean that the tests are limited to verifying only that ClassName
. Remember, the unit in unit testing is a unit of behavior, not a class. This unit can span across one or several classes, the size is irrelevant. Still, you have to start somewhere. View the class in [ClassName]Tests
as that: an entry point, an API, using which you can verify a unit of behavior.
Example: renaming a test towards the guidelines
Let’s now take a test as an example and try to gradually improve its name using the guidelines above. In listing 10, you can see a test verifying that a delivery with a past date is invalid. The tests name is written using the rigid naming policy that doesn’t help with the test readability.
Listing 10. A test named using the rigid naming policy.
[Fact] public void IsDeliveryValid_InvalidDate_ReturnsFalse() { DeliveryService sut = new DeliveryService(); DateTime pastDate = DateTime.Now.AddDays(-1); Delivery delivery = new Delivery { Date = pastDate }; bool isValid = sut.IsDeliveryValid(delivery); Assert.False(isValid); }
This test checks that DeliveryService
properly identifies a delivery with an incorrect date as invalid. How would you re-write the test’s name in plain English?
The following is a good first try:
public void Delivery_with_invalid_date_should_be_considered_invalid()
Notice two things in the new version:
- The name now makes sense to a non-programmer. Which means that programmers have an easier time understanding it too.
- The name of the SUT’s method —
IsDeliveryValid
— is no longer part of the test’s name.
The second point is a natural consequence of rewriting the test’s name in plain English and can be easily overlooked. This consequence is important and can be elevated into a guideline of its own.
|
Don’t include the name of the SUT’s method in the test’s name. |
Remember, you don’t test code, you test application behavior. Therefore, it doesn’t matter what you name of the method used in the test. As I mentioned previously, the SUT is an entry point, a means to invoke a behavior. You can decide to rename the method under test to IsDeliveryCorrect
and it has no effect on the SUT’s behavior. Still, if you follow the original naming convention, you need to rename the test. This once again shows that targeting code instead of behavior couples tests to that code’s implementation details, which negatively affects the test suite maintainability.
The only exception to this guideline is when you work on utility code. Such code doesn’t contain business logic — its behavior doesn’t go much beyond simple auxiliary functionality and doesn’t mean anything to the business people. It’s fine to use the SUT’s method names there.
Let’s get back to our example. The new version of the test’s name is a good start but it can be improved further. What does it mean for a delivery date to be invalid, exactly? From the test in listing 10 we can see that an invalid date is any date in the past. Which makes sense — you should only be allowed to choose a delivery date in the future.
Let’s be specific and reflect this knowledge in the test’s name:
public void Delivery_with_past_date_should_be_considered_invalid()
This is better, but still not ideal. It’s too verbose. We can safely get rid of the word considered
without any loss of meaning:
public void Delivery_with_past_date_should_be_invalid()
The wording should be is another common anti-pattern; a test is a single, atomic fact about a unit of behavior. When stating a fact there’s no place for a wish or a desire. Name the test accordingly.
Replace should be with is:
public void Delivery_with_past_date_is_invalid()
Finally, there’s no need to avoid basic English grammar. Articles help the test read flawlessly. Add an indefinite article (“a”) to the test’s name:
public void Delivery_with_a_past_date_is_invalid()
There you go. This final version is a straight-to-the-point statement of a fact which describes one of the facets of the application behavior under test. In this particular case — determining whether a delivery can be done.
Refactoring to parameterized tests
One test is usually not enough to fully describe a unit of behavior. Such a unit normally consists of multiple components, each of which should be captured with its own test. If the behavior is complex enough, the number of tests describing it can grow dramatically, and may become unmanageable. Luckily, most unit testing frameworks provide functionality which allows you to group similar tests (see figure 2).
Figure 2. A typical application exhibits multiple behaviors. The higher the complexity of the behavior, the more facts it takes to fully describe. Each fact is represented by a test. Similar facts can be grouped into a single test method.
Let’s say that our delivery functionality works in such a way that the soonest allowed delivery date is two days from now. Clearly, the one test we currently have is not enough. In addition to the test checking a past delivery date, we’ll also need tests that check for today’s date, tomorrow’s date, and the date after that.
The existing test is called Delivery_with_a_past_date_is_invalid
. We could add three more:
public void Delivery_for_today_is_invalid() public void Delivery_for_tomorrow_is_invalid() public void The_soonest_delivery_date_is_two_days_from_now()
This results in four test methods, with the only difference between them being the delivery date.
A better approach is to group these tests into one in order to reduce the amount of test code. xUnit (and most of other test frameworks) has a feature called parameterized tests which allows you to do exactly that. Listing 11 shows how such grouping looks. Each InlineData
attribute represents a separate fact about the system, it’s a test case in its own right.
Listing 11. A test that encompasses several facts.
public class DeliveryServiceTests { [InlineData(-1, false)] (1) [InlineData(0, false)] (1) [InlineData(1, false)] (1) [InlineData(2, true)] (1) [Theory] public void Can_detect_an_invalid_delivery_date( int daysFromNow, (2) bool expected) (2) { DeliveryService sut = new DeliveryService(); DateTime deliveryDate = DateTime.Now .AddDays(daysFromNow); (3) Delivery delivery = new Delivery { Date = deliveryDate }; bool isValid = sut.IsDeliveryValid(delivery); Assert.Equal(expected, isValid); (3) } }
1The InlineData attribute sends a set of input values to the test method. Each line represents a separate fact about the behavior.
2 Parameters to which the attributes attach the input values
3 Uses the parameters
Notice the use of the [Theory]
attribute instead of [Fact]
. A theory is a bunch of facts about the behavior. Each fact is now represented by an [InlineData]
line rather than a separate test.
I also renamed the test method into something more generic. It doesn’t mention what constitutes a valid or invalid date anymore.
Note that I didn’t name the test Detects_an_invalid_delivery_date
, although this version is shorter. This is because DeliveryService
doesn’t always detect an invalid delivery date; only when it’s invalid. Hence the name Can_detect_an_invalid_delivery_date
: it may or may not detect it, depending on what date we specify for the delivery.
Parameterized tests allow you to significantly reduce the amount of test code but this benefit comes at a cost. It’s now hard to figure out what facts the test method represents. And the more parameters there are, the harder it becomes.
As a compromise, you can extract the positive test case into its own test and benefit from the descriptive naming where it matters the most — in determining what differentiates valid and invalid delivery dates (listing 12).
Listing 12. Two tests verifying the positive and negative scenarios.
public class DeliveryServiceTests { [InlineData(-1)] [InlineData(0)] [InlineData(1)] [Theory] public void Detects_an_invalid_delivery_date(int daysFromNow) { /* ... */ } [Fact] public void The_soonest_delivery_date_is_two_days_from_now() { /* ... */ } }
It also simplifies the negative test cases, because you can remove the expected
boolean parameter from the test method. You can transform the positive test method into a parameterized test as well, to test multiple dates.
As you can see, there is a trade-off between the amount of test code and the readability of that code. As a rule of thumb, keep both positive and negative test cases together in a single method only when it’s self-evident from the input parameters which case stands for what. Extract the positive test cases otherwise. And if the behavior is too complicated, don’t use the parameterized tests at all.
Represent each negative and positive test case with its own test method.
Generating data for parameterized tests
Notice that in listing 11, I used the daysFromNow
parameter as an input to the test method. Why not the date and time, you might ask? Unfortunately, this code won’t work (listing 13).
Listing 13. Calls to runtime don’t work in attributes.
[InlineData(DateTime.Now.AddDays(-1), false)] [InlineData(DateTime.Now, false)] [InlineData(DateTime.Now.AddDays(1), false)] [InlineData(DateTime.Now.AddDays(2), true)] [Theory] public void Can_detect_an_invalid_delivery_date( DateTime deliveryDate, bool expected) { DeliveryService sut = new DeliveryService(); Delivery delivery = new Delivery { Date = deliveryDate }; bool isValid = sut.IsDeliveryValid(delivery); Assert.Equal(expected, isValid); }
In C#, the content of all attributes is evaluated at compile time, and you have to use only those values which the compiler can understand: constants, literals, and typeof()
expressions. The call to DateTime.Now
relies on the .NET runtime and isn’t allowed.
A way to overcome this problem can be found with xUnit, which has a feature to generate custom data to feed into the test method: [MemberData]
. Listing 14 shows how we can re-write the above test using this feature.
Listing 14. Generating complex data for the parameterized test.
[Theory] [MemberData(nameof(Data))] public void Can_detect_an_invalid_delivery_date( DateTime deliveryDate, bool expected) { /* ... */ } public static List<object[]> Data() { return new List<object[]> { new object[] { DateTime.Now.AddDays(-1), false }, new object[] { DateTime.Now, false }, new object[] { DateTime.Now.AddDays(1), false }, new object[] { DateTime.Now.AddDays(2), true } }; }
MemberData
accepts the name of a static method which generates a collection of input data (the compiler translates nameof(Data)
into a "Data"
literal). Each element of the collection is a collection which gets mapped into the two input parameters: deliveryDate
and expected
. This feature allows you to overcome the compiler’s restrictions and use parameters of any type in the parameterized tests.
Using an assertion library to further improve test readability
One more thing you can do to improve test readability is to use an assertion library. I personally prefer Fluent Assertions (https://fluentassertions.com/) but there are several competing libraries in .NET in this area. Shouldly is a good example (https://github.com/shouldly/shouldly).
The main benefit is how, with an assertion library, you can restructure the assertions to look more readable. Here’s one of our earlier tests:
[Fact] public void Sum_of_two_numbers() { var sut = new Calculator(); double result = sut.Sum(10, 20); Assert.Equal(30, result); }
Now compare it to the following:
[Fact] public void Sum_of_two_numbers() { var sut = new Calculator(); double result = sut.Sum(10, 20); result.Should().Be(30); }
The assertion from the second test reads like plain English, which is exactly how you want all your code to read. What is important here is word order: we prefer to absorb information in the form of stories. All stories adhere to the specific pattern:
[Subject] [action] [object].
For example:
Bob opened the door.
Here, Bob
is a subject, opened
is an action and the door
is an object. The same rule can be applied to code. result.Should().Be(30)
reads better than Assert.Equal(30, result)
precisely because it follows the story pattern. It’s a simple story in which result
is a subject, should be
is an action, and 30
is an object.
By the way, the paradigm of Object-Oriented Programming (OOP) has come to a success partly because of this readability benefit. It too allows you to structure the code in a way that reads like a story.
The Fluent Assertions library also provides numerous helper methods to assert against numbers, strings, collections, dates and time, and much more. The drawback is that such a library is an additional dependency which you might not want to introduce to your project.
That’s all for this article.
If you want to learn more about the book, check it out on liveBook here and see this slide deck.