In the digital age, where software permeates every facet of life, the significance of reliable code cannot be overstated.
Imagine the chaos when a banking system falters, or the disruption when an e-commerce platform crashes. These scenarios underscore the critical role of testing in the software development lifecycle.
Testing is not just a phase; it's an ongoing commitment to quality and user trust.
But it's not a question of doing just anything. Badly written tests can cause more harm than a program with no tests at all. Nothing is worse than producing false-positive tests, i.e. tests that lead us to believe in a result that is not the right one. The cause can be multiple: the element is not tested when it should be, the test return is made before the case you want to test....
To avoid this, it's important to follow certain rules, which we'll look at together.
No Conditions, No Loops: The Purity of Test Logic
The most important rule that should be mandatory for all developers: keep your tests clean of conditional logic and loops.
This is not just a stylistic choice but a foundational principle for creating transparent and maintainable test suites.
The easiest way to introduce errors into tests is to add logic, however trivial it may seem. The purpose of a test is not to reproduce a behavior, but to validate one. And the best way to do this is to refrain from using conditions or loops, which are the basis of computer logic.
So yes, in some cases, it could be practical. Let's imagine that we want to test the creation of a list of elements Goat
to verify the following rule: if the name of the goat contains the letter E
, then it's not possible to create it. The corresponding test would look like this:
public void Test()
{
var goats = new List<Goat>(XXX.....);
var results = myService.Create(goats);
foreach(var result in results)
{
if(goat.Name.Contains("E") && result.IsCreated)
Assert.False();
Assert.True();
}
And to do this, we have to introduce a if
and a for
, which is catastrophic! Let's imagine that tomorrow, we add a rule that also blocks goats that are not major, then the condition will be modified, which means more logic in the test and so on...
The mistake here is to want to test everything at once. The best would have been to separate the 2 cases into 2 tests:
- One that checks that the classic creation works (without E)
- One that checks that creation doesn't work when the name contains an E
AAA: The Backbone of Good Tests
Excluding logic from tests is not enough to make them effective.
Let's take 30 seconds to read the following test and analyze it:
public void Test()
{
var goat = new Goat(XXX.....);
var result = myService.Create(goat);
Assert.True(result.IsSuccess);
goat.Name = "YYY";
result = myService.Update(goat);
Assert.True(result.IsSuccess);
Assert.Equals(result.Name, goat.Name);
// AND SO ON FOR DELETE, GET ....
}
This test creates a goat, verifies that the goat is created, then modifies it and verifies that the modification has been made. Indeed, this test seems to validate 2 different things: creation and modification.
The mistake to avoid here is building overly complex tests that validate several behaviors at once.
The AAA pattern is a guiding light for structuring tests with precision. This methodical approach delineates :
- The preparatory steps (
Arrange
) - The specific operation (
Act
) - The outcome verification (
Assert
)
The Arrange
phase can be as long as necessary to instantiate all the elements needed to get into the test case.
The Act
phase must contain a single function call, the one being tested.
The Assert
phase contains as many validations as necessary.
public void TestCreate()
{
// Arrange
var goat = new Goat(XXX.....);
// Act
var result = myService.Create(goat);
// Assert
Assert.True(result.IsSuccess);
}
public void TestUpdate()
{
// Arrange
var goat = new Goat("YYY".....);
// Act
result = myService.Update(goat);
// Assert
Assert.True(result.IsSuccess);
Assert.Equals(result.Name, goat.Name);
}
// AND SO ON FOR DELETE, GET ....
Of course, these phases are ordered and cannot be changed or called up more than once. If you ever feel the need to do this, especially for the validation phase, it's often a sign that the test needs to be split into several phases.
This pattern maintains a clean separation of concerns within the test and avoids any confusion about the test's purpose.
Naming Conventions: A Window into Intent
There's a tendency to think that good development documentation means putting XML tags all over the code, exporting it from time to time and keeping an up-to-date file next to it containing all the specifications.
Well, that's one of the worst things you can do.
Believing that a document external to the code is the source of truth about what works is wrong. It's the running production code that is.
To obtain specifications from the code, there's nothing like having tests validate the application's functionality. But be careful, you need to be able to find your way around without wasting an enormous amount of time.
The naming of tests is as crucial as their content.
Conventions like Should_When
and Given_When_Then
provide a glimpse into the history of behavior-driven development (BDD). These naming patterns, inspired by the Gherkin language, offer a descriptive and intuitive way to understand what a test is verifying and under what circumstances.
They can be summed up as follows:
- Should[ResultExpected]_When[CaseTested]
- Given[InitialCondition]_When[CaseTested]_Then[ResultExpected]
To go back to the previous examples, here are the tests named in the right way:
public void ShouldCreateGoat_WhenGoatIsValid()
{
// Write here a valid goat name and age
}
public void ShouldFailGoatCreation_WhenGoatNameContainsLetterE()
{
// Write here a goat name with E
}
public void ShouldFailGoatCreation_WhenGoatIsMinor()
{
// Write here a goat age is under 18
}
It's not necessary to prioritize one or the other, and it's even possible for a test to use both names. It's a matter of personal choice, and keeping in mind that the test name is as important as the test itself.
FluentAssertions: The Art of Readability
In addition to the quality of test naming, the quality of validation naming is a key element of a relevant test. It ensures that the reader of a test quickly understands the elements validating the case under test.
And for this, FluentAssertions provides a syntax for expressing assertions in a more natural, easy-to-read way.
// Are equal "Hello, World!" and actualString
Assert.AreEqual("Hello, World!", actualString);
// Became
// actualString should be "Hello, World!"
actualString.Should().Be("Hello, World!");
If you'd like to find out more, we've dedicated an article to FluentAssertions:
The Delicate Dance with Mocks
Before going any further on this rule, let's put what follows into context.
It goes without saying that some tests should not contain any mocks, such as E2E or Components tests, as they test components external to an application. However, when creating unit tests, it's common to have external calls (database, file system, MQTT...) in our features, and as it's forbidden to have external elements in a unit test, these interfaces are mocked. It's a good way of doing things.
A mock is practical: you can quickly transform an interface into the state you want to test.
That's true, but be careful! You mustn't go overboard.
Having seen it with my own eyes, some people tend to mock all classes that are a little complicated to instantiate, and they end up mocking anything and everything to validate their usecase, and that's where the danger lies.
The aim of a unit test is to verify a feature as a whole, and to achieve this, as many classes as possible must behave as they really do. If the implementation of a class is complex (apart from an external element), rather than using a mock, you should think of it as a redflag. It's a good warning that the class is poorly designed, and that work should be done to make it simpler.
Mocks should be kept for external elements, and as little as possible, if at all, for classes internal to the application!
Conclusion
The establishment of best practices for writing tests is aimed above all at having them as allies rather than as blocking elements in the good life of an application.
It's not uncommon for tests to have been put in place, with good will, and then to become the developers' worst enemy because they're so difficult to maintain.
And a good way of doing this is to follow these 5 rules:
- No conditions, no loops
- Structure with
Arrange
,Act
,Assert
Should_When
andGiven_When_Then
for tests naming conventions- Improve asserts readability with FluentAssertions
- Use mock only for external components
Have a goat day 🐐
Join the conversation.