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)