使用用于 .NET 的 Azure SDK 进行单元测试和模拟

单元测试是可持续发展过程的一个重要部分,它可以提高代码质量,并防止应用中的回归或 bug。 但是,单元测试在测试的代码执行网络调用(例如对 Azure 资源进行的调用)时带来了挑战。 针对实时服务运行的测试可能会遇到问题,例如延迟降低测试执行速度、对独立测试外部代码的依赖关系,以及每次运行测试时管理服务状态和成本问题。 不要针对实时 Azure 服务进行测试,而是将服务客户端替换为模拟或内存中实现。 这可以避免上述问题,让开发人员专注于测试其应用程序逻辑,独立于网络和服务。

本文介绍如何为用于 .NET 的 Azure SDK 编写单元测试,以隔离依赖项,使测试更加可靠。 你还将了解如何将关键组件替换为内存中测试实现来创建快速可靠的单元测试,以及如何设计自己的类,以更好地支持单元测试。 本文包含使用 MoqNSubstitute 的示例,这些示例是适用于 .NET 的常用模拟库。

了解服务客户端

服务客户端类是 Azure SDK 库中开发人员的主要入口点,实现与 Azure 服务通信的大部分逻辑。 单元测试服务客户端类时,必须能够创建一个按预期运行且不进行任何网络调用的客户端实例。

每个 Azure SDK 客户端都遵循模拟准则,以允许覆盖其行为:

  • 每个客户端至少提供一个受保护的构造函数,以允许继承进行测试。
  • 所有公共客户端成员都是虚拟的,允许重写。

注释

本文中的代码示例使用 Azure Key Vault 服务的 Azure.Security.KeyVault.Secrets 库中的类型。 本文中演示的概念也适用于来自许多其他 Azure 服务(例如 Azure 存储或 Azure 服务总线)的服务客户端。

若要创建测试服务客户端,可以使用模拟库或标准 C# 功能,例如继承。 借助模拟框架,可以简化必须编写的代码以替代成员行为。 (这些框架还具有超出本文范围的其他有用功能。

若要使用不带模拟库的 C# 创建测试客户端实例,请从客户端类型继承,并在代码中调用的方法中重写其实现,使其返回一组测试对象。 大多数客户端都包含同步和异步操作的方法:只重写您的应用程序代码所调用的那个方法。

注释

手动定义测试类可能很麻烦,尤其是在需要为每个测试以不同的方式自定义行为时。 请考虑使用 Moq 或 NSubstitute 等库来简化测试。

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;
}

服务客户端输入和输出模型

模型类型保存从 Azure 服务发送和接收的数据。 有三种类型的模型:

  • 输入模型 旨在创建并作为参数传递给开发人员的服务方法。 它们具有一个或多个公共构造函数和可写属性。
  • 输出模型 仅由服务返回,并且没有公共构造函数或可写属性。
  • 往返模型 不太常见,但由服务返回、修改并用作输入。

若要创建输入模型的测试实例,请使用其中一个可用的公共构造函数并设置所需的其他属性。

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

若要创建输出模型的实例,请使用模型工厂。 Azure SDK 客户端库在其名称中提供带有后缀的静态模型工厂类 ModelFactory 。 该类包含一组静态方法,用于初始化库的输出模型类型。 例如,模型工厂的 SecretClient 值为 SecretModelFactory

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

注释

某些输入模型具有只读属性,这些属性仅在服务返回模型时填充。 在这种情况下,可以使用模型工厂方法来设置这些属性。 例如,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);

浏览响应类型

Response 类是一个抽象类,表示 HTTP 响应,并由大多数服务客户端方法返回。 可以使用模拟库或标准 C# 继承来创建测试 Response 实例。

Response 是抽象的,这意味着有许多成员需要覆盖。 请考虑使用库来简化你的工作流程。

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

某些服务还支持使用 Response<T> 类型,该类型是包含模型和返回模型的 HTTP 响应的类。 若要创建测试实例 Response<T>,请使用静态 Response.FromValue 方法:

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

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

浏览分页

Page<T> 类用作服务方法中的基础组件,用于调用返回多页结果的操作。 Page<T>很少直接通过 API 返回,但在下一部分中可用于创建AsyncPageable<T>Pageable<T>实例。 若要创建 Page<T> 实例,请使用 Page<T>.FromValues 该方法,传递项列表、继续标记和 Response.

continuationToken 参数用于从服务中检索下一页。 出于单元测试的目的,最后一页应设置为 null,而其他页面则应设置为非空。

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

AsyncPageable<T>Pageable<T> 是类,它们表示由服务以页形式返回的模型集合。 它们之间的唯一区别是,一个与同步方法一起使用,而另一个方法则与异步方法一起使用。

要创建PageableAsyncPageable的测试实例,请使用FromPages静态方法:

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 });

编写模拟单元测试

假设你的应用包含一个类,该类查找将在给定时间内过期的密钥名称。

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

你想要测试 AboutToExpireSecretFinder 的以下行为,以确保它们继续按预期运行:

  • 不返回未设置到期日期的机密。
  • 返回的是比阈值截止日期更接近当前日期的机密。

当单元测试时,你只需要单元测试来验证应用程序逻辑,而不是 Azure 服务或库是否正常工作。 以下示例使用常用 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);
    }
}

重构类型以便实现可测试性

需要测试的类应设计为 依赖项注入,这样类就可以接收其依赖项,而不是在内部创建依赖项。 这是一个无缝的过程,用于更换上一节中示例的SecretClient实现,因为这是构造函数参数之一。 但是,代码中可能存在创建其自己的依赖项且不容易测试的类,例如以下类:

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

若要启用依赖项注入测试,最简单的重构是将客户端公开为参数,并在未提供任何值时运行默认创建代码。 此方法允许你使类可测试,同时仍保持使用类型的灵活性,而无需执行太多仪式。

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

另一个选项是将依赖项创建完全移动到调用代码中:

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

如果要合并依赖项创建并在多个使用类之间共享客户端,此方法非常有用。

了解 Azure 资源管理器 (ARM) 客户端

在 ARM 库中,客户端旨在强调彼此之间的关系,从而镜像服务层次结构。 为了实现这一目标,扩展方法通常用于向客户端添加其他功能。

例如,Azure 资源组中存在 Azure 虚拟机。 命名空间 Azure.ResourceManager.Compute 将 Azure 虚拟机建模为 VirtualMachineResource。 命名空间 Azure.ResourceManager 将 Azure 资源组建模为 ResourceGroupResource。 若要查询资源组的虚拟机,需要编写:

VirtualMachineCollection virtualMachineCollection = resourceGroup.GetVirtualMachines();

由于虚拟机相关功能(例如GetVirtualMachinesResourceGroupResource上)作为扩展方法实现,因此无法仅模拟类型并重写方法。 相反,您还必须为“可模拟资源”创建一个模拟类并将它们关联在一起。

可模拟资源类型始终位于 Mocking 扩展方法的子命名空间中。 在前面的示例中,可模拟资源类型位于命名空间中 Azure.ResourceManager.Compute.Mocking 。 可模拟资源类型始终以资源类型命名,其“Mockable”和库名称为前缀。 在前面的示例中,可模拟资源类型命名 MockableComputeResourceGroupResource,其中 ResourceGroupResource 扩展方法的资源类型是 Compute 库名称。

在运行单元测试之前,还有一个要求是模拟扩展方法所属资源类型中的GetCachedClient方法。 完成此步骤将连接扩展方法和可模拟资源类型上的方法。

以下是其工作原理:

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)
    {
        // Initialize with an empty mock to satisfy non-null contract
        _mockableComputeResourceGroupResource =
            new MockMockableComputeResourceGroupResource(new MockVirtualMachineCollection(client, id));
    }

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

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

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

另请参阅