Compartir a través de


Pruebas unitarias y simulación con el SDK de Azure para .NET

Las pruebas unitarias son una parte importante de un proceso de desarrollo sostenible que puede mejorar la calidad del código y evitar regresiones o errores en las aplicaciones. Sin embargo, las pruebas unitarias presentan desafíos cuando el código que está probando realiza llamadas de red, como las realizadas a recursos de Azure. Las pruebas que se ejecutan en servicios activos pueden experimentar problemas, como la latencia que ralentiza la ejecución de pruebas, las dependencias del código fuera de la prueba aislada y los problemas con la administración del estado del servicio y los costos cada vez que se ejecuta la prueba. En lugar de realizar pruebas en los servicios activos de Azure, reemplace los clientes de servicio por implementaciones simuladas o en memoria. Esto evita los problemas anteriores y permite a los desarrolladores centrarse en probar su lógica de aplicación, independientemente de la red y el servicio.

En este artículo, aprenderá a escribir pruebas unitarias para el SDK de Azure para .NET que aíslan las dependencias para que las pruebas sean más confiables. También aprenderá a reemplazar los componentes clave por implementaciones de pruebas en memoria para crear pruebas unitarias rápidas y confiables y ver cómo diseñar sus propias clases para admitir mejor las pruebas unitarias. En este artículo se incluyen ejemplos que usan Moq y NSubstitute, que son bibliotecas simuladas populares para .NET.

Descripción de los clientes de servicio

Una clase cliente de servicio es el punto de entrada principal para los desarrolladores de una biblioteca de Azure SDK e implementa la mayoría de la lógica para comunicarse con el servicio de Azure. Cuando se prueban las clases de cliente del servicio de pruebas unitarias, es importante poder crear una instancia del cliente que se comporte según lo esperado sin realizar ninguna llamada de red.

Cada uno de los clientes del SDK de Azure sigue las directrices de simulación que permiten invalidar su comportamiento:

  • Cada cliente ofrece al menos un constructor protegido para permitir la herencia durante las pruebas.
  • Todos los miembros del cliente público son virtuales para permitir la invalidación.

Nota:

Los ejemplos de código de este artículo usan tipos de la biblioteca Azure.Security.KeyVault.Secrets para el servicio Azure Key Vault. Los conceptos que se muestran en este artículo también se aplican a los clientes de servicio de muchos otros servicios de Azure, como Azure Storage o Azure Service Bus.

Para crear un cliente de servicio de prueba, puede usar una biblioteca simulada o características estándar de C#, como la herencia. Los marcos de trabajo ficticios permiten simplificar el código que debe escribir para invalidar el comportamiento de los miembros. (Estos marcos también tienen otras características útiles que están fuera del ámbito de este artículo).

Para crear una instancia de cliente de prueba mediante C# sin una biblioteca ficticia, herede del tipo de cliente e invalide los métodos que está llamando en el código con una implementación que devuelve un conjunto de objetos de prueba. La mayoría de los clientes contienen métodos tanto sincrónicos como asincrónicos para las operaciones; sobrescriba solo aquel que su código de aplicación está llamando.

Nota:

Puede resultar complicado definir manualmente clases de prueba, especialmente si necesita personalizar el comportamiento de forma diferente para cada prueba. Considere la posibilidad de usar una biblioteca como Moq o NSubstitute para simplificar las pruebas.

using Azure.Security.KeyVault.Secrets;
using Azure;
using NSubstitute.Routing.Handlers;

namespace UnitTestingSampleApp.NonLibrary;

public sealed class MockSecretClient : SecretClient
{
    AsyncPageable<SecretProperties> _pageable;

    // Allow a pageable to be passed in for mocking different responses
    public MockSecretClient(AsyncPageable<SecretProperties> pageable)
    {
        _pageable = pageable;
    }

    public override Response<KeyVaultSecret> GetSecret(
        string name,
        string version = null,
        CancellationToken cancellationToken = default)
        => throw new NotImplementedException();

    public override Task<Response<KeyVaultSecret>> GetSecretAsync(
        string name,
        string version = null,
        CancellationToken cancellationToken = default)
        => throw new NotImplementedException();

    // Return the pageable that was passed in
    public override AsyncPageable<SecretProperties> GetPropertiesOfSecretsAsync
        (CancellationToken cancellationToken = default)
        => _pageable;
}

Modelos de entrada y salida de cliente de servicio

Los tipos de modelo contienen los datos que se envían y reciben de los servicios de Azure. Hay tres tipos de modelos:

  • Los modelos de entrada están destinados a ser creados y pasados como parámetros a los métodos de servicio por los desarrolladores. Tienen uno o varios constructores públicos y propiedades modificables.
  • Los modelos de salida solo los devuelve el servicio y no tienen constructores públicos ni propiedades que se puedan escribir.
  • Los modelos de ida y vuelta son menos comunes, pero los devuelve el servicio, se modifican y se usan como entrada.

Para crear una instancia de prueba de un modelo de entrada, use uno de los constructores públicos disponibles y establezca las propiedades adicionales que necesita.

var secretProperties = new SecretProperties("secret")
{
    NotBefore = DateTimeOffset.Now
};

Para crear instancias de modelos de salida, se usa un generador de modelos. Las bibliotecas cliente del SDK de Azure proporcionan una clase de generador de modelos estáticos con un ModelFactory sufijo en su nombre. La clase contiene un conjunto de métodos estáticos para inicializar los tipos de modelo de salida de la biblioteca. Por ejemplo, el generador de modelos para SecretClient es SecretModelFactory:

KeyVaultSecret keyVaultSecret = SecretModelFactory.KeyVaultSecret(
    new SecretProperties("secret"), "secretValue");

Nota:

Algunos modelos de entrada tienen propiedades de solo lectura que solo se rellenan cuando el servicio devuelve el modelo. En este caso, habrá disponible un método de generador de modelos que permita establecer estas propiedades. Por ejemplo: SecretProperties.

// CreatedOn is a read-only property and can only be
// set via the model factory's SecretProperties method.
secretPropertiesWithCreatedOn = SecretModelFactory.SecretProperties(
    name: "secret", createdOn: DateTimeOffset.Now);

Exploración de los tipos de respuesta

La Response clase es una clase abstracta que representa una respuesta HTTP y la devuelve la mayoría de los métodos cliente de servicio. Puede crear instancias de prueba Response mediante una biblioteca ficticia o una herencia estándar de C#.

La clase Response es abstracta, lo que significa que hay muchos miembros que se deben sobrescribir. Considere la posibilidad de usar una biblioteca para simplificar el enfoque.

using Azure.Core;
using Azure;
using System.Diagnostics.CodeAnalysis;

namespace UnitTestingSampleApp.NonLibrary;

public sealed class MockResponse : Response
{
    public override int Status => throw new NotImplementedException();

    public override string ReasonPhrase => throw new NotImplementedException();

    public override Stream? ContentStream
    {
        get => throw new NotImplementedException();
        set => throw new NotImplementedException();
    }
    public override string ClientRequestId
    {
        get => throw new NotImplementedException();
        set => throw new NotImplementedException();
    }

    public override void Dispose() =>
        throw new NotImplementedException();
    protected override bool ContainsHeader(string name) =>
        throw new NotImplementedException();
    protected override IEnumerable<HttpHeader> EnumerateHeaders() =>
        throw new NotImplementedException();
    protected override bool TryGetHeader(
        string name,
        [NotNullWhen(true)] out string? value) =>
        throw new NotImplementedException();
    protected override bool TryGetHeaderValues(
        string name,
        [NotNullWhen(true)] out IEnumerable<string>? values) =>
        throw new NotImplementedException();
}

Algunos servicios también admiten el uso del Response<T> tipo , que es una clase que contiene un modelo y la respuesta HTTP que la devolvió. Para crear una instancia de prueba de Response<T>, use el método estático Response.FromValue :

KeyVaultSecret keyVaultSecret = SecretModelFactory.KeyVaultSecret(
    new SecretProperties("secret"), "secretValue");

Response<KeyVaultSecret> response = Response.FromValue(keyVaultSecret, new MockResponse());

Explora la paginación

La Page<T> clase se usa como un bloque de creación en métodos de servicio que invocan operaciones que devuelven resultados en varias páginas. Raramente se devuelve desde las API directamente, pero resulta útil para crear las instancias de Page<T> y AsyncPageable<T> en la siguiente sección. Para crear una instancia Page<T>, use el método Page<T>.FromValues pasando una lista de elementos, un token de continuación y Response.

El continuationToken parámetro se usa para recuperar la página siguiente del servicio. Para pruebas unitarias, debe establecerse null para la última página y no debe ser vacío para otras páginas.

Page<SecretProperties> responsePage = Page<SecretProperties>.FromValues(
    new[] {
        new SecretProperties("secret1"),
        new SecretProperties("secret2")
    },
    continuationToken: null,
    new MockResponse());

AsyncPageable<T> y Pageable<T> son clases que representan colecciones de modelos devueltos por el servicio en páginas. La única diferencia entre ellos es que se usa uno con métodos sincrónicos mientras que el otro se usa con métodos asincrónicos.

Para crear una instancia de prueba de Pageable o AsyncPageable, use el FromPages método estático:

Page<SecretProperties> page1 = Page<SecretProperties>.FromValues(
    new[]
    {
        new SecretProperties("secret1"),
        new SecretProperties("secret2")
    },
    "continuationToken",
    new MockResponse());

Page<SecretProperties> page2 = Page<SecretProperties>.FromValues(
    new[]
    {
        new SecretProperties("secret3"),
        new SecretProperties("secret4")
    },
    "continuationToken2",
    new MockResponse());

Page<SecretProperties> lastPage = Page<SecretProperties>.FromValues(
    new[]
    {
        new SecretProperties("secret5"),
        new SecretProperties("secret6")
    },
    continuationToken: null,
    new MockResponse());

Pageable<SecretProperties> pageable = Pageable<SecretProperties>
    .FromPages(new[] { page1, page2, lastPage });

AsyncPageable<SecretProperties> asyncPageable = AsyncPageable<SecretProperties>
    .FromPages(new[] { page1, page2, lastPage });

Escribir una prueba unitaria simulada

Supongamos que la aplicación contiene una clase que encuentra los nombres de las claves que expirarán dentro de un período de tiempo determinado.

using Azure.Security.KeyVault.Secrets;

public class AboutToExpireSecretFinder
{
    private readonly TimeSpan _threshold;
    private readonly SecretClient _client;

    public AboutToExpireSecretFinder(TimeSpan threshold, SecretClient client)
    {
        _threshold = threshold;
        _client = client;
    }

    public async Task<string[]> GetAboutToExpireSecretsAsync()
    {
        List<string> secretsAboutToExpire = new();

        await foreach (var secret in _client.GetPropertiesOfSecretsAsync())
        {
            if (secret.ExpiresOn.HasValue &&
                secret.ExpiresOn.Value - DateTimeOffset.Now <= _threshold)
            {
                secretsAboutToExpire.Add(secret.Name);
            }
        }

        return secretsAboutToExpire.ToArray();
    }
}

Quiere probar los siguientes comportamientos del AboutToExpireSecretFinder para asegurarse de que siguen funcionando según lo previsto.

  • No se devuelven secretos sin una fecha de expiración establecida.
  • Se devuelven secretos con una fecha de expiración más cercana a la fecha actual que el umbral.

Al realizar pruebas unitarias, solo se quiere que estas verifiquen la lógica de la aplicación y no si el servicio o la biblioteca de Azure funcionan correctamente. En el ejemplo siguiente se prueban los comportamientos clave mediante la conocida biblioteca xUnit :

using Azure;
using Azure.Security.KeyVault.Secrets;

namespace UnitTestingSampleApp.NonLibrary;

public class AboutToExpireSecretFinderTests
{
    [Fact]
    public async Task DoesNotReturnNonExpiringSecrets()
    {
        // Arrange
        // Create a page of enumeration results
        Page<SecretProperties> page = Page<SecretProperties>.FromValues(new[]
        {
            new SecretProperties("secret1") { ExpiresOn = null },
            new SecretProperties("secret2") { ExpiresOn = null }
        }, null, new MockResponse());

        // Create a pageable that consists of a single page
        AsyncPageable<SecretProperties> pageable =
            AsyncPageable<SecretProperties>.FromPages(new[] { page });

        var clientMock = new MockSecretClient(pageable);

        // Create an instance of a class to test passing in the mock client
        var finder = new AboutToExpireSecretFinder(TimeSpan.FromDays(2), clientMock);

        // Act
        string[] soonToExpire = await finder.GetAboutToExpireSecretsAsync();

        // Assert
        Assert.Empty(soonToExpire);
    }

    [Fact]
    public async Task ReturnsSecretsThatExpireSoon()
    {
        // Arrange

        // Create a page of enumeration results
        DateTimeOffset now = DateTimeOffset.Now;
        Page<SecretProperties> page = Page<SecretProperties>.FromValues(new[]
        {
            new SecretProperties("secret1") { ExpiresOn = now.AddDays(1) },
            new SecretProperties("secret2") { ExpiresOn = now.AddDays(2) },
            new SecretProperties("secret3") { ExpiresOn = now.AddDays(3) }
        },
        null, new MockResponse());

        // Create a pageable that consists of a single page
        AsyncPageable<SecretProperties> pageable =
            AsyncPageable<SecretProperties>.FromPages(new[] { page });

        // Create a client mock object
        var clientMock = new MockSecretClient(pageable);

        // Create an instance of a class to test passing in the mock client
        var finder = new AboutToExpireSecretFinder(TimeSpan.FromDays(2), clientMock);

        // Act
        string[] soonToExpire = await finder.GetAboutToExpireSecretsAsync();

        // Assert
        Assert.Equal(new[] { "secret1", "secret2" }, soonToExpire);
    }
}

Refactoriza tus tipos para que sean comprobables

Las clases que deben probarse deben diseñarse para la inserción de dependencias, lo que permite que la clase reciba sus dependencias en lugar de crearlas internamente. Fue un proceso sin problemas para reemplazar la SecretClient implementación en el ejemplo de la sección anterior porque era uno de los parámetros del constructor. Sin embargo, puede haber clases en el código que crean sus propias dependencias y no se pueden probar fácilmente, como la siguiente clase:

public class AboutToExpireSecretFinder
{
    public AboutToExpireSecretFinder(TimeSpan threshold)
    {
        _threshold = threshold;
        _client = new SecretClient(
            new Uri(Environment.GetEnvironmentVariable("KeyVaultUri")),
            new DefaultAzureCredential());
    }
}

La refactorización más sencilla que puede hacer para habilitar las pruebas con inserción de dependencias sería exponer el cliente como parámetro y ejecutar código de creación predeterminado cuando no se proporciona ningún valor. Este enfoque le permite hacer que la clase sea testeable mientras conserva la flexibilidad de utilizar el tipo sin complicaciones.

public class AboutToExpireSecretFinder
{
    public AboutToExpireSecretFinder(TimeSpan threshold, SecretClient client = null)
    {
        _threshold = threshold;
        _client = client ?? new SecretClient(
            new Uri(Environment.GetEnvironmentVariable("KeyVaultUri")),
            new DefaultAzureCredential());
    }
}

Otra opción es mover completamente la creación de dependencias al código de llamada:

public class AboutToExpireSecretFinder
{
    public AboutToExpireSecretFinder(TimeSpan threshold, SecretClient client)
    {
        _threshold = threshold;
        _client = client;
    }
}

var secretClient = new SecretClient(
    new Uri(Environment.GetEnvironmentVariable("KeyVaultUri")),
    new DefaultAzureCredential());
var finder = new AboutToExpireSecretFinder(TimeSpan.FromDays(2), secretClient);

Este enfoque es útil cuando desea consolidar la creación de dependencias y compartir el cliente entre varias clases de consumo.

Descripción de los clientes de Azure Resource Manager (ARM)

En las bibliotecas de ARM, los clientes se diseñaron para resaltar su relación entre sí, lo que refleja la jerarquía de servicios. Para lograr ese objetivo, los métodos de extensión se usan ampliamente para agregar características adicionales a los clientes.

Por ejemplo, existe una máquina virtual de Azure en un grupo de recursos de Azure. El Azure.ResourceManager.Compute espacio de nombres modela la máquina virtual de Azure como VirtualMachineResource. El Azure.ResourceManager espacio de nombres modela el grupo de recursos de Azure como ResourceGroupResource. Para consultar las máquinas virtuales de un grupo de recursos, escribiría lo siguiente:

VirtualMachineCollection virtualMachineCollection = resourceGroup.GetVirtualMachines();

Dado que la funcionalidad relacionada con la máquina virtual, como GetVirtualMachines en ResourceGroupResource, se implementa como métodos de extensión, es imposible simplemente crear un simulacro del tipo e invalidar el método. En su lugar, también tendrá que crear una clase ficticia para el "recurso ficticio" y conectarlas juntas.

El tipo de recurso simulable siempre está en el Mocking subespacio de nombres del método de extensión. En el ejemplo anterior, el tipo de recurso simulable está en el espacio de nombres Azure.ResourceManager.Compute.Mocking. El tipo de recurso ficticio siempre lleva el nombre del tipo de recurso junto con "Mockable" y el nombre de la biblioteca como prefijos. En el ejemplo anterior, el tipo de recurso ficticio se denomina MockableComputeResourceGroupResource, donde ResourceGroupResource es el tipo de recurso del método de extensión y Compute es el nombre de la biblioteca.

Un requisito más antes de ejecutar la prueba unitaria es simular el GetCachedClient método en el tipo de recurso del método de extensión. Al completar este paso, se conecta el método de extensión con el método del tipo de recurso ficticio.

Así es como funciona:

using Azure.Core;

namespace UnitTestingSampleApp.ResourceManager.NonLibrary;

public sealed class MockMockableComputeResourceGroupResource : MockableComputeResourceGroupResource
{
    private VirtualMachineCollection _virtualMachineCollection;
    public MockMockableComputeResourceGroupResource(VirtualMachineCollection virtualMachineCollection)
    {
        _virtualMachineCollection = virtualMachineCollection;
    }

    public override VirtualMachineCollection GetVirtualMachines()
    {
        return _virtualMachineCollection;
    }
}

public sealed class MockResourceGroupResource : ResourceGroupResource
{
    private readonly MockableComputeResourceGroupResource _mockableComputeResourceGroupResource;
    public MockResourceGroupResource(VirtualMachineCollection virtualMachineCollection)
    {
        _mockableComputeResourceGroupResource =
            new MockMockableComputeResourceGroupResource(virtualMachineCollection);
    }

    internal MockResourceGroupResource(ArmClient client, ResourceIdentifier id) : base(client, id)
    {}

    public override T GetCachedClient<T>(Func<ArmClient, T> factory) where T : class
    {
        if (typeof(T) == typeof(MockableComputeResourceGroupResource))
            return _mockableComputeResourceGroupResource as T;
        return base.GetCachedClient(factory);
    }
}

public sealed class MockVirtualMachineCollection : VirtualMachineCollection
{
    public MockVirtualMachineCollection()
    {}

    internal MockVirtualMachineCollection(ArmClient client, ResourceIdentifier id) : base(client, id)
    {}
}

Consulte también