code-snippets/docs/dotnet/controller-testing.md
Liam Pietralla 8ad5845efc
Some checks failed
Build, Test & Publish / Build and Publish Container Image (push) Has been cancelled
Build, Test & Publish / Deploy to Infrastructure (push) Has been cancelled
Build, Test & Publish / Build (push) Has been cancelled
initial commit
2024-09-05 13:54:08 +10:00

15 KiB

Testing Controller

Testing controllers can be an important part of your application testing strategy. Controllers are the entry point for your application and are responsible for handling requests and responses. In this guide we will cover how to test controllers in a Web API application.

Testing controllers is a little more tricky but the best method is to actually run a test version of your app, and interact with it using HTTP requests. This style of testing is known as integration testing and is a great way to test the entire application stack, even though we will often use a in-memory database to avoid the need for a real database.

.NET provides the WebApplicationFactory class to help with this. This class allows you to create a test server that can be used to send HTTP requests to your application.

Example Application

In this example we will create a simple Web API to maintain a user list. To get started create a new Web API project titled UserApp and enabled controller support.

User Model and DB Context

Create a new Data folder and insert the following files:

::: code-group

namespace UserApp.Data
{
    public class User
    {
        public int Id { get; set; }
        public string Name { get; set; } = string.Empty;
        public string Email { get; set; } = string.Empty;
        public string FirstName { get; set; } = string.Empty;
        public string LastName { get; set; } = string.Empty;
    }
}

:::

::: code-group

using Microsoft.EntityFrameworkCore;

namespace UserApp.Data
{
    public class UserAppContext : DbContext
    {
        public UserAppContext(DbContextOptions<UserAppContext> options) : base(options) { }

        public DbSet<User> Users { get; set; }
    }
}

:::

::: code-group

namespace UserApp.Models
{
    public class UserViewModel(string email, string firstName, string lastName)
    {
        public string Email { get; set; } = email;
        public string FirstName { get; set; } = firstName;
        public string LastName { get; set; } = lastName;
    }
}

:::

As part of this ensure the following NuGet packages are installed:

  • Microsoft.EntityFrameworkCore

Service Interface and Implementation

First create a entity not found exception:

::: code-group

namespace UserApp.Infrastructure.Exceptions
{
    public class EntityNotFoundException(string message) : Exception(message) { }
}

:::

Then we can create the service interface and implementation:

::: code-group

using UserApp.Data;

namespace UserApp.Services
{
    public interface IUserService
    {
        public Task<IEnumerable<User>> GetUsersAsync();
        public Task<User> GetUserAsync(int id);
        public Task<User> AddUserAsync(string email, string firstName, string lastName);
        public Task<User> UpdateUserAsync(int id, string email, string firstName, string lastName);
        public Task<User> DeleteUserAsync(int id);
    }
}
using Microsoft.EntityFrameworkCore;
using UserApp.Data;
using UserApp.Infrastructure.Exceptions;

namespace UserApp.Services
{
    public class UserService(UserAppContext context) : IUserService
    {
        private readonly UserAppContext _context = context;

        public async Task<User> AddUserAsync(string email, string firstName, string lastName)
        {
            User user = new()
            {
                Email = email,
                FirstName = firstName,
                LastName = lastName
            };

            await _context.Users.AddAsync(user);
            await _context.SaveChangesAsync();

            return user;
        }

        public async Task<User> DeleteUserAsync(int id)
        {
            User? user = await _context.Users.FindAsync(id) ?? throw new EntityNotFoundException("User not found");

            _context.Users.Remove(user);
            await _context.SaveChangesAsync();

            return user;
        }

        public async Task<User> GetUserAsync(int id)
        {
            User user = await _context.Users.FindAsync(id) ?? throw new EntityNotFoundException("User not found");

            return user;
        }

        public async Task<IEnumerable<User>> GetUsersAsync()
        {
            return await _context.Users.ToListAsync();
        }

        public async Task<User> UpdateUserAsync(int id, string email, string firstName, string lastName)
        {
            User user = await _context.Users.FindAsync(id) ?? throw new EntityNotFoundException("User not found");

            user.Email = email;
            user.FirstName = firstName;
            user.LastName = lastName;

            await _context.SaveChangesAsync();
            return user;
        }
    }
}

:::

Setup Dependency Injection

We now need to add our DI config to the Program.cs file. Add the following to the service section, you will also need to install the following NuGet packages:

  • Microsoft.EntityFrameworkCore.Sqlite
  • Microsoft.EntityFrameworkCore.Design
  • Microsoft.EntityFrameworkCore.Tools

::: code-group

builder.Services.AddDbContext<UserAppContext>(options =>
{
    options.UseSqlite("UserApp");
});

builder.Services.AddScoped<IUserService, UserService>();

:::

Once this is done we can generate the database migrations that we need:

::: code-group

dotnet ef migrations add InitialCreate
Add-Migration InitialCreate

:::

Controller

Finally we can create a controller to interact with the service:

::: code-group

using Microsoft.AspNetCore.Mvc;
using UserApp.Data;
using UserApp.Infrastructure.Exceptions;
using UserApp.Models;
using UserApp.Services;

namespace UserApp.Controllers
{
    [Route("api/[controller]")]
    public class UserController(IUserService userService, ILogger<UserController> logger) : ControllerBase
    {
        private readonly ILogger<UserController> _logger = logger;
        private readonly IUserService _userService = userService;

        [HttpGet]
        public async Task<IActionResult> GetUsersAsync()
        {
            try
            {
                return Ok(await _userService.GetUsersAsync());
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, "An error occurred while getting the users");
                return StatusCode(500);
            }
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetUserAsync(int id)
        {
            try
            {
                return Ok(await _userService.GetUserAsync(id));
            }
            catch (EntityNotFoundException exception)
            {
                _logger.LogError(exception, "User not found");
                return NotFound();
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, "An error occurred while getting the user");
                return StatusCode(500);
            }
        }

        [HttpPost]
        public async Task<IActionResult> AddUserAsync([FromBody] UserViewModel user)
        {
            try
            {
                User createdUser = await _userService.AddUserAsync(user.Email, user.FirstName, user.LastName);

                return CreatedAtAction("GetUser", new { id = createdUser.Id }, createdUser);
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, "An error occurred while adding the user");
                return StatusCode(500);
            }
        }

        [HttpPut("{id}")]
        public async Task<IActionResult> UpdateUserAsync(int id, [FromBody] UserViewModel user)
        {
            try
            {
                return Ok(await _userService.UpdateUserAsync(id, user.Email, user.FirstName, user.LastName));
            }
            catch (EntityNotFoundException exception)
            {
                _logger.LogError(exception, "User not found");
                return NotFound();
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, "An error occurred while updating the user");
                return StatusCode(500);
            }
        }

        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteUserAsync(int id)
        {
            try
            {
                return Ok(await _userService.DeleteUserAsync(id));
            }
            catch (EntityNotFoundException exception)
            {
                _logger.LogError(exception, "User not found");
                return NotFound();
            }
            catch (Exception exception)
            {
                _logger.LogError(exception, "An error occurred while deleting the user");
                return StatusCode(500);
            }
        }
    }
}

:::

Controller Testing

To test the controller we will create a new MSTest Test project called UserApp.Tests. Add the following NuGet packages:

  • Microsoft.EntityFrameworkCore.Sqlite
  • Microsoft.AspNetCore.Mvc.Testing
  • Microsoft.EntityFrameworkCore.Sqlite

Test Setup

For this example we are going to be using a SQLite database for testing the application. This is not needed for this paticular example, as the code is already using an in-memory database, but it is a good example showing how to override the database connection for testing.

In an actual system you would want to use a real database for testing, such as one spun up in a docker container in the CI.

To setup the test project add the following to the Program.cs file:

::: code-group

// ...
public partial class Program { }

:::

Now we want to create a new factory to generate the test server:

::: code-group

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using UserApp.Data;

namespace UserApp.Tests.Factories
{
    public class UserAppApplicationFactory<TProgram>(object[] existingEntites = null!) : WebApplicationFactory<TProgram> where TProgram : class
    {
        private readonly object[] _existingEntites = existingEntites ?? [];

        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(services =>
            {
                var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UserAppContext>));

                if (descriptor != null)
                {
                    services.Remove(descriptor);
                }

                services.AddDbContext<UserAppContext>((container, options) =>
                {
                    options.UseNpgsql("Server=localhost;Port=5432;Database=postgres;User Id=postgres;Password=postgres;");
                });

                // After the DbContext is registered, we need to create the database
                using var scope = services.BuildServiceProvider().CreateScope();
                var context = scope.ServiceProvider.GetRequiredService<UserAppContext>();
                context.Database.ExecuteSqlRaw(DB_CLEAR);
                context.Database.Migrate();

                // Add any existing entities to the context
                foreach (var entity in _existingEntites)
                {
                    context.Add(entity);
                }

                if (_existingEntites.Length != 0)
                {
                    context.SaveChanges();
                }
            });
        }

        public string DB_CLEAR = """
            DROP TABLE IF EXISTS "Users";
            DROP TABLE IF EXISTS "__EFMigrationsHistory";
        """;
    }
}

:::

::: danger

NOTE: The above code will delete any existing data in the database, so be careful when using this in a real application. Always make sure the connection string is pointing to a test database.

:::

This setup requires that a Postgres database is running, the best way to do this is to use a docker container (which is what will occur in the CI as well). A compose is below:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres
    ports:
      - "5432:5432"

Writing Controller Tests

Now we can write some tests for the controller. Create a new file called UsersControllerTests.cs and add the following:

::: code-group

using Newtonsoft.Json;
using UserApp.Data;
using UserApp.Tests.Factories;

namespace UserApp.Tests
{
    [TestClass]
    public class UserControllerTests
    {
        private HttpClient _client = null!;
        private UserAppApplicationFactory<Program> _factory = null!;


        [TestCleanup]
        public void Cleanup()
        {
            _client.Dispose();
            _factory.Dispose();
        }

        [TestMethod]
        public async Task GetUsers_ForNoUsers_ReturnsSuccessAndNoUsers()
        {
            // Arrange
            _factory = new UserAppApplicationFactory<Program>();
            _client = _factory.CreateClient();
            var request = new HttpRequestMessage(HttpMethod.Get, "/api/user");

            // Act
            var response = await _client.SendAsync(request);

            // Assert
            response.EnsureSuccessStatusCode();
            var content = await response.Content.ReadAsStringAsync();
            Assert.AreEqual("[]", content);
        }

        [TestMethod]
        public async Task GetUsers_ForUsers_ReturnsSuccessAndUsers()
        {
            // Arrange
            User user1 = new()
            {
                Id = 1,
                FirstName = "John",
                LastName = "Doe",
                Email = "john.doe@test.com"
            };

            User user2 = new()
            {
                Id = 2,
                FirstName = "Jane",
                LastName = "Doe",
                Email = "jane.doe@test.com"

            };

            _factory = new UserAppApplicationFactory<Program>([user1, user2]);
            _client = _factory.CreateClient();

            // Act
            var response = await _client.GetAsync("/api/user");

            // Assert
            response.EnsureSuccessStatusCode();
            var content = await response.Content.ReadAsStringAsync();
            var users = JsonConvert.DeserializeObject<User[]>(content);
            Assert.AreEqual(2, users!.Length);
            Assert.AreEqual("john.doe@test.com", users[0].Email);
            Assert.AreEqual("jane.doe@test.com", users[1].Email);
        }
    }
}

:::