Integration tests with AWS S3 buckets using Localstack and Testcontainers
Tags: dotnet, csharp, testing, aws
Published: 2023-11-03 18:17:05

As I explained previously in [this article](https://blog.genezini.com/p/integration-tests-without-api-dependencies-with-asp.net-core-and-wiremock.net/), 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](https://www.docker.com/products/docker-desktop/), [Podman](https://podman-desktop.io/), [Rancher](https://www.rancher.com/products/rancher-desktop), 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();
```

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:

```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();

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](/p/dotnet-and-aws-s3-with-localstack-how-to-develop-with-local-s3-buckets/), 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:

```csharp
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](/p/integration-tests-without-api-dependencies-with-asp.net-core-and-wiremock.net/).

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

```csharp
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:

```json
{
  ...

  "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.

```csharp
[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):

```csharp
[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:

```csharp
[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:

```csharp
[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:

```csharp
[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](https://github.com/dgenezini/UploadImageToS3LocalStack)

## References and Links

- [LocalStack Website](https://localstack.cloud/)
- [LocalStack Feature Coverage](https://docs.localstack.cloud/user-guide/aws/feature-coverage/)
- [Testcontainers](https://dotnet.testcontainers.org/)

## Related

{% embed https://blog.genezini.com/p/how-to-run-disposable-databases-for-your-tests.-improve-your-integration-tests-accuracy-with-testcontainers/ %}

## Liked this post?

I post extra content in my personal blog. 

{% embed https://blog.genezini.com/ %}

## Follow me

- [On Feedly](https://feedly.com/i/subscription/feed/https://blog.genezini.com/index.xml)
- [RSS](https://blog.genezini.com/index.xml)
- [Linkedin](https://www.linkedin.com/in/danielgenezini/)
- [Twitter](https://twitter.com/GeneziniDaniel)