次の方法で共有


Azure SDK for .NET を使用した単体テストとモック

単体テストは、コードの品質を向上させ、アプリの回帰やバグを防ぐことができる、持続可能な開発プロセスの重要な部分です。 ただし、単体テストでは、テストするコードがネットワーク呼び出し (Azure リソースに対して行われたものなど) を実行する場合、課題が発生します。 ライブ サービスに対して実行されるテストでは、テストの実行速度が低下する待機時間、分離されたテスト以外のコードへの依存関係、テストが実行されるたびにサービスの状態とコストの管理に関する問題などの問題が発生する可能性があります。 ライブ Azure サービスに対してテストする代わりに、サービス クライアントをモック実装またはメモリ内実装に置き換えます。 これにより、上記の問題が回避され、開発者はネットワークやサービスから独立して、アプリケーション ロジックのテストに集中できます。

この記事では、テストの信頼性を高めるために依存関係を分離する Azure SDK for .NET の単体テストを記述する方法について説明します。 また、主要コンポーネントをインメモリ テストの実装に置き換えて、高速で信頼性の高い単体テストを作成する方法と、単体テストをより適切にサポートするように独自のクラスを設計する方法についても説明します。 この記事には、.NET 用の一般的なモック ライブラリである MoqNAries を使用する例が含まれています。

サービス クライアントについて

サービス クライアント クラスは、Azure SDK ライブラリの開発者にとっての主要なエントリ ポイントであり、Azure サービスと通信するためのほとんどのロジックを実装します。 サービス クライアント クラスを単体テストする場合は、ネットワーク呼び出しを行わずに期待どおりに動作するクライアントのインスタンスを作成できることが重要です。

各 Azure SDK クライアントは、動作をオーバーライドできるようにする モックガイドライン に従います。

  • 各クライアントには、テスト用の継承を許可する保護されたコンストラクターが少なくとも 1 つ用意されています。
  • すべてのパブリック クライアント メンバーは、オーバーライドを許可する仮想です。

この記事のコード例では、Azure Key Vault サービス用 の Azure.Security.KeyVault.Secrets ライブラリの型を使用します。 この記事で示す概念は、Azure Storage や Azure Service Bus など、他の多くの Azure サービスのサービス クライアントにも適用されます。

テスト サービス クライアントを作成するには、モック ライブラリまたは継承などの標準的な C# 機能を使用できます。 モック フレームワークを使用すると、メンバーの動作をオーバーライドするために記述する必要があるコードを簡略化できます。 (これらのフレームワークには、この記事の範囲外の他の便利な機能もあります)。

モック ライブラリなしで C# を使用してテスト クライアント インスタンスを作成するには、クライアントの種類を継承し、コードで呼び出しているメソッドを、一連のテスト オブジェクトを返す実装でオーバーライドします。 ほとんどのクライアントには、操作用の同期メソッドと非同期メソッドの両方が含まれています。は、アプリケーション コードが呼び出している 1 つだけをオーバーライドします。

テスト クラスを手動で定義するのは面倒な場合があります。特に、テストごとに動作を異なる方法でカスタマイズする必要がある場合です。 テストを効率化するには、Moq や N Affinit などのライブラリを使用することを検討してください。

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 サービスから送受信されるデータを保持します。 モデルには次の 3 種類があります。

  • 入力モデル は、開発者が作成し、パラメーターとしてサービス メソッドに渡すことを目的としています。 1 つ以上のパブリック コンストラクターと書き込み可能なプロパティがあります。
  • 出力モデル はサービスによってのみ返され、パブリック コンストラクターや書き込み可能なプロパティはありません。
  • ラウンド トリップ モデル はあまり一般的ではありませんが、サービスによって返され、変更され、入力として使用されます。

入力モデルのテスト インスタンスを作成するには、使用可能なパブリック コンストラクターのいずれかを使用し、必要な追加のプロパティを設定します。

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

一部のサービスでは、モデルとそれを返した HTTP 応答を含むクラスである Response<T> 型の使用もサポートされています。 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> は、サービスによって返されるモデルのコレクションをページで表すクラスです。 唯一の違いは、1 つは同期メソッドで使用され、もう 1 つは非同期メソッドで使用されるということです。

PageableまたはAsyncPageableのテスト インスタンスを作成するには、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);
    }
}

テスト容易性のために型をリファクタリングする

テストする必要があるクラスは 、依存関係の挿入用に設計する必要があります。これにより、クラスは内部で作成するのではなく、依存関係を受け取ることができます。 これは、コンストラクター パラメーターの 1 つであるため、前のセクションの例の 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());
    }
}

もう 1 つのオプションは、依存関係の作成を呼び出し元のコードに完全に移動することです。

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 Resource Manager (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 はライブラリ名です。

単体テストを実行する前にもう 1 つの要件は、拡張メソッドのリソースの種類で 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)
    {}

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

こちらも参照ください