DEV Community

Fernando Andrade
Fernando Andrade

Posted on

Testes de integração com containers

Tabela de conteúdo

1. Introdução
2. Requisitos
3. Conteúdo
4. Consideração final

1. Introdução

Durante o desenvolvimento de software é essencial realizamos testes. Testes de integração permite que todo fluxo da aplicação seja testada, desde da sua infraestutura até as chamadas da suas APIs, dessa forma garantindo que entregue o que seja esperado e tornando-se mais fácil de identificar erros que podem existir durante as chamadas de suas APIs.

Geralmente quando realizamos testes de integração é utilizado um DbInMemory(Um banco de dados na memória funciona como um banco de dados comum, mas o conteúdo é armazenado na memória em vez de no disco), porém, injetar dados em memória não nos traz um cenário real de uma aplicação rodando. Utilizar um container docker nos permite subir um banco de dados temporariamente para execução dos casos de teste e com isso conseguimos replicar um cenário real de uma aplicação rodando em conjunto com um banco de dados.

Este artigo será um pouquinho longo. Assumindo que você já tenha um conhecimento básico com .NET. Este é um tutorial de como configurar uma aplicação em .NET 6 ou superior para subir uma instância do docker e os testes serem capazes de interagir com um banco de dados, fazendo uso do package Testcontainers para subir a instância. Para este exemplo não foi utilizado nenhum padrão de projeto popular, mantendo o mais simples possível. Você pode encontrar o source do código neste link

2. Requisitos

Para este tutorial você precisa ter

3. Conteúdo

Considerando que já tenha uma experiência prévia com C# e .NET o código está simples para entendimento. Temos uma solução com dois projetos, sendo um a webapi e o outro projeto de teste, com a seguinte estrutura:

TodoApi

├── appsettings.Development.json
├── appsettings.json
├── Context
│   ├── AppContext.cs
│   └── IAppContext.cs
├── Controllers
│   └── TodoController.cs
├── Models
│   └── Todo.cs
├── Program.cs
├── Properties
│   └── launchSettings.json
└── TodoApi.csproj
Enter fullscreen mode Exit fullscreen mode

TodoApiIntegrationTest

├── Setup
│   ├── ClientFixture.cs
│   ├── SeedCreator.cs
│   └── WebApiFactoryConfig.cs
├── TodoApiIntegrationTest.csproj
├── TodoControllerTest.cs
└── Usings.cs
Enter fullscreen mode Exit fullscreen mode

Entendo a webapi

Iniciando pela webapi temos:

  • Models/Todo.cs: Entidade que é representada no banco de dados.
public class Todo
{
    public int Id {get; set;}
    public string? Title {get; set;}
    public bool Done {get; set;}
}
Enter fullscreen mode Exit fullscreen mode
  • Context/IAppContext.cs: O IAppContext.cs representa o contrato para a nossa classe abstrata AppContext.
public interface IAppContext
{
    DbSet<Todo> Todos {get; set;}
}
Enter fullscreen mode Exit fullscreen mode
  • Context/AppContext.cs: O AppContext.cs representa a definição da classe abstrata da nossa interface IAppContext.
public class AppDbContext : DbContext, IAppContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) {}

    public DbSet<Todo> Todos {get; set;}
}
Enter fullscreen mode Exit fullscreen mode
  • Controller/TodoController.cs: Nele contém os endpoints, e estamos injetando por dependência o Context do banco diretamente pela simplicidade.
[ApiController]
[Route("api/[controller]")]
public class TodoController : ControllerBase
{
    private readonly AppDbContext _context;

    public TodoController(AppDbContext context)
    {
        _context = context;
    }

    [HttpGet]
    public async Task<ActionResult<int>> GetAllTodo()
    {
       var result = await _context.Todos
        .AsNoTracking()
        .ToListAsync();

       return Ok(result);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<int>> GetTodoById(int id)
    {
       var result = await _context.Todos
        .AsNoTracking()
        .FirstOrDefaultAsync(todo => todo.Id == id);

       return Ok(result);
    }

    [HttpPost]
    public async Task<ActionResult<int>> CreateTodo(Todo todo)
    {
        try
        {
            await _context.Todos.AddAsync(todo);
            await _context.SaveChangesAsync();
            return Created("", todo.Id);
        }
        catch(Exception ex)
        {
            return Ok(ex);
        }
    }


}
Enter fullscreen mode Exit fullscreen mode

Program.cs: Responsável por registrar e resolver as dependências do projeto. A partir do .NET 6 não há a necessidade de uma class statup e a aplicação é iniciada atráves do top level statements.
No Program.cs é implementado o context, passando a conexão com o banco e usando para este exemplo o provedor Npgsql e o registro do IAppContext para conseguirmos utilizar injeção de dependência, e, além do Context é adicionado uma classe partial, fazemos isso porque a aplicação está sendo iniciada por top level statement, para os testes de integração é utilizado o WebApplicationFactory que receber um classe Program, utilizar uma class partial permite expor o conteúdo do Program.cs

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var connection = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
        options.UseNpgsql(connection).UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

builder.Services.AddScoped<IAppContext>(provider => provider.GetRequiredService<AppDbContext>());
var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

public partial class Program { }
Enter fullscreen mode Exit fullscreen mode

Entendo o projeto de teste

Agora iremos configurar o projeto de testes, neste exemplo faço uso de Fixture(abordagem de subir uma instância da classe de teste para cada teste executado, dessa forma qualquer código inserido no construtor da classe de teste será executado para cada teste individual) afim de evitar repetição de código e deixar mais limpo possível.

WebApiFactoryConfig.cs

No WebApiFactoryConfig.cs vamos utilizar o WebApplicationFactory para criar uma instância da nossa classe Program
Para classe é criado dois types genericos para facilitar a criação da Fixture. Utilizando o TestContainer é temos uma propriedade _container que irá ser responsável por criar a instância do banco de dados utilizando uma imagem docker. Em seguida fazemos uma nova implementação do método ConfigureWebHost através do override para implementar nossa conexão da imagem docker, além disso é adicionado também o SeedCreator para adicionar alguns dados para os testes.

public class WebApiFactoryConfig<TProgram, TDbContext> :
    WebApplicationFactory<TProgram>, IAsyncLifetime where TProgram :
        class where TDbContext : DbContext
{
    private readonly TestcontainerDatabase _container =
        new TestcontainersBuilder<PostgreSqlTestcontainer>()
            .WithDatabase(new PostgreSqlTestcontainerConfiguration
            {
                Database = "todo_db",
                Password = "supersecuritypassword",
                Username = "postgres"
            })
            .WithImage("postgres:11")
            .WithCleanUp(true)
            .Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions<TDbContext>));
            if (descriptor != null) services.Remove(descriptor);
            services.AddDbContext<TDbContext>(options => { options.UseNpgsql(_container.ConnectionString); });


            var serviceProvider = services.BuildServiceProvider();

            using var scope = serviceProvider.CreateScope();
            var scopedServices = scope.ServiceProvider;
            var context = scopedServices.GetRequiredService<TDbContext>();
            context.Database.EnsureCreated();

            services.AddTransient<SeedCreator>();
        });
    }

    public async Task InitializeAsync() => await _container.StartAsync();

    public new async Task DisposeAsync() => await _container.DisposeAsync();
}
Enter fullscreen mode Exit fullscreen mode

SeedWork.cs

O SeedWork.cs é uma classe que há alguns métodos para adicionar dados no database da imagem docker

private readonly AppDbContext _context;

    public SeedCreator(AppDbContext context)
    {
        _context = context;
    }

    public async Task AddTodo()
    {
        var todos = new List<Todo>
        {
            new Todo { Done = false, Title = "Fazer Compras" },
            new Todo { Done = false, Title = "Areia para caixinha dos gatos" },
            new Todo { Done = false, Title = "Lavar as roupas" },
            new Todo { Done = true, Title = "Ir ao shopping" },
        };

        await _context.Todos.AddRangeAsync(todos);
        await _context.SaveChangesAsync();
    }
Enter fullscreen mode Exit fullscreen mode

ClientFixture

Essa classe é responsável por disponibilizar algumas propriedades e métodos de chamadas HTTP para nossos controler de teste, como também instanciar o WebAppFactory recebendo a classe Program e a classe do context AppDbContext

public class ClientFixture : IClassFixture<WebApiFactoryConfig<Program, AppDbContext>>
{
    private readonly WebApiFactoryConfig<Program, AppDbContext> Factory;
    public readonly HttpClient Client;
    public readonly SeedCreator SeedWork;
    public readonly AppDbContext dbContext;

    public ClientFixture(WebApiFactoryConfig<Program, AppDbContext> factory)
    {
        Factory = factory;
        var scope = factory.Services.CreateScope();
        dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        Client = factory.CreateClient();
        SeedWork = scope.ServiceProvider.GetRequiredService<SeedCreator>();
    }

    public async Task<HttpResponseMessage> AsPostAsync<T>(string url, T body)
    {
        var json = JsonSerializer.Serialize(body);
        var buffer = System.Text.Encoding.UTF8.GetBytes(json);
        var byteContent = new ByteArrayContent(buffer);
        byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
        return await Client.PostAsync(url, byteContent);
    }

    public async Task<HttpResponseMessage> AsPutAsync<T>(string url, T body)
    {
        var json = JsonSerializer.Serialize(body);
        var buffer = System.Text.Encoding.UTF8.GetBytes(json);
        var byteContent = new ByteArrayContent(buffer);
        byteContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");
        return await Client.PutAsync(url, byteContent);
    }

    public async Task<HttpResponseMessage> AsGetAsync(string url)
        => await Client.GetAsync(url);

    public async Task<HttpResponseMessage> AsDeleteAsync(string url)
        => await Client.DeleteAsync(url);
}
Enter fullscreen mode Exit fullscreen mode

TodoApiIntegrationTest.cs

No TodoApiIntegrationTest.cs herda o ClientFixture, é passado no construtor a classe Program e AppDbContext para instanciar o WebAppFactory. Temos dois casos de teste, com os métodos de chamada HTTP criado no ClienFixture e com isto conseguimos chamar as rotas da API, em seguida é garantir que o resultado da chamada esteja como esperado fazendo uso dos Asserts.

public class TodoControllerTest : ClientFixture
{
    public TodoControllerTest(WebApiFactoryConfig<Program, AppDbContext> factory) : base(factory) {}


    [Fact]
    public async Task Create_New_Todo_Return_200OK()
    {
        // Arrange
        Todo @todo = new()
        {
            Done= false,
            Title = "Novo todo",
        };

        // Act
        var response = await AsPostAsync("api/Todo/", todo);
        var resultId = Int32.Parse(await response.Content.ReadAsStringAsync());
        // Assert
        Assert.True(resultId > 0);
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    }

    [Fact]
    public async Task Get_Todo_Return_200OK()
    {
        // Arrange
        await SeedWork.AddTodo();

        // Act
        var response = await AsGetAsync("api/Todo/");
        var result = await response.Content.ReadAsStringAsync();

        // Assert
        List<Todo>? todos =  JsonSerializer.Deserialize<List<Todo>>(result);
        var assertresult = todos?.Count == 5;

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.True(assertresult);
    }
}
Enter fullscreen mode Exit fullscreen mode

Neste caso de teste foi realizado chamada do SeedWork e logo em seguida é possível obter os dados que foram adicionados no container 😁.

Image description

4. Consideração final

Testes de integração com container facilita a inicialização de um banco de dados completo para fins de teste, tornando-se perfeito para testes de integração!

Espero que este artigo tenha sido útil e básico para o entendimento de todos, obrigado se você leu até aqui.

Top comments (2)

Collapse
 
canhassi profile image
Canhassi

Muiitoo bom!! Parabens!

Collapse
 
renanvidal profile image
Renan Vidal Rodrigues

Muito bom! Valeu pelo conteúdo, aprendi um pouco mais graças a você!