Learn Test-Driven Development with Integration Tests in .NET 5.0

Learn Test-Driven Development with Integration Tests in .NET 5.0

·

14 min read

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 test 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.

Test Success

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.

Failing (Red) State

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.

Check more tutorials on .NET here.