This is part 2 of my series on unit testing in .NET (C#) and will be about my conventions regarding naming unit tests, how to structure them and which parts to test. And lastly I will help you increase readability of assertions and potential error messages with the FluentAssertions library.
- Part 1: Getting started
- Part 2: Naming, structure, and readability (FluentAssertions)
- Part 3: Mocking and writing testable code (NSubstitute)
- Part 4: Keeping it DRY and maintainable (AutoFixture)
- Part 5: Test Driven Development (TDD) with Live Unit Testing (LUT)
Naming tests
Test method names should generally be detailed, descriptive and readable.
We don't really care about normal naming conventions here, because we want to be able to tell what a test is supposed to test for at a glance. This also helps when a test inevitably fails; you don't have to dig into the code to figure out what scenario is actually failing.
The test class itself should normally just match the class your are testing postfixed with Tests
, e.g. BasketTests
.
For the naming of the test methods I usually go by a pattern like this {method-name}_{scenario}_{expected-outcome}
. Some examples:
AddItem_NullItem_ThrowsArgumentNullException
AddItem_NewItem_AddsNewItem
AddItem_ExistingItem_DoesNotAddNewItem
AddItem_ExistingItem_ShouldIncreaseQuantityOfExistingItem
AddItem
is the method being tested.
NullItem
, NewItem
, ExistingItem
indicates a sort of input for the test, or as a way to sort of grouping the tests by a specific scenario.
The last part is self-explanatory and just rather precisely describes what we expect to happen, given the scenario.
Compare that to test names like these:
AddNullItemTest
AddNewItemTest
AddExistingItemTest
You can't tell much just from the name of these; you will have to look at the code to see what is actually being tested and what is causing it to fail. What method is actually being tested? What was the expected outcome?
This also illustrates a common thing - maybe the tests are testing too much? Keeping the tests simple and focused on a single thing instead of a bit of everything will help make the tests more readably and easier to debug.
What parts of your code to test
A common issue I hear or notice is that many people are unsure of what to actually test.
As I see it there at least two ways to approach this:
- From the outside. Looking at the method signature and the inputs, what do you expect the outcome should be?
- From the inside. Looking at the code where does it branch into different scenarios and edge-cases (if-conditions, exceptions etc.)
Starting with the 1. case - looking at it from the outside - is sort of close to Test Driven Development (TTD) in my eyes, where you can have some requirements that should be matched by the code which you want to verify. It could be acceptance criteria of a feature request or the expected outcome detailed in a bug report.
An example could be the basket example from Part 1 of this series. A requirement for when adding items is that it should not create a new (duplicate) item in the basket if the item already exist, instead it should add the quantity to the existing item. Here we are just looking at the input and expected outcome.
The 2. approach is to look at it from the inside; looking at the code itself and if there are any obvious logic branches or handling of edge cases. You can then add tests to make sure cover all the different scenarios and thereby increase your code coverage (although just because the code is covered doesn't mean that all cases are covered).
I find that approach number 1 is best at finding requirements that were missed during development, while number 2 is good for legacy code before adding new features or doing refactoring, because it helps ensure the existing functionality doesn't break.
Structuring tests
When writing test they should really - like most stuff - be structured for readability and maintainability. As much as possible you want to easily be able to tell at a glance what a test does.
I always follow the "Arrange, Act and Assert" pattern (AAA). Shortly explained that means:
- Arrange: Setup and prepare everything for your test
- Act: Execute the code to be tested
- Assert: Verify that the results are as expected
It's not strictly necessary but I almost always add a comment above each section unless it's 2-liner or something very very simple. It helps me to visually separate the sections and allows me to have empty lines between logically grouped arrange-steps.
[Fact]
public void AddItem_ExistingItem_DoesNotAddNewItem()
{
// Arrange
var itemNo = "qwerty";
var basket = new Basket
{
Items = new List<BasketItem>
{
new BasketItem
{
ItemNo = itemNo,
Quantity = 1
}
}
};
Assert.Equal(1, basket.Items.Count);
// Act
basket.AddItem(itemNo, 1);
// Assert
Assert.Equal(1, basket.Items.Count);
}
Example of the Arrange-Act-Assert pattern with comments for each section to visually separate them.
Readability versus repetition (DRY)
Usually I prefer to have a bit of repetition in favor of sacrificing readability. It can often be tempting - and I see this often - to extract the arrange-section into a separate method and just call that as the setup for a lot of different test, e.g. a CreateBasket()
setup method.
That can be okay in some scenarios, but what usually ends up happening is that the setup-method is then made to work with all your different tests and has some setup that is not necessary for every test and might even mask some issues that would have been noticed if the setup was done more specifically for each test. It also make it harder to see at a glance what the setup for the test actually is without having to jump to that method.
Sometimes a compromise can be made if you use the same setup for a group of tests and another setup for another group of tests. Then you could have setup-methods like CreateEmptyBasket()
, CreateBasketWith3Items()
etc., making the method describe the setup being done.
Test a single thing per test
The last thing I want to mention regarding the structure of your tests is try to focus on testing a single thing per test. If you test too much in a single test and the test fails, then it will not be immediately apparent what is actually failing.
If you have a failed test named something like TestThatBasketWorks()
which tests both adding and deleting items, then you won't know if it is adding or deleting items that failed the test and you have to spent time figuring that out first.
If you instead have 2 tests - AddItem_AddsANewItem()
and DeleteItem_RemovesItem()
- and one of them fails, then you know right away which part of the basket has broken.
Increasing readability of assertions and error messages
I always use the FluentAssertionspackage when writing unit tests.
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="FluentAssertions.Analyzers" Version="0.11.4" />
It's a very nice set of extension methods that helps you write more natural assertions and also more descriptive error messages in case a test fails. You simply add a call to the extension method Should()
after the object/value that you want to do one ore more assertions on and then you can chain other calls like Be("something")
or BeTrue()
or HaveCount(2)
for verifying the amount of items in a collection.
Go check out their "Getting stared" section in the documentation for more information.
Assert.NotNull(basket.Items);
Assert.Empty(basket.Items);
// With FluentAssertions we can write the above like this:
basket.Items.Should().NotBeNull();
basket.Items.Should().BeEmpty();
// You can even chain assertions like this:
basket.Items.Should().NotBeNull().And.BeEmpty();
Using the normal way with the Assert
class the assertions reads a bit as if spoken by Yoda: "assert that not null, the basket items are".
The assertions made with FluentAssertionsreads more naturally like "basket items should not be null" and "basket items should not be empty".
Regarding the more descriptive error messages, let's look at this example:
Assert.Equal(1, basket.Items.Count);
// Error message:
// Assert.Equal() Failure
// Expected: 1
// Actual: 0
basket.Items.Should().HaveCount(1);
// Error message with FluentAssertions:
// Expected collection to contain 1 item(s) but found 0.
The error message with FluentAssertionscontains more information about the assertion being made. In this case that we were expecting a collection to have a specific count. Without FluentAssertionswe just now that some comparison of integer values were not equal as expected.
You can also add a "because" text to the assertion, specifying why the assertion is needed.
basket.Items.Should().HaveCount(3, "because we added 3 different items");
// Error message:
// Expected collection to contain 3 item(s) because we added 3 different items, but found 1.
Next up in Part 3
That's all for this time. In the next part I'll talk about mocking dependencies with NSubstitute and about how to make you code more easily testable.
- Part 1: Getting started
- Part 2: Naming, structure, and readability (FluentAssertions)
- Part 3: Mocking and writing testable code (NSubstitute)
- Part 4: Keeping it DRY and maintainable (AutoFixture)
- Part 5: Test Driven Development (TDD) with Live Unit Testing (LUT)