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.
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
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.
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
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
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
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
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
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
Repositório GitHub
Referências e links