Azurite con Testcontainers

Si trabajas con cuentas de almacenamiento de Azure, usar un emulador local como Azurite te facilitará el desarrollo. Si además usamos Testcontainers, podrás usar Azurite también en tus tests de integración.

No hace mucho, la opción preferida como emulador local de cuentas de almacenamiento de Azure era el storage emulator. Sin embargo ha sido deprecado en favor de Azurite.

Lo que ha sido deprecado es el emulador, no la herramienta cliente, a.k.a. Microsoft Azure Storage Explorer

Lo que me gusta especialmente de Azurite es que tiene una imagen Docker.

La documentación para tests automatizados te dice que primero lo arranques manualmente y después ejecutes tus tests o incluso que lo instales en el agente donde corre tu pipeline. No obstante, con la ayuda de Testcontainers podemos ahorrarnos este paso manual y que todo fluya mejor.

La idea es sencilla, usar Testcontainers para crear un contenedor al vuelo de Azurite.

Primero hay que instalar Testcontainers:

dotnet add package Testcontainers

La fixture quedaría así:

using System.Text;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;

namespace TestProject1;

public class AzuriteFixture : IAsyncLifetime
{
    private IContainer? _azurite;
    public string ConnectionString = null!;
    private ushort _blobEndpointPort;
    private string _accountName = null!;
    private string _destination = null!;

    public async Task InitializeAsync()
    {
        _accountName = $"azurite_{Guid.NewGuid()}";
        _destination =
            Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _accountName));
        DeleteDirectory(_destination);
        Directory.CreateDirectory(_destination);

        var accountKey = Convert.ToBase64String(Encoding.UTF8.GetBytes(_accountName));

        const int blobEndpointPort = 10000;
        _azurite = new ContainerBuilder()
            .WithName(_accountName)
            .WithImage("mcr.microsoft.com/azure-storage/azurite")
            .WithPortBinding(blobEndpointPort, assignRandomHostPort: true)
            .WithPortBinding(10001, assignRandomHostPort: true)
            .WithPortBinding(10002, assignRandomHostPort: true)
            .WithEnvironment(new Dictionary<string, string>
            {
                { "AZURITE_ACCOUNTS", $"{_accountName}:{accountKey}" }
            })
            .WithBindMount(_destination, "/data")
            .WithWaitStrategy(Wait.ForUnixContainer()
                .UntilPortIsAvailable(blobEndpointPort))
            .WithImagePullPolicy(PullPolicy.Always)
            .Build();

        await _azurite.StartAsync();

        _blobEndpointPort = _azurite.GetMappedPublicPort(blobEndpointPort);
        var queueEndpointPort = _azurite.GetMappedPublicPort(10001);
        var tableEndpointPort = _azurite.GetMappedPublicPort(10002);
        ConnectionString =
            @$"DefaultEndpointsProtocol=http;AccountName={_accountName};AccountKey={accountKey};BlobEndpoint=http://127.0.0.1:{_blobEndpointPort}/{_accountName};QueueEndpoint=http://127.0.0.1:{queueEndpointPort}/{_accountName};TableEndpoint=http://127.0.0.1:{tableEndpointPort}/{_accountName};";
    }

    public string BlobStorageUrl(string blobContainerName, string blobName) =>
        $"http://127.0.0.1:{_blobEndpointPort}/{_accountName}/{blobContainerName}/{blobName}";

    public async Task DisposeAsync()
    {
        if (_azurite is not null)
        {
            await _azurite.StopAsync();
        }

        DeleteDirectory(_destination);
    }

    private static void DeleteDirectory(string path)
    {
        try
        {
            if (Path.Exists(path))
            {
                Directory.Delete(path, recursive: true);
            }
        }
        catch
        {
        }
    }
}

Si quieres conectar desde Microsoft Azure Storage Explorer al contenedor, usa directamente el valor de la propiedad ConnectionString (bien depurando o usando ITestOutputHelper). Para Microsoft Azure Storage Explorer el contenedor ya no es un “emulador” porque no usa los puertos por defecto. Sin embargo, usamos puertos random porque no queremos tener que hacer una colección de xUnit.net y perder así la posibilidad de ejecutar tests en paralelo que usen esta fixture. Lógicamente, al no tener una CollectionFixture, es tu responsabilidad no levantar chorrocientos contenedores de Azurite, ¡allá tú con tus cores!

Ahora, imaginemos tenemos el siguiente código en nuestro proyecto de producción (básicamente una clase que permite escribir y/o leer un blob).

dotnet add package Azure.Storage.Blobs
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;

public class StorageManager
{
    public virtual async Task WriteBlobAsync(string connectionString,
        string blobContainerName,
        string blobName,
        string content,
        CancellationToken cancellationToken = default)
    {
        var blobServiceClient = new BlobServiceClient(connectionString);
        var blobContainerClient = blobServiceClient.GetBlobContainerClient(blobContainerName);
        if (!await blobContainerClient.ExistsAsync(cancellationToken))
        {
            await blobServiceClient.CreateBlobContainerAsync(blobContainerName, cancellationToken: cancellationToken);
        }

        var blobClient = blobContainerClient.GetBlobClient(blobName);
        await blobClient.UploadAsync(BinaryData.FromString(content), overwrite: true,
            cancellationToken: cancellationToken);
    }

    public virtual async Task<string> GetBlobContentAsync(string connectionString, string blobContainerName,
        string blobName)
    {
        var blobContainerClient = new BlobContainerClient(connectionString, blobContainerName);
        var blobClient = blobContainerClient.GetBlobClient(blobName);
        return ((BlobDownloadResult)await blobClient.DownloadContentAsync()).Content.ToString();
    }
}

Pues podríamos hacer un test de integración usando nuestra fixture:

namespace TestProject1;

public class StorageManagerShould : IClassFixture<AzuriteFixture>
{
    private readonly AzuriteFixture _azuriteFixture;

    public StorageManagerShould(AzuriteFixture azuriteFixture)
    {
        _azuriteFixture = azuriteFixture;
    }

    [Fact]
    public async Task write_a_blob_successfully()
    {
        var storageManager = new StorageManager();
        var connectionString = _azuriteFixture.ConnectionString;
        const string blobContainerName = "my-container";
        const string blobName = "blob.txt";
        const string content = "Hello World!";

        await storageManager.WriteBlobAsync(connectionString, blobContainerName, blobName, content);

        var actual = await storageManager.GetBlobContentAsync(connectionString, blobContainerName, blobName);
        Assert.Equal(content, actual);
    }
}

Otro punto a tener en cuento (de tanto en tanto) será la versión de API que soporta Azurite. Es decir, la API de storage Azure tiene distintas versiones y Azurite las soportará o no (actualmente soporta todas, la actual es 2021-12-02). Sin embargo, puede ser que una versión muy reciente de API todavía no esté soportada por la imagen de Azurite y en ese caso puedes, bien usar --skipApiVersionCheck o mejor (al menos a mí me ha funcionado) especificar en el cliente la versión contra la que quieres trabajar. Los cambios serían algo así:

using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;

public class StorageManager
{
    // Last Azurite-supported API version
    private readonly BlobClientOptions _blobClientOptions = new(BlobClientOptions.ServiceVersion.V2021_12_02);

    public virtual async Task WriteBlobAsync(string connectionString,
        string blobContainerName,
        string blobName,
        string content,
        CancellationToken cancellationToken = default)
    {
        var blobServiceClient = new BlobServiceClient(connectionString, _blobClientOptions);
        // The remaining code goes here
    }

    public virtual async Task<string> GetBlobContentAsync(string connectionString, string blobContainerName,
        string blobName)
    {
        var blobContainerClient = new BlobContainerClient(connectionString, blobContainerName, _blobClientOptions);
        // The remaining code goes here
    }
}

Un saludo!


Ver también