DEV Community

👨‍💻 Lucas Silva
👨‍💻 Lucas Silva

Posted on

TestContainers para testes de integração com .Net

Introdução

Diferente de testes de unidade, os testes de integração permitem validar o comportamento de uma aplicação quando todos os componentes dela são utilizados em conjunto. Isso inclui bancos de dados, serviços de cache, serviços de mensageria etc.

Na teoria, tudo parece interessante e simples. Mas esses testes podem gerar e alterar um grande volume de dados, então é necessário tomar cuidado com os recursos utilizados. Até porque acidentes acontecem, e talvez, em um descuido, você pode acabar executando um DELETE sem WHERE, levando à exclusão total de uma tabela. 😅

Para evitar esse tipo de problemas, é possível criar esses recursos a partir de containers Docker por meio da lib TestContainers.

Neste tutorial, explicarei os passos para a utilização desses containers em uma API .Net.

API

O projeto completo pode ser encontrado neste link. Trata-se de uma API de gerenciamento de tarefas (a famosa "To-Do list"). Ela consiste de basicamente 3 partes:

Uma entidade:

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

Um service com a lógica da aplicação (visando a simplicidade, algumas operações não foram criadas):

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

E um 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

Testes

A configuração de TestContainer é feita na criação da WebApplicationFactory para os testes de integração. Neste tutorial, decidi utilizar PostgreSQL. A criação de um container desse banco de dados pode ser feita da seguinte forma:

private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().WithUsername("postgres").WithPassword("postgres").Build();
Enter fullscreen mode Exit fullscreen mode

É possível alterar o usuário e a senha da forma que desejar. Há, inclusive, a opção de alterar outras configurações no builder, como o nome do db, o host etc.

Com o container criado, é possível obter a connection string dele da seguinte forma:

_postgres.GetConnectionString()
Enter fullscreen mode Exit fullscreen mode

Pode ser necessário remover o dbContext da aplicação para adicionar um novo com a connection string do container de teste. Nesse caso, é possível fazê-lo da seguinte forma:

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

Por fim, é interessante que sua classe de WebApplicationFactory implemente a interface IAsyncLifetime para que o container criado seja inicializado / parado.

public Task InitializeAsync()
{
    return _postgres.StartAsync();
}

public new Task DisposeAsync()
{
    return _postgres.StopAsync();
}
Enter fullscreen mode Exit fullscreen mode

Com a configuração feita, já é possível criar testes de integração. No teste abaixo, utilizei o TodoService para criar uma tarefa, e então verifiquei se os dados no banco de dados estavam de acordo com o que deveriam:

[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

(Acesse o repositório para verificar os demais testes.)

O exemplo acima testa uma classe simples. Entretanto, poderia testar uma classe mais complexa, como um Handler, que manipula os dados através de diversos objetos. Além disso, o service foi criado para não testar as chamadas diretas ao controller.

GitHub actions

É possível integrar os testes à pipeline. Essa postagem do Milan Jovanović mostra como integrá-los a uma 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

Top comments (0)