Introduction
As I explained previously in this article, in integration tests, we should mock unmanaged dependencies (dependencies that are external to our system and not controlled by us, like APIs) but test against real managed dependencies (dependencies that are controlled by our system, like databases, queues, etc). This improves the reliability of the integration tests because the communication with these dependencies are a complex part of the system and can break with a package update, a database update or a change in a queue message format.
In this post, I’ll show how to use Localstack and Testcontainers to emulate an AWS environment for use in integration tests.
Why use Localstack for integration tests?
- Reduced Costs: Using LocalStack eliminates the use of AWS resources during tests and development. It also avoids accidental charges during development, for example, in cases of incorrect logic;
- Development speed: Using LocalStack allows developer to test without having to deploy or configure AWS credentials on the local environment. It also remove external factors, as other people using the same AWS resources on the environment;
- Reproducibility and less flaky tests: Integration tests run in an isolated environment, avoiding any interference with the production or staging environment. This makes tests reproducible in any developer’s machine and less flaky because there is no dependency with the network and a shared AWS environment, that can change frequently;
- Cleanup: LocalStack environment is automatically cleaned up after the tests, making easier to use in automated tests;
- Test of Edge Cases: We can customize LocalStack’s configuration, allowing us to test edge cases that may not be tested on an AWS environment, like rate limits and permissions errors.
What is Testcontainers?
Testcontainers is a library that manages containers’ lifecycle to be used in automated tests. These containers are useful for testing applications against real managed dependencies, like databases, or AWS resources (using LocalStack) that can be created and disposed of after the tests.
Running a disposable container with Testcontainers
To use Testcontainers, you will need to have a container runtime (Docker, Podman, Rancher, etc) installed on your machine.
Then, you need to add the Testcontainers NuGet package to your test project:
dotnet add package Testcontainers
To run a LocalStack container, we first need to use the ContainerBuilder
class to build an 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();
|
Then, we start the container and use the Hostname
property and GetMappedPublicPort()
method to create the ServiceUrl that will be used by the AWS Clients:
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)}";
|
❗ The IContainer
interface extends IAsyncDisposable
and needs to be disposed of after use. We can use the await using
syntax, as in the example above, or call the DisposeAsync
method, as in the fixture shown below.
Example context
In the example from Part 1, I have a controller with a POST
method that uploads an image to an S3 bucket and a GET
method that finds an image from the S3 bucket by its file name:
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();
}
});
|
⚠️ The business logic in the controller is just for the sake of simplicity. In a real-world application, the logic should be in a Use Case/Interactor or something with the same purpose.
Creating integration tests with Testcontainers
In this example, I’m using xUnit and the WebApplicationFactory<T>
class from ASP.NET Core.
If you don’t know how to use the WebApplicationFactory<T>
class, I explained in this post.
When running in AWS, the AmazonS3Client
will get its access data from the IAM Role attached to the service running it. When running locally, it will get from the AWS CLI profile named default
or from the settings we pass to it.
ℹ️ The default profile is read from the AWSCLI credentials file (%userprofile%\.aws\credentials
on Windows and ~/.aws/credentials
on Linux).
In the code below, I’m checking for a configuration section with the name AWS
, that is not present in the production environment. If it’s found, I set the Region
, ServiceURL
and ForcePathStyle
properties of the AmazonS3Config
and pass it to the creation of the 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);
}
});
|
For the integration tests, those settings (except for the ServiceURL
) will be configured in appsettings.IntegrationTest.json
file, that is injected by the WebApplicationFactory<T>
class:
1
2
3
4
5
6
7
8
9
10
| {
...
"AWS": {
"Region": "us-east-1",
"ForcePathStyle": "true",
"AwsAccessKey": "test",
"AwsSecretKey": "test"
}
}
|
ℹ️ As the container will have a random public port, the AWS:ServiceURL
configuration has to be passed after Testcontainers starts the LocalStack container, using the Hostname
and GetMappedPublicPort(4566)
to create the full URL.
LocalStack/Testcontainer Fixture
This fixture will start and dispose of the Localstack container, sharing it with all tests.
I recommend using the same container instance for all tests, instead of one per test. It will require more attention to avoid cause between the tests, but will save time in executing them.
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();
}
}
|
Upload an image test
The test reads the configurations with IConfiguration
to create an AmazonS3Client
and create the bucket in the LocalStack environment and to assert the test condition (the object was created in the 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);
}
}
|
ℹ️ We could also create the S3 bucket using the ExecAsync
method from Testcontainer’s IContainer
to execute AWS CLI commands in the Localstack container, but I find it easier and less error prone to do it with the AWS SDK.
Upload an existing image test
This test uses the ListObjectsAsync
method of the AmazonS3Client
to assert that uploading an image with the same file name will override the image instead of creating another:
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);
}
|
Get an image test
To test the endpoint that returns the image, I use the AmazonS3Client
to create the bucket and upload an image, and then, call the endpoint and assert that it returns the image:
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"));
}
}
|
Image not found test
To test the behavior when the image doesn’t exist, I use the AmazonS3Client
to create the bucket, and then, call the endpoint that returns the image with a file name that doesn’t exist in the 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);
}
|
Full source code
GitHub repository
References and Links