DEV Community

👨‍💻 Lucas Silva
👨‍💻 Lucas Silva

Posted on • Edited on

TestContainers for Integration Testing with .NET

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; }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)