TDD (Test Driven Development) is a much debated word in the tech industry. Debates like Whether you should do TDD or not? or How advantageous is it? are quite popular. Simply said, TDD is write tests before you develop.
Now, there are a lot of school of thoughts regarding what type of test's are included and what are not in TDD. As an example, should it include Unit Test, Integration Test, System Test or even UAT?
In this article, we will go through a real-world example on how to write integration tests in .NET 5.0 with TDD methodology.
Project Requirements
TDD requires a very clear understanding of scope of work. Without clarity, all the test cases might not be covered.
Let's define the scope of work. We will be developing a patient admission system for a Hospital.
Business Requirements
- A hospital has X ICU rooms, Y Premium rooms & Z General rooms.
- ICU & Premium rooms can have a single patient at a time, while General rooms can have 2 patients. Each room has a room number.
- On admitting, the patient has to provide name, age, gender & phone number.
- It is possible to search a patient via name or phone number.
- Same patient cannot be admitted to multiple beds while he is still checked in.
- A patient cannot be admitted if all the rooms are occupied.
Model Validation Rules
Based on the above requirements, there are 2 models namely Patient & Room.
- A patient's age is between 0 & 150. The length of name should be between 2 and 40. Gender can be male, female & other. Phone Number's length should be between 7 and 12 and it should all be digits.
- Room type can be either "ICU", "Premium" or "General".
Test Cases
Now, that we have defined rules & requirements, lets start creating test cases. Since it's a basic CRUD application we mostly have integration tests.
Patient
- Do all the model validation tests.
- Admit the same patient twice
- Check out the same patient twice.
- Admit the same patient to multiple rooms at the same time.
- Search a patient with phone number and name.
TDD Setup
In the above section we gathered requirements. Secondly, we defined the models. Finally, we created the list of test cases which we will implement.
Open your terminal and run the below script to create and setup a new project.
mkdir TDD
cd TDD
dotnet new sln
dotnet new webapi --name TDD
dotnet new xunit --name TDD.Tests
cd TDD
dotnet add package Microsoft.EntityFrameworkCore --version 5.0.5
cd ../TDD.Tests
dotnet add reference ../TDD/TDD.csproj
dotnet add package Microsoft.EntityFrameworkCore --version 5.0.5
dotnet add package Microsoft.AspNetCore.Hosting --version 2.2.7
dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 5.0.5
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 5.0.5
cd ..
dotnet sln add TDD/TDD.csproj
dotnet sln add TDD.Tests/TDD.Tests.csproj
code .
The above script creates a solution file named TDD.sln. Secondly, we create 2 projects for TDD & TDD.Tests. Then we add the dependencies for each project. Lastly, we add the projects to the solution and open the project in VS Code.
Before we start testing, some more setup is required. Basically, integration tests test the a specific module without mocking. So we will be mimicking our application via TestServer.
Custom WAF
In order to mimic the TestServer there is a class called WebApplicationFactory (WAF) which bootstraps the application in memory.
In your TDD.Tests project create a file named PatientTestsDbWAF.cs with the following code.
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore;
namespace TDD.Tests
{
public class PatientTestsDbWAF<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
protected override IWebHostBuilder CreateWebHostBuilder()
{
return WebHost.CreateDefaultBuilder()
.UseStartup<TStartup>();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(async services =>
{
// Remove the app's DbContext registration.
var descriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbContextOptions<DataContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Add DbContext using an in-memory database for testing.
services.AddDbContext<DataContext>(options =>
{
// Use in memory db to not interfere with the original db.
options.UseInMemoryDatabase("PatientTestsTDD.db");
});
});
}
}
}
We are removing the applications DbContext and adding an in memory DbContext. It is a necessary step since we don't want to interfere with the original database.
Secondly, we are initialising the database with some dummy data.
Since, DataContext is a custom class, it will give compiler error. So, we need to create it.
Data Context
Therefore, in your TDD project, create a file named DataContext.cs with the following code.
using Microsoft.EntityFrameworkCore;
namespace TDD
{
public class DataContext : DbContext
{
public DataContext(DbContextOptions options) : base(options) { }
// For storing the list of patients and their state
public DbSet<Patient> Patient { get; set; }
// For the storying the rooms along with their types and capacity
public DbSet<Room> Room { get; set; }
// For logging which patients are currently admitted to which room
public DbSet<RoomPatient> RoomPatient { get; set; }
}
}
Here Patient, Room & RoomPatient are Entity classes with the required properties, which we will create next.
Patient
Again, in your TDD project, create a file named Patient.cs and paste in the code below.
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TDD
{
public class Patient
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public String Name { get; set; }
public String PhoneNumber { get; set; }
public int Age { get; set; }
public String Gender { get; set; }
}
}
Room
Create another file named Room.cs with the following code.
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TDD
{
public class Room
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
public String RoomType { get; set; }
public int CurrentCapacity { get; set; }
public int MaxCapacity { get; set; }
}
}
RoomPatient
Create the last model file RoomPatient.cs with the following code.
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TDD
{
public class RoomPatient
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
public int RoomId { get; set; }
[ForeignKey("RoomId")]
public Room Room { get; set; }
[Required]
public int PatientId { get; set; }
[ForeignKey("PatientId")]
public Patient Patient { get; set; }
}
}
Now you shouldn't be getting any compiler error.
Lastly, remove the WeatherForecast.cs and WeatherForecastController.cs files.
Go to your terminal in VS Code and run the below command.
cd TDD.Tests
dotnet test
You will see a nice green result which says 1 test passed.
Patient Controller
Unfortunately dotnet doesn't provide a way to directly test the model's in itself. So, we will have to create a controller to test it.
Go ahead and create PatientController.cs in the Controllers folder in TDD project with the below code.
using Microsoft.AspNetCore.Mvc;
namespace TDD.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class PatientController : Controller
{
[HttpPost]
public IActionResult AddPatient([FromBody] Patient Patient)
{
// TODO: Insert the patient into db
return Created("/patient/1", Patient);
}
}
}
We created an api to add a patient. In order to test our model we will call this api.
That is all the things required to start testing.
Model Validation Tests
Since, we have setup the basic code for testing, let's write a test that fails. We will start our testing with the model validation tests.
Failing (Red) State
Let's create a new file named PatientTests.cs in your TDD.Tests project and delete the file named UnitTest1.cs. Copy the below code in your file.
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Mvc.Testing;
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
namespace TDD.Tests
{
public class PatientTests : IClassFixture<PatientTestsDbWAF<Startup>>
{
// HttpClient to call our api's
private readonly HttpClient httpClient;
public WebApplicationFactory<Startup> _factory;
public PatientTests(PatientTestsDbWAF<Startup> factory)
{
_factory = factory;
// Initiate the HttpClient
httpClient = _factory.CreateClient();
}
[Theory]
[InlineData("Test Name 2", "1234567891", 20, "Male", HttpStatusCode.Created)]
[InlineData("T", "1234567891", 20, "Male", HttpStatusCode.BadRequest)]
[InlineData("A very very very very very very loooooooooong name", "1234567891", 20, "Male", HttpStatusCode.BadRequest)]
[InlineData(null, "1234567890", 20, "Invalid Gender", HttpStatusCode.BadRequest)]
[InlineData("Test Name", "InvalidNumber", 20, "Male", HttpStatusCode.BadRequest)]
[InlineData("Test Name", "1234567890", -10, "Male", HttpStatusCode.BadRequest)]
[InlineData("Test Name", "1234567890", 20, "Invalid Gender", HttpStatusCode.BadRequest)]
[InlineData("Test Name", "12345678901234444", 20, "Invalid Gender", HttpStatusCode.BadRequest)]
public async Task PatientTestsAsync(String Name, String PhoneNumber, int Age, String Gender, HttpStatusCode ResponseCode)
{
var scopeFactory = _factory.Services;
using (var scope = scopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetService<DataContext>();
// Initialize the database, so that
// changes made by other tests are reset.
await DBUtilities.InitializeDbForTestsAsync(context);
// Arrange
var request = new HttpRequestMessage(HttpMethod.Post, "api/patient");
request.Content = new StringContent(JsonSerializer.Serialize(new Patient
{
Name = Name,
PhoneNumber = PhoneNumber,
Age = Age,
Gender = Gender
}), Encoding.UTF8, "application/json");
// Act
var response = await httpClient.SendAsync(request);
// Assert
var StatusCode = response.StatusCode;
Assert.Equal(ResponseCode, StatusCode);
}
}
}
}
[Theory] attribute allows us to mention different parameters for our tests. Consequently, we don't have to write different tests for all the combinations.
Also, DBUtilities is a utility class to reinitialise the database to it's initial state. This might seem trivial when we have 1 or 2 tests but, gets critical as we add more tests.
DBUtilities
The DBUtilities class will initialise your database with 1 patient and 3 different type of rooms.
Create a file named DBUtilities.cs in your TDD.Tests project with the below code.
using System.Threading.Tasks;
namespace TDD.Tests
{
// Helps to initialise the database either from the WAF for the first time
// Or before running each test.
public class DBUtilities
{
// Clears the database and then,
//Adds 1 Patient and 3 different types of rooms to the database
public static async Task InitializeDbForTestsAsync(DataContext context)
{
context.RoomPatient.RemoveRange(context.RoomPatient);
context.Patient.RemoveRange(context.Patient);
context.Room.RemoveRange(context.Room);
// Arrange
var Patient = new Patient
{
Name = "Test Patient",
PhoneNumber = "1234567890",
Age = 20,
Gender = "Male"
};
context.Patient.Add(Patient);
var ICURoom = new Room
{
RoomType = "ICU",
MaxCapacity = 1,
CurrentCapacity = 1
};
context.Room.Add(ICURoom);
var GeneralRoom = new Room
{
RoomType = "General",
MaxCapacity = 2,
CurrentCapacity = 2
};
context.Room.Add(GeneralRoom);
var PremiumRoom = new Room
{
RoomType = "Premium",
MaxCapacity = 1,
CurrentCapacity = 1
};
context.Room.Add(PremiumRoom);
await context.SaveChangesAsync();
}
}
}
Go ahead and run the dotnet test command again and you will see 1 passed and 4 failed tests. This is because the 4 tests were expecting BadRequest but getting a Created result.
Let's fix it!
Success (Green) State
In order to fix these we need to add attributes to our Patient.cs class.
Update the Patient.cs file as below.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TDD
{
public class Patient : IValidatableObject
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Required]
[StringLength(40, MinimumLength = 2, ErrorMessage = "The name should be between 2 & 40 characters.")]
public String Name { get; set; }
[Required]
[DataType(DataType.PhoneNumber)]
[RegularExpression(@"^(\d{7,12})$", ErrorMessage = "Not a valid phone number")]
public String PhoneNumber { get; set; }
[Required]
[Range(1, 150)]
public int Age { get; set; }
[Required]
public String Gender { get; set; }
public Boolean IsAdmitted { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// Only Male, Female or Other gender are allowed
if (Gender.Equals("Male", System.StringComparison.CurrentCultureIgnoreCase) == false &&
Gender.Equals("Female", System.StringComparison.CurrentCultureIgnoreCase) == false &&
Gender.Equals("Other", System.StringComparison.CurrentCultureIgnoreCase) == false)
{
yield return new ValidationResult("The gender can either be Male, Female or Other");
}
yield return ValidationResult.Success;
}
}
}
Here, we have added the required attributes. We have also implemented the IValidatableObject interface so that we can verify the Gender.
Time to run the dotnet test command. You will see a nice green line saying 5 tests passed.
You can add more edge case scenarios in the InlineData to test the Patient model validation tests thoroughly.
Duplicate Patient Test
We shall now create a test which fails when we try to add a duplicate patient.
Failing (Red) Test
Create another test in your class PatientTests. Add the below code.
[Fact]
public async Task PatientDuplicationTestsAsync()
{
var scopeFactory = _factory.Services;
using (var scope = scopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetService<DataContext>();
await DBUtilities.InitializeDbForTestsAsync(context);
// Arrange
var Patient = await context.Patient.FirstOrDefaultAsync();
var Request = new HttpRequestMessage(HttpMethod.Post, "api/patient");
Request.Content = new StringContent(JsonSerializer.Serialize(Patient), Encoding.UTF8, "application/json");
// Act
var Response = await httpClient.SendAsync(Request);
// Assert
var StatusCode = Response.StatusCode;
Assert.Equal(HttpStatusCode.BadRequest, StatusCode);
}
}
We have used a [Fact] attribute instead of [Theory] attribute here since we don't want to test the same method with different parameters. Instead, we want to make the same request twice.
Run dotnet test to run our newly created test. The test will fail with message Assert.Equal() Failure. Time to fix it.
Success (Green) Test
To fix the failing test we need to add the implementation for the AddPatient method in PatientController.cs. Update the file's code as below.
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace TDD.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class PatientController : Controller
{
private readonly DataContext _context;
public PatientController(DataContext context)
{
_context = context;
}
[HttpPost]
public async Task<IActionResult> AddPatientAsync([FromBody] Patient Patient)
{
var FetchedPatient = await _context.Patient.FirstOrDefaultAsync(x => x.PhoneNumber == Patient.PhoneNumber);
// If the patient doesn't exist create a new one
if (FetchedPatient == null)
{
_context.Patient.Add(Patient);
await _context.SaveChangesAsync();
return Created($"/patient/{Patient.Id}", Patient);
}
// Else throw a bad request
else
{
return BadRequest();
}
}
}
}
Run the dotnet test again and you will see that the test has passed.
You can run all the tests by calling dotnet test.
Important Notes
As you add more models/domains like Doctors, Staff, Instruments etc. You will have to create more tests. Make sure to have a different WAF, utility wrappers and different Test files for each of them.
Secondly, the tests in the same file do not run in parallel. But, the tests from different files do run in parallel. Therefore, each WAF should have a different database name so that data is not misconfigured.
Lastly, the connections to the original database still needs to be setup in the main project.
Thought Process
The thought process for creating tests for all scenarios are similar.
That is, you should first identify the requirements. Then, set up a skeleton of methods and classes without implementation. Write tests to verify the implementation. Finally, refactor as needed and rerun the tests.
This tutorial didn't include authentication and authorisation for api's. You can read here on how to set it up.
Since, it is not possible to cover all the test cases, I have created a repository on Github. It covers the implementation for all the test cases and the implementation as well.
You can find the project here.
Conclusion
In order for TDD to be effective you really need to have a clear idea of what the requirements are. If the requirements keep on changing it would get very tough to maintain the tests as well as the project.
TDD mainly covers unit, integration & functional tests. You will still have to do UAT, Configuration & Production testing before you go live.
Having said that, TDD is really helpful in making your project bug free. Secondly, it boosts your confidence for the implementation. You will be able to change bits & pieces of your code as long as the tests pass. Lastly, it provides a better architecture for your project.
Hope you like the article. Let me know your thoughts or feedback.
Top comments (8)
Nice 😄 , One question. TDD is test before you develop.
How can we test before we develop ? Suppose i have function
CheckValidUser()
.The original implementation of
CheckValidUser()
function is not implemented or even coded then how can i test that ?That's the beauty of TDD.
In your use case, you will create an empty function CheckValidUser().
Then you write the tests and see them fail.
And finally write the implementation for CheckValidUser() such that the tests pass.
This avoids writing extra unnecessary code.
Initially it might feel counter intuitive like taking your car in reverse by looking at the mirror and not looking back. But once mastered it is the easier and more convenient way.
Ok Got it so you will create an empty function. I was thinking how come anyone test something without creating nothing. 😄
Suppose i am working in a company were they need to ship a product within 3 months. Writing TDD will cost time.
TDD can prevent bugs. But what other value does
TDD
gives ?TDD provides clarity of requirements. One needs to understand the project or a part of project thoroughly before starting deployment.
For me this is even bigger advantage than preventing bugs.
I have seen a lot of developers of dive code first and then think about the architecture, refactoring etc. By the time it's too late.
Clarity of requirements should be provided by the specification and design documents, not by code, not even by test code.
Agreed. But I was talking more from a standpoint of a developer. He/she needs to be clear on what is to be developed and TDD helps to get this clarity.
The specification and requirements have their own unique place.
Hi @arjavdave ,
Thanks for your post. It's a nice introduction. But I think you should check the sample code. "Lesser than" and "Greater than" symbols (< & >) are encoded. So the code looks like this
instead of this
It would help with readability a lot.
Good catch. I have updated the code.