Getting Started with Unit Testing in ASP.NET Core

Introduction

A process of testing the smallest functional unit of code. Unit testing often involves testing the logic of the unit of code by providing the fake input to that piece of code. It usually involves three steps Arrange, Act, and Assert.

Importance of Unit Testing in Modern Web Development

There are several reasons why unit testing is important.

  1. Ensure Code Quality: Writing unit tests for a piece helps verify that each line of code is working as expected or not.
  2. Promotes Maintainability: Well-tested helps to maintain the code base because we can identify the root cause of the problem more quickly.
  3. Bugs Detection: Bugs can be found more easily because you can provide fake data to that piece to check the behaviors it will perform in real scenarios.
  4. Facilitate Continuous Integration and Deployment: By writing unit tests you can include that in your CI/CD pipeline to ensure that when a code goes for deployment it first passes all the unit tests otherwise it does not.

Let's jump into the code and do some practical to understand it better.

Setting Up the Testing Environment

ASP.NET Core provides various testing frameworks like,

  • xUnit
  • NUnit
  • MSTest

For me, I like working with xUnit so in this demo we will go with xUnit. Create a new test project and select the xUnit testing framework template in Visual Studio. You can also create the project by using dotnet CLI.

dotnet new sln -o unit-testing-using-dotnet-test

Install Required Nuget Packages

Here's the corrected version, including the real commands to install the packages.

I like to use a few NuGet packages when working with unit testing.

  1. AutoFixture: Used to generate fake data. `dotnet add package AutoFixture`.
  2. Moq: Used to generate mock objects of dependencies. `dotnet add package Moq`.
  3. FluentAssertions: Used to assert test results more descriptively and functionally. `dotnet add package FluentAssertions`

What do you need to test exactly?

I have a clean architecture in this demo project and I will test the Application Layer. The application layer takes care of the HTTP Request, performs operations, and then gives a response back to the API layer. So in my opinion writing a unit test for the Application layer makes sense. Below is the screenshot of the clean architecture setup of my project.

HTTP Request

After setting up the solution with the xUnit Testing framework now let's start writing some unit tests.

I have this CompanyService class which is in the Application Layer and I will write unit tests for each function of this service.

public sealed class CompanyService
{
    private readonly ApplicationDbContext _context;

    public CompanyService(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<Company> GetCompanyAsync(Guid id) 
    {
        var company = await _context.Companies.FindAsync(id);
        return company!;
    }

    public async Task<Company> GetCompanyAsync(string companyName)
    {
        var company = await _context.Companies.SingleOrDefaultAsync(e => e.Name.Equals(companyName));
        return company!;
    }

    public async Task<Guid> CreateCompany(string name)
    {
        var company = Company.Create(name);
        _context.Companies.Add(company);
        await _context.SaveChangesAsync();

        return company.Id;
    }
}

Now I will create a test class with the name CompanyServiceTest to write all my company entity-related tests inside it.

public class CompanyServiceTests: IDisposable
{
    private readonly ApplicationDbContext _dbContext;
    private readonly CompanyService _companyService;

    public CompanyServiceTests()
    {
        var options = new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseInMemoryDatabase(databaseName: "TestDatabase")
            .Options;

        _dbContext = new ApplicationDbContext(options);
        _companyService = new CompanyService(_dbContext);
    }

    [Fact]
    public async Task GetCompanyById_ShouldReturnCompany_WhenCompanyExists()
    {
        // Arrange
        var companyId = Guid.NewGuid();
        var company = new Company { Id = companyId, Name = "Test Company" };

        _dbContext.Companies.Add(company);
        await _dbContext.SaveChangesAsync();

        // Act
        var result = await _companyService.GetCompanyAsync(companyId);

        // Assert
        Assert.NotNull(result);
        Assert.Equal(companyId, result.Id);
        Assert.Equal("Test Company", result.Name);
    }

    [Fact]
    public async Task ShouldCreateCompany_WhenCompanyNotExists()
    {
        // Arrange
        var company = Company.Create("Test Company");

        _dbContext.Companies.Add(company);
        await _dbContext.SaveChangesAsync();

        // Act
        var result = await _companyService.CreateCompany(company.Name);

        // Assert
        result.Should().NotBeEmpty();
    }

    [Fact]
    public async Task GetCompanyByName_ShouldReturnCompany_WhenCompanyExists()
    {
        // Arrange
        var companyId = Guid.NewGuid();
        var company = new Company { Id = companyId, Name = "Test 1 Company" };

        _dbContext.Companies.Add(company);
        await _dbContext.SaveChangesAsync();

        // Act
        var result = await _companyService.GetCompanyAsync(company.Name);

        // Assert
        Assert.NotNull(result);
        Assert.Equal(companyId, result.Id);
        Assert.Equal("Test 1 Company", result.Name);
    }

    public void Dispose()
    {
        _dbContext.Dispose();
    }
}

Here's the corrected version.

Let me explain the code above.

  1. I have two read-only properties `ApplicationDbContext` and the actual `CompanyService`, for which we are writing unit tests. In the `CompanyServiceTests` class constructor, I need to set up the `dbContext` because, as you may recall from the previous code snippet, we are using Entity Framework's `dbContext` to interact with our database.
  2. For testing purposes, I'm using an in-memory database named "TestDatabase". We initialize the `dbContext` with the InMemoryDatabase setup and then pass it to the `CompanyService` constructor to obtain its instance for running the tests.
  3. You'll also notice that I'm implementing the `IDisposable` interface. This is to ensure that the `dbContext` is disposed of after the tests are run, releasing all the resources acquired by `dbContext`, including the InMemoryDatabase.

Let's understand the test now.

Start the function name with "Should_..." I love this because it gives more meaning to the test case. For example, "GetCompanyById_ShouldReturnCompany_WhenCompanyExists" describes our test case well.

  • Arrange: In this step, we set up all the required inputs and expected outputs that the code or function will use and produce.
  • Act: In this step, we execute the function or piece of code we want to test, using the inputs we set up in the Arrange step.
  • Assert: In this step, we take the result from the action and then perform assertions to check whether it behaved correctly with our dummy input and output.

Here you can all my tests related to CompanyService are passed.

CompanyService

How do you deal with Repository Interfaces in Unit Testing?

I have a repository named `DeviceRepository` as an example. My `DeviceService` uses this repository to handle database operations, and of course, the repository is using Entity Framework.

 public class DeviceService
 {
     private readonly IDeviceRepository _deviceRepository;
     private readonly IUserContextService _userContextService;

     public DeviceService(IDeviceRepository deviceRepository,
         IUserContextService userContextService)
     {
         _deviceRepository = deviceRepository;
         _userContextService = userContextService;
     }

     public async Task<GetDevicesListDto> GetDeviceAsync(Guid id)
     {
         var device = await _deviceRepository.Get(id);
         if (device is null)
         {
             throw new InvalidOperationException("Device not found.");
         }

         var deviceDto = new GetDevicesListDto();
         deviceDto.Id = id;
         deviceDto.Name = device.Name;
         deviceDto.Date = device.CreatedOn;
         deviceDto.TypeId = device.Type;
         deviceDto.Location = device.Location;

         return deviceDto;
     }
}

We have a simple function that retrieves a device from the database using a given ID, converts it into a DTO (Data Transfer Object), and returns it. Let's walk through the process of writing a unit test for this function.

public class DeviceServiceTests
{
    private readonly IFixture _fixture;
    private readonly Mock<IDeviceRepository> _deviceRepository;
    private readonly Mock<IUserContextService> _userContextService;
    private readonly DeviceService _deviceService;
    public DeviceServiceTests()
    {
        _fixture = (Fixture?)new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true })!;
        _deviceRepository = new Mock<IDeviceRepository>();
        _userContextService = new Mock<IUserContextService>();
        _deviceService = new DeviceService(_deviceRepository.Object, _userContextService.Object);
    }

    [Fact]
    public async Task Should_Get_Device_Async()
    {
        //Arrange
        var id = Guid.NewGuid();
        var device = _fixture.Create<Device>();
        var deviceListDto = _fixture.Create<GetDevicesListDto>();
        _deviceRepository.Setup(x => x.Get(It.IsAny<Guid>())).Returns(Task.FromResult(device));

        //Act
        var result = await _deviceService.GetDeviceAsync(id);

        //Assert
        result.Should().BeAssignableTo<GetDevicesListDto>();
        Assert.NotNull(result);
    }
}

Let me explain the above code block.

First, we set up all the dependencies using Moq instances and create an instance of the `DeviceService` class by passing these Moq instances through the constructor. In the test function, we followed the same process that we used when writing the unit tests for the `CompanyService` functions.

Conclusion

Unit testing in ASP.NET Core is crucial for ensuring code reliability and quality by detecting bugs early and facilitating safe code refactoring. It helps maintain a robust and maintainable codebase, leading to smoother deployments and reduced development costs.

Thanks for reading!