Essential practices for writing better tests in TDD
Introduction
TDD is a well-known software development approach in which tests are written before writing any production code. It is an iterative process with three steps: red (writing tests that fail), green (writing minimum code to pass those tests) and refactoring (improving the code quality without affecting functionality).
When writing code, we try to incorporate software principles and follow best practices and coding standards to make it clean and maintainable. Wiring clean code is important but writing clean tests is also equally important. They define the scenarios that verify the behavior of components that are being tested. If you have clean and well-written tests, it is not easy to introduce new bugs through updating code or by introducing new features. Moreover, it is easy to debug when unexpected error or bug occurs in the production.
I have good experience in following the TDD approach for developing software. In this process, I have learned how to write clean and self-describing tests that validate the code. In this article, we will see a few best practices I found in this process to avoid bad tests and write clean tests.
But before we go further, I have a couple of things to discuss about the importance of team agreements and a note.
Importance of team Agreements
When working as a team, developers must establish coding conventions and standards to ensure maintaining consistency in the codebase. These agreements typically are, naming conventions, formatting, best practices, error handling, code organization code reviewing, etc. Following these also facilitates learning and adapting quickly for new engineers joining the teams.
But also, the team should agree on the conventions and best practices for writing tests. This ensures testing efforts are organized and effective.
Adhering to coding agreements helps to ensure that the codebase remains maintainable, scalable, and consistent over time.
Important note
These examples have been written in the xUnit testing framework in dotnet. The code may vary from framework to framework and language to language. But the practices will remain the same.
These approaches are not only for TDD/BDD but wherever you write tests.
Best practices to write test cases
Use descriptive test names
Name your test cases descriptively to convey their purpose or the behavior being tested. This will increase the readability, debugging, and maintainability of the tests.
The below test case is lack of clarity. It indicates basket total should be a success but does not specify any specific behavior. This test case name can indicate multiple scenarios related to basket totals.
[Fact]
public async Task BasketTotal_Success()
{
// test code goes here
}
Write the test case name with the specific behavior. Although there are different ways you can name the tests, you can follow the below ways.
- Given, when and then
- Method name, use case, and expected result
[Fact]
public void BasketTotal_ReturnTotalPrice_WhenBasketHasItems()
{
// test code goes here
}
Pro tip: The team should agree on the test name convention to have uniform test names, otherwise we will have different naming conventions in the codebase.
Structure the test cases
Structuring the test case is essential for creating clarity and increasing readability. One famous way is to follow the AAA (Arrange, Act, and Assert) pattern or given/when/then pattern. These approaches separate preconditions, test case data with behavior we want to test, and results we will verify.
In the below test case, we see there is a separation, so it is easy to understand the test's purpose and steps.
[Fact]
public void BasketTotal_ReturnTotalPrice_WhenBasketHasItems()
{
// arrange
var basket = GetBasket();
basket.Add(new BasketItem("Item 1", 1.00));
basket.Add(new BasketItem("Item 2", 2.00));
// act
var total = basket.Total();
// assert
Assert.AreEqual(2.99, total);
}
For example, for given/when/then pattern
[Fact]
public void BasketTotal_ReturnTotalPrice_WhenBasketHasItems()
{
// given
var basket = GetBasket();
basket.Add(new BasketItem("Item 1", 1.00));
basket.Add(new BasketItem("Item 2", 2.00));
// when
var total = basket.Total();
// then
Assert.AreEqual(2.99, total);
}
Use setup and clean up methods
When we write tests for a service/component, it is common for the service/component to be shared between test cases. If a component under test is shared there might be a chance of interference between tests. This will lead to inconsistent test execution. In some cases, makes it difficult to debug.
When we share a service under test with test cases, it is important to write setup and clean methods. This will ensure that each test case will receive a new instance of service under test and ensuing each test is executing in isolation.
Please see below test cases below. The order processor processes the order, but the order might have failed or been successful.
public class OrderProcessorTests{
private readonly OrderProcessor _cut;
[Fact]
public void OrderProcessor_ShouldHaveProcessed()
{
// Arrange
var basket = new Basket();
// Act
_cut.Process(basket);
// Assert
Assert.True(order.IsProcessed);
}
[Fact]
public void OrderProcessor_ShouldHaveSubmitted_WhenBasketIsValid()
{
// Arrange
var basket = new Basket();
// Act
_cut.Process(basket);
// Assert
Assert.True(order.IsSubmitted);
}
}
Extract common setup code into helper methods or use Setup/Cleanup methods provided by the testing framework.
// better way
public class OrderProcessorTests(){
private readonly IOrderProcessor _cut = null;
public OrderProcessorTests
{
_cut = new OrderProcessor();
}
[Fact]
public void OrderProcessor_ShouldHaveProcessed()
{
// Arrange
var basket = new Basket();
// Act
order.Process(basket);
// Assert
Assert.NotNull(order.IsProcessed);
}
[Fact]
public void OrderProcessor_ShouldHaveSubmitted_WhenBasketIsValid()
{
// Arrange
var basket = new Basket();
// Act
order.Process(basket);
// Assert
Assert.NotNull(order.IsSubmitted);
}
}
Hide irrelevant details
The irrelevant details in the context of the test case, are details that are specific implementation details that are not related to the behavior that is being tested.
If we look at below test case below, the test case does not need to know about specific items or their prices.
[Fact]
public void BasketTotal_ReturnTotalPrice_GivenBasketWithItems()
{
// arrange
var basket = GetBasket();
basket.Add(new BasketItem) { Name = "Item 1", Price = 1.00 };
basket.Add(new BasketItem) { Name = "Item 2", Price = 1.99 };
// act
var total = basket.Total();
// assert
Assert.AreEqual(2.99, total);
}
The above test should focus on only the data that impacts behavior. To achieve this, we can use test data builders to create data with properties that impact results or parametrize the inputs and outputs.
[[Theory]
[InlineData(new []{ new { Name = "Item 1", Price = 1.00 }, new { Name = "Item 1", Price = 1.00 }}, 2.99)]
[InlineData(new []{ new { Name = "Test item", Price = 3.00 }}, 3)]
public void BasketTotal_ReturnTotalPrice_GivenBasketWithItems(List<BasketItem> items, double expectedPrice)
{
// arrange
var basket = GetBasket();
foreach(var item in items)
basket.Add(item);
// act
var total = basket.Total();
// assert
Assert.AreEqual(expectedPrice, total);
}
Pro tip: if we have used the builder to get the basket with items, we will have to go to the builder to see the items and get the expected total price. This approach makes use of abstraction but is difficult to read. So use the approaches based on the tests.
Never overload assertions
A single test method contains multiple assertions, each testing different aspects of the code. While having multiple assertions in a single test is not entirely wrong, it can make tests harder to understand, maintain, and debug.
The below test is verifying two things, whether the order is processed and submitted.
[Fact]
public void OrderProcessor_ShouldHaveProcessed()
{
// Arrange
var basket = new Basket();
// Act
order.Process(basket);
// Assert
Assert.True(order.IsProcessed);
Assert.True(order.IsSubmitted);
}
The better way is to split the test case into multiple tests. So that each test only verifies a single behavior.
[Fact]
public void OrderProcessor_ShouldHaveProcessed()
{
// Arrange
var basket = new Basket();
var cut = new OrderProcessor();
// Act
cut.Process(basket);
// Assert
Assert.True(order.IsProcessed);
}
[Fact]
public void OrderProcessor_ShouldHaveSubmitted_WhenBasketIsValid()
{
// Arrange
var basket = new Basket();
var cut = new OrderProcessor();
// Act
cut.Process(basket);
// Assert
Assert.True(order.IsSubmitted);
}
Avoid writing logic
Tests are too complex to understand and prone to bugs. There will be usages of logic such as if-else statements, loops, or switch cases in tests.
Look at the below test case, which uses using if else statement to assert the result.
[Theory]
[InlineData(PromotionTypeEnum.BuyQualifyingItemGetDollarsOffSubtotal, true)]
[InlineData(PromotionTypeEnum.BuyQualifyingItemGetPercentOffSubtotal, true)]
[InlineData(PromotionTypeEnum.BuyQualifyingItemGetPercentOffAnotherItem, true)]
[InlineData(PromotionTypeEnum.BuyQualifyingItemGetPercentOffThatItem, true)]
[InlineData(PromotionTypeEnum.PriceAtDeliveryFee, false)]
public void For_Selected_Promotion_Type_IsQualifyingItemVisible(PromotionTypeEnum promotionType, bool returnValue)
{
//Arrange
var isQualifyingItemVisible = OfferCreatePageHelper.IsQualifyingItemVisible(promotionType);
//Assert
if(returnValue)
isQualifyingItemVisible.Should().BeTrue();
else
isQualifyingItemVisible.Should().BeFalse();
}
To fix the above test case, we should refactor the test case as below
[Theory]
[InlineData(PromotionTypeEnum.BuyQualifyingItemGetDollarsOffSubtotal, true)]
[InlineData(PromotionTypeEnum.BuyQualifyingItemGetPercentOffSubtotal, true)]
[InlineData(PromotionTypeEnum.BuyQualifyingItemGetPercentOffAnotherItem, true)]
[InlineData(PromotionTypeEnum.BuyQualifyingItemGetPercentOffThatItem, true)]
[InlineData(PromotionTypeEnum.PriceAtDeliveryFee, false)]
public void For_Selected_Promotion_Type_IsQualifyingItemVisible(PromotionTypeEnum promotionType, bool expectedValue)
{
//Arrange
var isQualifyingItemVisible = OfferCreatePageHelper.IsQualifyingItemVisible(promotionType);
//Assert
isQualifyingItemVisible.Should().Be(expectedValue);
}
Another way to fix this is to separate test cases with inputs that result in true and false.
Avoid any logic in your test code! Once you feel the need for these, it's a smell that you test more than one thing. You can get rid of test logic by splitting up your tests into multiple test cases. Another option is, as shown above, to use parameterized tests to declare your test cases and to avoid duplication.
Use Fluent assertions
Nothing is more annoying than a unit test that fails without clearly explaining why. More than often, you need to set a breakpoint and start up the debugger to be able to figure out what went wrong.
That's why we designed Fluent Assertions to help you in this area. Not only by using named assertion methods but also by making sure the failure message provides as much information as possible.
Consider this example, this will give an error saying
[Theory]
[InlineData(new []{ new { Name = "Item 1", Price = 1.00 }, new { Name = "Item 1", Price = 1.00 }}, 2.99)]
[InlineData(new []{ new { Name = "Test item", Price = 3.00 }}, 3)]
public void BasketTotal_ReturnTotalPrice_GivenBasketWithItems(List<BasketItem> items, double expectedPrice)
{
// arrange
var basket = GetBasketWithItems(items);
// act
var total = basket.Total();
// assert
Assert.AreEqual(expectedPrice, total);
}
// output
Expected: 42
But was: 37
Assert.AreEqual failed. Expected:<42>. Actual:<37>.
If we use FluentAsserts, we can see improved error messages when the test case fails, making it easy to debug the test case.
[Theory]
[InlineData(new []{ new { Name = "Item 1", Price = 1.00 }, new { Name = "Item 1", Price = 1.00 }}, 2.99)]
[InlineData(new []{ new { Name = "Test item", Price = 3.00 }}, 3)]
public void BasketTotal_ReturnTotalPrice_GivenBasketWithItems(List<BasketItem> items, double expectedPrice)
{
// arrange
var basket = GetBasketWithItems(items);
// act
var total = basket.Total();
// assert
total.Should().Be(expectedPrice);
}
// output
Expected the total to be 42, but found 37.
Conclusion
In conclusion, identifying and rectifying common test smells in tests is important for upholding a robust and efficient test suite. By adhering to recommended practices such as following basic conventions, prioritizing behavior over implementation specifics, eliminating test redundancy, segregating test logic from production code, employing singular assertions per test case, and promptly addressing failed tests, developers can cultivate a more resilient and streamlined test suite. Consequently, this brings superior code quality, facilitates smoother refactoring, and greater assurance of the software's correctness.
Ultimately, by following these practices and avoiding the issues and consistently refining the test suite, developers can ensure that their TDD methodologies positively influence the overall development process, resulting in software products that are easier to maintain, more dependable, and of higher quality.