Over the last few years, test-driven development (a.k.a. TDD) has grown in popularity. Many programmers have tried and failed with this technique, concluding that TDD is not worth the effort. Yet, in this article, we will go through what test-driven development is, explore why people use it, and reach out how to apply TDD effectively with practical examples.
Basic concept
What is Test-Driven Development?
Test-driven development is an approach in software development in which you write tests first before implementing a feature. Kent Beck created it in the late 1990s as part of Extreme Programming. It reverses the traditional technique that leads the software development by designing the code architecture first, writing code, then iteratively doing testing until it passes all the test cases that are defined after the development has been done. In TDD, we will focus on ‘what’ to implement first, instead of ‘how’ to achieve it. TDD focuses on code design and pursues a better quality of code as well.
Advantages of using Test-Driven Development
- TDD guarantees that all the codes are well-tested. A key characteristic of a test is that it can fail, and the development team verifies that each new test fails. This will bring high confidence when we release the software to production.
- Less code: You only need to write the minimum lines of code that pass the test. It will help to reduce code duplication, enabling faster innovation and continuous delivery.
- Easier to maintain: TDD makes your code flexible and extensible. The code can be refactored or moved with minimal risk of breaking code. Since refactoring is an important step repeated in every iteration of the TDD cycle, the code will be continuously maintained.
Disadvantages of using Test-Driven Development
TDD also has some disadvantages that you need to consider depending on your project size.
- It requires more time to write the test. Since the test should be pre-defined, it would take time for our developers to write test cases and so the development time can be longer than usual.
- It requires developers more experienced. It means that developers working with TDD need to have an understanding of this technique, have enough skill to write failing tests, as well as the ability to follow the process strictly.
How to apply Test-Driven Development?
There are 3 steps to implementing TDD:
- Step 1: Understand the requirement and write code that makes the test fail.
- Step 2: Write code to make the test pass.
- Step 3: Refactor the code you have just written to make it more readable and maintainable.
These 3 steps are often described as a Red-Green-Refactor (RGR) cycle as in the figure below.
The purpose of the RGR cycle is to guarantee that you only write sufficient code to make the test pass, and meanwhile keep the code well structured. To ensure that, Robert C. Martin defines the three laws of TDD:
“First law: You may not write production code until you have written a failing unit test.
Second law: You may not write more of a unit test that is sufficient to fail, and not compiling is failing.
Third law: You may not write more production code that is sufficient to pass the currently failing test.”
TDD implementation in a .NET Core application with code examples
Let us take an example of how TDD is applied in a project. From Visual Studio, create a .NET Core Web API named TddExample. By default, it will create an empty solution with a default controller named “WeatherForecastController”. But if it did not create due to an older version of Visual Studio, let us create it manually and replace it with the following code.
WeatherForecastController:
namespace TddExample.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly List<WeatherForecast> Data = new List<WeatherForecast>
{
new WeatherForecast(DateTime.Now, 16, "Freezing"),
new WeatherForecast(DateTime.Now.AddDays(1), 20, "Cold"),
new WeatherForecast(DateTime.Now.AddDays(3), 21, "Cold"),
new WeatherForecast(DateTime.Now.AddDays(3), 24, "Mild"),
new WeatherForecast(DateTime.Now.AddDays(4), 38, "Sweltering"),
new WeatherForecast(DateTime.Now.AddDays(5), 39, "Scorching"),
new WeatherForecast(DateTime.Now.AddDays(6), 40, "Scorching"),
new WeatherForecast(DateTime.Now.AddDays(7), 26, "Mild"),
new WeatherForecast(DateTime.Now.AddDays(8), 29, "Warm"),
new WeatherForecast(DateTime.Now.AddDays(9), 30, "Hot"),
new WeatherForecast(DateTime.Now.AddDays(10), 31, "Hot"),
new WeatherForecast(DateTime.Now.AddDays(11), 32, "Balmy"),
new WeatherForecast(DateTime.Now.AddDays(12), 27, "Warm"),
new WeatherForecast(DateTime.Now.AddDays(13), 22, "Mild"),
};
public WeatherForecastController()
{
}
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
return null;
}
}
}
and WeatherForecast class:
namespace TddExample
{
public class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string Summary { get; set; }
public WeatherForecast(DateTime date, int tempC, string summary)
{
Date = date;
TemperatureC = tempC;
Summary = summary;
}
}
}
Assuming we have the following requirement for the HttpGet API to get a weather forecast:
- The API should return the weather forecast information for the next seven days, including the current day.
- If a day with a temperature in Celsius greater than 39, the Summary should be added with the warning text: “Do not go outside at noon!”.
First, let’s add an NUnit Test project to the solution, named TddExample.Test. Rename UnitTest1.cs with the name WeatherForecastControllerTest.cs, and replace it with the following code.
namespace TddExample.Test
{
public class WeatherForecastControllerTest
{
[SetUp]
public void Setup()
{
}
}
}
Now everything is ready for the implementation of TDD. The first step of TDD is to write a failed test. Add the following method to the Test class we have just created above.
[Test]
public void Get()
{
// Arrange
var controller = new Controllers.WeatherForecastController();
// Act
var nextSevenDaysForecast = controller.Get();
// Assert
// 1. The API returns enough 7 days data
Assert.AreEqual(7, nextSevenDaysForecast.Count());
// 2. Data is 7 days in-a-row from today
int index = 0;
for (DateTime date = DateTime.Now.Date; date <= DateTime.Now.AddDays(6); date = date.AddDays(1))
{
Assert.AreEqual(nextSevenDaysForecast.ElementAt(index).Date.Date, date.Date);
index++;
}
}
Note: You can read more about the Arrange-Act-Assert pattern.
At this moment, if we try to run the test, we will get the failed result with this error: System.ArgumentNullException. It is the correct result because the method Get() returns null, so the .Count() method raised an exception. We have done step 1: writing the failed test (the ‘Red’ step).
Now, as we have the failed test, the next step is to make it ‘Green’ by implementing the method to make it pass the test. Edit the Get method in the Controller to make it returns the next seven days' data.
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
return Data.Where(data => data.Date < DateTime.Now.AddDays(6));
}
As we have implemented the code in order to make the test pass, let’s re-run the test to check the result. You might beware that it is still failed.
By looking at the Error Message, Expected: 2022-05-27 00:00:00 But was: 2022-05-26 00:00:00, we know one of our Assert conditions has not passed. Re-check the code implementation to find what causes the test to be failed and correct it. After a few seconds, you will see that it is because the data is incorrect: the 3rd day in our mock Data should be the next two days, not three days.
Edit it to AddDays(2) and re-run it to see if the test is passed now.
Now you realize that TDD will help us ensure the code's accuracy. It will ensure the program runs correctly as long as all the tests are well-defined, possible falling cases are covered, and all tests are passed after implementation.
Continue with the second requirement, let’s repeat the RGR cycle by defining a second test method to check if the API returns the correct data that matches the requirement:
[Test]
public void GetWithWarning()
{
// Arrange
var controller = new Controllers.WeatherForecastController();
// Act
var nextSevenDaysForecast = controller.Get();
// Assert
if(nextSevenDaysForecast.Any(_ => _.TemperatureC > 39))
{
const string Warning = "Do not go outside at noon!";
Assert.IsTrue(nextSevenDaysForecast
.Where(_ => _.TemperatureC > 39)
.All(data => data.Summary
.EndsWith(Warning)));
}
}
And like what we did before, first re-run to see if it’s failed.
Then, implement the code in the Controller to get it passed.
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
var nextSevenDaysForecast = Data.Where(data => data.Date < DateTime.Now.AddDays(6));
const string Warning = "Do not go outside at noon!";
nextSevenDaysForecast.Where(_ => _.TemperatureC > 39).ToList().ForEach(date =>
{
date.Summary = string.Concat(date.Summary, Warning);
});
return nextSevenDaysForecast;
}
Re-run to see if the 2 tests are passed.
Although the code in this example is simple enough that the Refactor phase is not required, in production, you should be aware of refactoring continuously after a test has passed to keep the code well-structured and maintainable.
Final thoughts
Test-driven development is a process of developing, running automated tests, and refactoring before the actual development of the application. It enables developers to build solid and robust software with clearer and more understandable code. Hopefully, you have an overview of TDD and know how to apply it to your software development practice.
Happy coding!