Share via


Azure SDK for .NET の単体テストとモック作成

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

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

サービス クライアントとは

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

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

  • 各クライアントには、テスト用に継承できるプロテクト コンストラクターを少なくとも 1 つ用意します。
  • パブリック クライアントのメンバーは、すべて仮想でオーバーライドできます。

注意

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

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

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

Note

テスト クラスを手動で定義するのは面倒な場合があります。特に、テストごとに違う動作をするようにカスタマイズする必要がある場合です。 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 サービスから送受信したデータを保持します。 モデルには 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 応答を表す抽象クラスであり、ほとんどのサービス クライアント メソッドはこれを返します。 テスト用の Response インスタンスは、モック ライブラリまたは標準の C# の継承を使って作成できます。

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> は、サービスから複数ページで返されたモデルのコレクションを表すクラスです。 両者の唯一の違いは、一方は同期メソッドで使用し、もう一方は非同期メソッドで使用するという点です。

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

テストしやすくするために型をリファクタリングする

テストする必要があるクラスは、依存関係の挿入を前提に設計する必要があります。これにより、クラスは内部で依存関係を作成する代わりに外から受け取ることができます。 前のセクションの例では、SecretClient の実装はコンストラクター パラメーターの 1 つであったため、置き換えはシームレスなプロセスでした。 ただし、以下のクラスのように、独自の依存関係を作り出し、簡単にテストできないクラスがコードに存在する場合があります。

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

このアプローチは、依存関係の作成を 1 つにまとめ、クライアントを複数のクラスで共有する場合に便利です。

Azure Resource Manager (ARM) クライアントについて

ARM ライブラリでは、クライアントはサービス階層をミラーリングして、相互の関係を強調するように設計されました。 その目標を達成するため、クライアントに機能をさらに追加するために拡張メソッドが広く使用されています。

たとえば、Azure リソース グループには Azure 仮想マシンが存在します。 Azure.ResourceManager.Compute 名前空間では、Azure 仮想マシンが VirtualMachineResource としてモデル化されます。 Azure.ResourceManager 名前空間では、Azure リソース グループが ResourceGroupResource としてモデル化されます。 リソース グループの仮想マシンに対してクエリを実行するには、次のように記述します。

VirtualMachineCollection virtualMachineCollection = resourceGroup.GetVirtualMachines();

ResourceGroupResource での GetVirtualMachines などの仮想マシン関連の機能は拡張メソッドとして実装されているため、単にその種類のモックを作成してメソッドをオーバーライドすることはできません。 代わりに、"モック可能なリソース" のモック クラスを作成し、それらを結び付ける必要もあります。

モック可能なリソースの種類は常に、拡張メソッドの 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)
    {}
}

関連項目