DEV Community

Cover image for Integration tests with AWS S3 buckets using Localstack and Testcontainers
Daniel Genezini
Daniel Genezini

Posted on • Originally published at blog.genezini.com on

Integration tests with AWS S3 buckets using Localstack and Testcontainers

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:

```bash {linenos=false}
dotnet add package Testcontainers




To run a LocalStack container, we first need to use the `ContainerBuilder` class to build an `IContainer`:



```csharp
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();
Enter fullscreen mode Exit fullscreen mode

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:

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)}";
Enter fullscreen mode Exit fullscreen mode

❗ 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:

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();
    }
});
Enter fullscreen mode Exit fullscreen mode

⚠️ 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:

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);
    }
});
Enter fullscreen mode Exit fullscreen mode

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:

{
  ...

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

ℹ️ 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.

[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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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):

[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);
    }
}
Enter fullscreen mode Exit fullscreen mode

ℹ️ 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:

[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);
}
Enter fullscreen mode Exit fullscreen mode

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:

[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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

[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);
}
Enter fullscreen mode Exit fullscreen mode

Full source code

GitHub repository

References and Links

Related

Liked this post?

I post extra content in my personal blog.

Daniel Genezini | It works on my machine

My blog where I share my experiences about .NET, Blazor, AWS, and other tech stuff. Very important note: Everything that I post works on my machine!

blog.genezini.com

Follow me

Top comments (0)