Parte 2
- Testes de integração com buckets AWS S3 usando Localstack e Testcontainers
Introdução
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?
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?
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
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:
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:
❗ 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
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:
app.MapPost("/upload",async(IAmazonS3s3Client,IFormFilefile)=>{varbucketName=builder.Configuration["BucketName"]!;varbucketExists=awaits3Client.DoesS3BucketExistAsync(bucketName);if(!bucketExists){returnResults.BadRequest($"Bucket {bucketName} does not exists.");}usingvarfileStream=file.OpenReadStream();varputObjectRequest=newPutObjectRequest(){BucketName=bucketName,Key=file.FileName,InputStream=fileStream};putObjectRequest.Metadata.Add("Content-Type",file.ContentType);varputResult=awaits3Client.PutObjectAsync(putObjectRequest);returnResults.Ok($"File {file.FileName} uploaded to S3 successfully!");});app.MapGet("/object/{key}",async(IAmazonS3s3Client,stringkey)=>{varbucketName=builder.Configuration["BucketName"]!;varbucketExists=awaits3Client.DoesS3BucketExistAsync(bucketName);if(!bucketExists){returnResults.BadRequest($"Bucket {bucketName} does not exists.");}try{vargetObjectResponse=awaits3Client.GetObjectAsync(bucketName,key);returnResults.File(getObjectResponse.ResponseStream,getObjectResponse.Headers.ContentType);}catch(AmazonS3Exceptionex)when(ex.ErrorCode.Equals("NotFound",StringComparison.OrdinalIgnoreCase)){returnResults.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
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:
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>:
ℹ️ 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
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.
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):
ℹ️ 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
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:
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:
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: