Intro
Unlike unit tests, integration tests allow validating the behavior of an application when all its components are used together. This includes databases, cache services, messaging services, etc.
In theory, everything seems interesting and simple. But these tests can generate and alter a large volume of data, so it is necessary to be careful with the resources used. After all, accidents happen, and you might end up executing a DELETE without WHERE, leading to the total deletion of a table. π
To avoid these kinds of problems, it's possible to create these resources using Docker containers through the TestContainers library.
In this tutorial, I'll explain the steps for using these containers in a .NET API.
API
The complete project can be found at this link. It's a task management API (the famous "To-Do list"). It consists basically of three parts:
An entity:
namespace IntegrationTestingDemo.API;
public class Todo
{
public Todo()
{
}
public Todo(string title, string description)
{
Title = title;
Description = description;
Id = Guid.NewGuid().ToString().Replace("-", "");
CreatedAt = DateTime.UtcNow;
Done = false;
}
public string Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public bool Done { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
A service (for simplicity's sake, some operations were not created):
public class TodoService(TodoContext context) : ITodoService
{
private readonly TodoContext _context = context;
public async Task<string> Create(string title, string description)
{
var todo = new Todo(title, description);
await _context.AddAsync(todo);
await _context.SaveChangesAsync();
return todo.Id;
}
public async Task<List<Todo>> GetAll()
{
return await _context.Todos.OrderBy(x => x.CreatedAt).ToListAsync();
}
public async Task<Todo> GetById(string id)
{
return await _context.Todos.FirstOrDefaultAsync(x => x.Id == id);
}
}
And a controller:
[ApiController]
[Route("[controller]")]
public class TodoController(ITodoService todoService) : ControllerBase
{
private readonly ITodoService _todoService = todoService;
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTodoModel model)
{
var result = await _todoService.Create(model.Title, model.Description);
return CreatedAtRoute(nameof(GetById), routeValues: new { Id = result }, result);
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var todos = await _todoService.GetAll();
return Ok(todos);
}
[HttpGet("{id}", Name = "GetById")]
public async Task<IActionResult> GetById(string id)
{
var todo = await _todoService.GetById(id);
return Ok(todo);
}
}
Tests
The TestContainer configuration is done when creating the WebApplicationFactory for integration tests. For this tutorial, I decided to use PostgreSQL. Creating it can be done as follows:
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().WithUsername("postgres").WithPassword("postgres").Build();
You can change the username and password as desired. There is also the option to change other settings in the builder, such as the db name, host etc.
You can obtain the container's connection string as follows:
_postgres.GetConnectionString()
It may be necessary to remove the dbContext from the application to add a new one with the connection string from the test container. If that's the case, you can do it as follows:
var context = services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(TodoContext));
if (context != null)
{
services.Remove(context);
var options = services.Where(r => (r.ServiceType == typeof(DbContextOptions))
|| (r.ServiceType.IsGenericType && r.ServiceType.GetGenericTypeDefinition() == typeof(DbContextOptions<>))).ToArray();
foreach (var option in options)
{
services.Remove(option);
}
}
services.AddDbContext<TodoContext>(options =>
{
options.UseNpgsql(_postgres.GetConnectionString());
});
Finally, it is interesting that your WebApplicationFactory class implements the IAsyncLifetime interface so that the created container is initialized/stopped.
public Task InitializeAsync()
{
return _postgres.StartAsync();
}
public new Task DisposeAsync()
{
return _postgres.StopAsync();
}
With the setup done, it is already possible to create integration tests. In the test below, I've used TodoService to create a Todo, then checked if its data was saved as expected:
[Fact]
public async Task Create_ShouldCreateTodoAndReturnItsId()
{
// Act
var result = await _todoService.Create(TestTitle, TestDescription);
// Assert
var todo = await _dbContext.Todos.FirstOrDefaultAsync(x => x.Id == result);
Assert.NotNull(todo);
Assert.False(todo.Done);
Assert.Equal(TestTitle, todo.Title);
Assert.Equal(TestDescription, todo.Description);
}
GitHub actions
It is possible to integrate the tests into a pipeline. This post by Milan JovanoviΔ shows how to integrate them into a Github Action:
name: Run Tests π
on:
workflow_dispatch:
push:
branches:
- main
jobs:
run-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore ./IntegrationTestingDemo.sln
- name: Build
run: dotnet build ./IntegrationTestingDemo.sln --no-restore
- name: Test
run: dotnet test ./IntegrationTestingDemo.sln --no-build
This is the end of the tutorial. I hope it has been enough to help you implement integration tests with TestContainers in your application.
Until next time!
Top comments (0)