Featured image of post Testes de integração com buckets AWS S3 usando Localstack e Testcontainers

Testes de integração com buckets AWS S3 usando Localstack e Testcontainers

Testando integrações AWS com um emulador AWS local

Esse post é parte de uma série:
Parte  2  -  Testes de integração com buckets AWS S3 usando Localstack e Testcontainers

Introdução Link to this section

Como expliquei anteriormente neste artigo, em testes de integração, devemos simular dependências não gerenciadas (dependências que são externas ao nosso sistema e não controladas por nós, como APIs), mas testar em relação a dependências gerenciadas reais (dependências que são controladas pelo nosso sistema, como bancos de dados, filas, etc.). Isso melhora a confiabilidade dos testes de integração porque a comunicação com essas dependências é uma parte complexa do sistema e pode quebrar com uma atualização de pacote, uma atualização de banco de dados ou uma alteração no formato da mensagem da fila.

Neste post, mostrarei como usar Localstack e Testcontainers para emular um ambiente AWS para uso em testes de integração.

Por que usar Localstack para testes de integração? Link to this section

  • Custos reduzidos: o uso do LocalStack elimina o uso de recursos da AWS durante os testes e o desenvolvimento. Também evita cobranças acidentais durante o desenvolvimento, por exemplo, em casos de lógica incorreta;
  • Velocidade de desenvolvimento: o uso do LocalStack permite que o desenvolvedor teste sem ter que implantar ou configurar credenciais da AWS no ambiente local. Também remove fatores externos, como outras pessoas usando os mesmos recursos da AWS no ambiente;
  • Reprodutibilidade e testes menos instáveis: os testes de integração são executados em um ambiente isolado, evitando qualquer interferência com o ambiente de produção ou preparação. Isso torna os testes reproduzíveis na máquina de qualquer desenvolvedor e menos instáveis porque não há dependência com a rede e um ambiente AWS compartilhado, que pode mudar frequentemente;
  • Limpeza: o ambiente LocalStack é limpo automaticamente após os testes, facilitando o uso em testes automatizados;
  • Teste de casos extremos: podemos personalizar a configuração do LocalStack, permitindo-nos testar casos extremos que podem não ser testados em um ambiente AWS, como limites de taxa e erros de permissões.

O que é Testcontainers? Link to this section

Testcontainers é uma biblioteca que gerencia o ciclo de vida dos containers para serem usados em testes automatizados. Esses containers são úteis para testar aplicativos em relação a dependências gerenciadas reais, como bancos de dados ou recursos da AWS (usando LocalStack) que podem ser criados e descartados após os testes.

Executando um container descartável com Testcontainers Link to this section

Para usar o Testcontainers, você precisará ter um tempo de execução de container (Docker, Podman, Rancher, etc.) instalado em sua máquina.

Em seguida, você precisa adicionar o pacote NuGet Testcontainers ao seu projeto de teste:

dotnet add package Testcontainers

Para executar um container LocalStack, primeiro precisamos usar a classe ContainerBuilder para construir um IContainer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const int LocalStackPort = 4566;
const string LocalStackImage = "localstack/localstack:1.3.1";

await using var LocalStackTestcontainer = new ContainerBuilder()
    .WithImage(LocalStackImage)
    .WithExposedPort(LocalStackPort)
    .WithPortBinding(LocalStackPort, true)
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilHttpRequestIsSucceeded(request => request
            .ForPath("/_localstack/health")
            .ForPort(LocalStackPort)))
    .Build();

Em seguida, iniciamos o container e usamos a propriedade Hostname e o método GetMappedPublicPort() para criar o ServiceUrl que será usado pelos clientes AWS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const int LocalStackPort = 4566;
const string LocalStackImage = "localstack/localstack:1.3.1";

await using var LocalStackTestcontainer = new ContainerBuilder()
    .WithImage(LocalStackImage)
    .WithExposedPort(LocalStackPort)
    .WithPortBinding(LocalStackPort, true)
    .WithWaitStrategy(Wait.ForUnixContainer()
        .UntilHttpRequestIsSucceeded(request => request
            .ForPath("/_localstack/health")
            .ForPort(LocalStackPort)))
    .Build();

await LocalStackTestcontainer.StartAsync();

var localstackUrl = $"http://{_localStackTestcontainer.Hostname}:{_localStackTestcontainer.GetMappedPublicPort(4566)}";

❗ A interface IContainer estende IAsyncDisposable e precisa ser descartada após o uso. Podemos usar a sintaxe await using, como no exemplo acima, ou chamar o método DisposeAsync, como no fixture mostrado abaixo.

Exemplo de contexto Link to this section

No exemplo da Parte 1, tenho um controlador com um método POST que envia uma imagem para um bucket S3 e um método GET que encontra uma imagem do bucket S3 pelo nome do arquivo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
app.MapPost("/upload", async (IAmazonS3 s3Client, IFormFile file) =>
{
    var bucketName = builder.Configuration["BucketName"]!;

    var bucketExists = await s3Client.DoesS3BucketExistAsync(bucketName);

    if (!bucketExists)
    {
        return Results.BadRequest($"Bucket {bucketName} does not exists.");
    }

    using var fileStream = file.OpenReadStream();

    var putObjectRequest = new PutObjectRequest()
    {
        BucketName = bucketName,
        Key = file.FileName,
        InputStream = fileStream
    };

    putObjectRequest.Metadata.Add("Content-Type", file.ContentType);

    var putResult = await s3Client.PutObjectAsync(putObjectRequest);

    return Results.Ok($"File {file.FileName} uploaded to S3 successfully!");
});

app.MapGet("/object/{key}", async (IAmazonS3 s3Client, string key) =>
{
    var bucketName = builder.Configuration["BucketName"]!;

    var bucketExists = await s3Client.DoesS3BucketExistAsync(bucketName);

    if (!bucketExists)
    {
        return Results.BadRequest($"Bucket {bucketName} does not exists.");
    }

    try
    {
        var getObjectResponse = await s3Client.GetObjectAsync(bucketName,
            key);

        return Results.File(getObjectResponse.ResponseStream,
            getObjectResponse.Headers.ContentType);
    }
    catch (AmazonS3Exception ex) when (ex.ErrorCode.Equals("NotFound", StringComparison.OrdinalIgnoreCase))
    {
        return Results.NotFound();
    }
});

⚠️ A lógica de negócios no controlador é apenas por uma questão de simplicidade. Em um aplicativo do mundo real, a lógica deve estar em um caso de uso/interator ou algo com o mesmo propósito.

Criando testes de integração com Testcontainers Link to this section

Neste exemplo, estou usando xUnit e a classe WebApplicationFactory<T> do ASP.NET Core.

Se você não sabe como usar a classe WebApplicationFactory<T>, expliquei neste post.

Ao ser executado na AWS, o AmazonS3Client obterá seus dados de acesso da função IAM anexada ao serviço que o executa. Ao ser executado localmente, ele obterá do perfil da AWS CLI chamado default ou das configurações que passamos para ele.

ℹ️ O perfil padrão é lido no arquivo de credenciais AWSCLI (%userprofile%\.aws\credentials no Windows e ~/.aws/credentials no Linux).

No código abaixo, estou verificando uma seção de configuração com o nome AWS, que não está presente no ambiente de produção. Se for encontrado, defino as propriedades Region, ServiceURL e ForcePathStyle do AmazonS3Config e passo-o para a criação do AmazonS3Client:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
builder.Services.AddSingleton<IAmazonS3>(sc =>
{
    var configuration = sc.GetRequiredService<IConfiguration>();
    var awsConfiguration = configuration.GetSection("AWS").Get<AwsConfiguration>();

    if (awsConfiguration?.ServiceURL is null)
    {
        return new AmazonS3Client();
    }
    else
    {
        return AwsS3ClientFactory.CreateAwsS3Client(
            awsConfiguration.ServiceURL,
            awsConfiguration.Region, awsConfiguration.ForcePathStyle,
            awsConfiguration.AwsAccessKey, awsConfiguration.AwsSecretKey);
    }
});

Para os testes de integração, essas configurações (exceto para o ServiceURL) serão configuradas no arquivo appsettings.IntegrationTest.json, que é injetado pela classe WebApplicationFactory<T>:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  ...

  "AWS": {
    "Region": "us-east-1",
    "ForcePathStyle": "true",
    "AwsAccessKey": "test",
    "AwsSecretKey": "test"
  }
}

ℹ️ Como o container terá uma porta pública aleatória, a configuração AWS:ServiceURL deve ser passada depois que o Testcontainers iniciar o container LocalStack, usando o Hostname e GetMappedPublicPort(4566) para criar o URL completo.

Fixture LocalStack/Testcontainer Link to this section

Este fixture iniciará e descartará o container Localstack, compartilhando-o com todos os testes.

Recomendo usar a mesma instância de container para todos os testes, em vez de uma por teste. Isso exigirá mais atenção para evitar a causa entre os testes, mas economizará tempo na execução deles.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[CollectionDefinition("LocalStackTestcontainer Collection")]
public class LocalStackTestcontainerCollection : 
    ICollectionFixture<LocalStackTestcontainerFixture>
{
}

public class LocalStackTestcontainerFixture : IAsyncLifetime
{
    public const int LocalStackPort = 4566;
    public const string LocalStackImage = "localstack/localstack:1.3.1";

    public IContainer LocalStackTestcontainer { get; private set; } = default!;
    
    public async Task InitializeAsync()
    {
        LocalStackTestcontainer = new ContainerBuilder()
            .WithImage(LocalStackImage)
            .WithExposedPort(LocalStackPort)
            .WithPortBinding(LocalStackPort, true)
            .WithWaitStrategy(Wait.ForUnixContainer()
                .UntilHttpRequestIsSucceeded(request => request
                    .ForPath("/_localstack/health")
                    .ForPort(LocalStackPort)))
            .Build();

        await LocalStackTestcontainer.StartAsync();
    }

    public async Task DisposeAsync()
    {
        await LocalStackTestcontainer.DisposeAsync();
    }
}

Teste de envio de imagem Link to this section

O teste lê as configurações com IConfiguration para criar um AmazonS3Client e criar o bucket no ambiente LocalStack e para afirmar a condição de teste (o objeto foi criado no bucket):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
[Collection("LocalStackTestcontainer Collection")]
public class UploadTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly CustomWebApplicationFactory _factory;
    private readonly IContainer _localStackTestcontainer;

    public UploadTests(CustomWebApplicationFactory factory,
        LocalStackTestcontainerFixture localStackTestcontainerFixture)
    {
        _factory = factory;
        _localStackTestcontainer = localStackTestcontainerFixture.LocalStackTestcontainer;
    }

    [Fact]
    public async Task UploadObject_Returns200()
    {
        //Arrange
        var localstackUrl = $"http://{_localStackTestcontainer.Hostname}:{_localStackTestcontainer.GetMappedPublicPort(4566)}";

        var HttpClient = _factory
            .WithWebHostBuilder(builder =>
            {
                builder.UseSetting("AWS:ServiceURL", localstackUrl);
            })
            .CreateClient();

        var configuration = _factory.Services.GetRequiredService<IConfiguration>();
        var awsConfiguration = configuration.GetSection("AWS").Get<AwsConfiguration>()!;

        var s3Client = AwsS3ClientFactory.CreateAwsS3Client(localstackUrl, 
            awsConfiguration.Region, awsConfiguration.ForcePathStyle,
            awsConfiguration.AwsAccessKey, awsConfiguration.AwsSecretKey);

        await s3Client.PutBucketAsync(configuration["BucketName"]);

        const string fileName = "upload.jpg";

        var filePath = Path.Combine(Directory.GetCurrentDirectory(),
            "Assets", fileName);

        //Act
        using var multipartFormContent = new MultipartFormDataContent();

        var fileStreamContent = new StreamContent(File.OpenRead(filePath));
        fileStreamContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");

        multipartFormContent.Add(fileStreamContent, name: "file", fileName: fileName);

        var httpResponse = await HttpClient.PostAsync($"/upload", multipartFormContent);

        //Assert
        httpResponse.StatusCode.Should().Be(HttpStatusCode.OK);

        var bucketFile = await s3Client.GetObjectAsync(configuration["BucketName"],
            fileName);

        bucketFile.HttpStatusCode.Should().Be(HttpStatusCode.OK);
    }
}

ℹ️ Também poderíamos criar o bucket S3 usando o método ExecAsync do IContainer do Testcontainer para executar comandos da AWS CLI no container Localstack, mas acho mais fácil e menos propenso a erros fazer isso com o AWS SDK.

Teste de envio de imagem existente Link to this section

Este teste usa o método ListObjectsAsync do AmazonS3Client para afirmar que o envio de uma imagem com o mesmo nome de arquivo substituirá a imagem em vez de criar outra:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
[Fact]
public async Task UploadExistentObject_Returns200AndOverride()
{
    //Arrange
    var localstackUrl = $"http://{_localStackTestcontainer.Hostname}:{_localStackTestcontainer.GetMappedPublicPort(4566)}";

    var HttpClient = _factory
        .WithWebHostBuilder(builder =>
        {
            builder.UseSetting("AWS:ServiceURL", localstackUrl);
        })
        .CreateClient();

    var configuration = _factory.Services.GetRequiredService<IConfiguration>();
    var awsConfiguration = configuration.GetSection("AWS").Get<AwsConfiguration>()!;

    var s3Client = AwsS3ClientFactory.CreateAwsS3Client(localstackUrl,
        awsConfiguration.Region, awsConfiguration.ForcePathStyle,
        awsConfiguration.AwsAccessKey, awsConfiguration.AwsSecretKey);

    await s3Client.PutBucketAsync(configuration["BucketName"]);

    const string fileName = "upload.jpg";

    var filePath = Path.Combine(Directory.GetCurrentDirectory(),
        "Assets", fileName);

    //Act
    using var multipartFormContent = new MultipartFormDataContent();

    var fileStreamContent = new StreamContent(File.OpenRead(filePath));
    fileStreamContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg");

    multipartFormContent.Add(fileStreamContent, name: "file", fileName: fileName);

    var httpResponse1 = await HttpClient.PostAsync($"/upload", multipartFormContent);

    var httpResponse2 = await HttpClient.PostAsync($"/upload", multipartFormContent);

    //Assert
    httpResponse1.StatusCode.Should().Be(HttpStatusCode.OK);

    httpResponse2.StatusCode.Should().Be(HttpStatusCode.OK);

    var bucketObjects = await s3Client.ListObjectsAsync(configuration["BucketName"]);

    bucketObjects.S3Objects.Count.Should().Be(1);

    var bucketFile = await s3Client.GetObjectAsync(configuration["BucketName"],
        fileName);

    bucketFile.HttpStatusCode.Should().Be(HttpStatusCode.OK);
}

Teste de obtenção de imagem Link to this section

Para testar o endpoint que retorna a imagem, uso o AmazonS3Client para criar o bucket e enviar uma imagem e, em seguida, chamo o endpoint e afirmo que ele retorna a imagem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
[Collection("LocalStackTestcontainer Collection")]
public class GetObjectTests : IClassFixture<CustomWebApplicationFactory>
{
    private readonly CustomWebApplicationFactory _factory;
    private readonly IContainer _localStackTestcontainer;

    public GetObjectTests(CustomWebApplicationFactory factory,
        LocalStackTestcontainerFixture localStackTestcontainerFixture)
    {
        _factory = factory;
        _localStackTestcontainer = localStackTestcontainerFixture.LocalStackTestcontainer;
    }

    [Fact]
    public async Task GetExistingObject_Returns200()
    {
        //Arrange
        var localstackUrl = $"http://{_localStackTestcontainer.Hostname}:{_localStackTestcontainer.GetMappedPublicPort(4566)}";

        var HttpClient = _factory
            .WithWebHostBuilder(builder =>
            {
                builder.UseSetting("AWS:ServiceURL", localstackUrl);
            })
            .CreateClient();

        var configuration = _factory.Services.GetRequiredService<IConfiguration>();
        var awsConfiguration = configuration.GetSection("AWS").Get<AwsConfiguration>()!;

        var s3Client = AwsS3ClientFactory.CreateAwsS3Client(localstackUrl,
            awsConfiguration.Region, awsConfiguration.ForcePathStyle,
            awsConfiguration.AwsAccessKey, awsConfiguration.AwsSecretKey);

        await s3Client.PutBucketAsync(configuration["BucketName"]);

        const string fileName = "upload.jpg";

        var filePath = Path.Combine(Directory.GetCurrentDirectory(),
            "Assets", fileName);

        var putObjectRequest = new PutObjectRequest()
        {
            BucketName = configuration["BucketName"],
            Key = fileName,
            FilePath = filePath
        };

        putObjectRequest.Metadata.Add("Content-Type", "image/jpg");

        var putResult = await s3Client.PutObjectAsync(putObjectRequest);

        //Act
        var httpResponse = await HttpClient.GetAsync($"/object/{fileName}");

        //Assert
        httpResponse.StatusCode.Should().Be(HttpStatusCode.OK);

        httpResponse.Content.Headers.ContentType.Should().Be(MediaTypeHeaderValue.Parse("image/jpeg"));
    }
}

Teste de imagem não encontrada Link to this section

Para testar o comportamento quando a imagem não existe, uso o AmazonS3Client para criar o bucket e, em seguida, chamo o endpoint que retorna a imagem com um nome de arquivo que não existe no bucket:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[Fact]
public async Task GetInexistingObject_Returns404()
{
    //Arrange
    var localstackUrl = $"http://{_localStackTestcontainer.Hostname}:{_localStackTestcontainer.GetMappedPublicPort(4566)}";

    var HttpClient = _factory
        .WithWebHostBuilder(builder =>
        {
            builder.UseSetting("AWS:ServiceURL", localstackUrl);
        })
        .CreateClient();

    var configuration = _factory.Services.GetRequiredService<IConfiguration>();
    var awsConfiguration = configuration.GetSection("AWS").Get<AwsConfiguration>()!;

    var s3Client = AwsS3ClientFactory.CreateAwsS3Client(localstackUrl,
        awsConfiguration.Region, awsConfiguration.ForcePathStyle,
        awsConfiguration.AwsAccessKey, awsConfiguration.AwsSecretKey);

    await s3Client.PutBucketAsync(configuration["BucketName"]);

    const string fileName = "inexisting.jpg";

    //Act
    var httpResponse = await HttpClient.GetAsync($"/object/{fileName}");

    //Assert
    httpResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

Código fonte completo Link to this section

Repositório GitHub

💬 Like or have something to add? Leave a comment below.
Ko-fi
GitHub Sponsor
Licensed under CC BY-NC-SA 4.0
Criado com Hugo
Tema Stack desenvolvido por Jimmy