Pengujian dan tiruan unit dengan Azure SDK untuk .NET

Pengujian unit adalah bagian penting dari proses pengembangan berkelanjutan yang dapat meningkatkan kualitas kode dan mencegah regresi atau bug di aplikasi Anda. Namun, pengujian unit menghadirkan tantangan ketika kode yang Anda uji melakukan panggilan jaringan, seperti yang dilakukan untuk sumber daya Azure. Pengujian yang berjalan terhadap layanan langsung dapat mengalami masalah, seperti latensi yang memperlambat eksekusi pengujian, dependensi pada kode di luar pengujian terisolasi, dan masalah dalam mengelola status dan biaya layanan setiap kali pengujian dijalankan. ** Daripada menguji layanan Azure secara langsung, ganti klien dengan implementasi mock atau dalam memori. Ini menghindari masalah di atas dan memungkinkan pengembang fokus pada pengujian logika aplikasi mereka, independen dari jaringan dan layanan.

Dalam artikel ini, Anda mempelajari cara menulis pengujian unit untuk Azure SDK untuk .NET yang mengisolasi dependensi Anda agar pengujian Anda lebih dapat diandalkan. Anda juga mempelajari cara mengganti komponen utama dengan implementasi pengujian dalam memori untuk membuat pengujian unit yang cepat dan andal, dan melihat cara merancang kelas Anda sendiri untuk mendukung pengujian unit dengan lebih baik. Artikel ini mencakup contoh yang menggunakan Moq dan NSubstitute, yang merupakan pustaka tiruan populer untuk .NET.

Memahami klien layanan

Kelas klien layanan adalah titik masuk utama bagi pengembang di pustaka Azure SDK dan mengimplementasikan sebagian besar logika untuk berkomunikasi dengan layanan Azure. Ketika menguji unit kelas klien layanan, penting untuk dapat membuat instance klien yang bertindak seperti yang diharapkan tanpa melakukan panggilan jaringan apa pun.

Setiap klien Azure SDK mengikuti panduan peniruan yang memungkinkan perilaku mereka diubah:

  • Setiap klien menawarkan setidaknya satu konstruktor yang dilindungi untuk memungkinkan warisan untuk pengujian.
  • Semua anggota klien publik adalah virtual untuk memungkinkan penggantian.

Nota

Contoh kode dalam artikel ini menggunakan jenis dari pustaka Azure.Security.KeyVault.Secrets untuk layanan Azure Key Vault. Konsep yang ditunjukkan dalam artikel ini juga berlaku untuk klien layanan dari banyak layanan Azure lainnya, seperti Azure Storage atau Azure Service Bus.

Untuk membuat klien layanan pengujian, Anda dapat menggunakan pustaka tiruan atau fitur C# standar seperti pewarisan. Kerangka kerja tiruan memungkinkan Anda menyederhanakan kode yang harus Anda tulis untuk mengambil alih perilaku anggota. (Kerangka kerja ini juga memiliki fitur berguna lainnya yang berada di luar cakupan artikel ini.)

Untuk membuat instans klien pengujian menggunakan C# tanpa pustaka tiruan, warisi dari jenis klien dan ambil alih metode yang Anda panggil dalam kode Anda dengan implementasi yang mengembalikan sekumpulan objek pengujian. Sebagian besar klien memiliki metode sinkron dan asinkron untuk operasi; timpa hanya metode yang dipanggil oleh kode aplikasi Anda.

Nota

Sangat rumit untuk menentukan kelas pengujian secara manual, terutama jika Anda perlu menyesuaikan perilaku secara berbeda untuk setiap pengujian. Pertimbangkan untuk menggunakan pustaka seperti Moq atau NSubstitute untuk menyederhanakan pengujian Anda.

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

Model input dan output klien layanan

Jenis model menyimpan data yang dikirim dan diterima dari layanan Azure. Ada tiga jenis model:

  • Model input dimaksudkan untuk dibuat dan diteruskan sebagai parameter ke metode layanan oleh pengembang. Mereka memiliki satu atau beberapa konstruktor publik dan properti yang dapat ditulis.
  • Model output hanya dikembalikan oleh layanan dan tidak memiliki konstruktor publik atau properti yang dapat ditulis.
  • Model pulang pergi kurang umum, tetapi dikembalikan oleh layanan, dimodifikasi, dan digunakan sebagai input.

Untuk membuat instans pengujian model input, gunakan salah satu konstruktor publik yang tersedia dan atur properti tambahan yang Anda butuhkan.

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

Untuk membuat instans model output, pabrik model digunakan. Pustaka klien Azure SDK menyediakan kelas pabrik model statis dengan akhiran ModelFactory di namanya. Kelas berisi sekumpulan metode statis untuk menginisialisasi jenis model output pustaka. Misalnya, pabrik model untuk SecretClient adalah SecretModelFactory:

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

Nota

Beberapa model input memiliki properti baca-saja yang hanya akan terisi ketika model tersebut dikembalikan oleh layanan. Dalam hal ini, akan tersedia sebuah metode pabrik model yang memungkinkan pengaturan properti ini. Contohnya, 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);

Menjelajahi jenis respons

Kelas Response adalah kelas abstrak yang mewakili respons HTTP dan dikembalikan oleh sebagian besar metode klien layanan. Anda dapat membuat instans pengujian Response menggunakan pustaka tiruan atau warisan C# standar.

Kelas Response ini abstrak, yang berarti ada banyak anggota yang harus diambil alih. Pertimbangkan untuk menggunakan pustaka untuk menyederhanakan pendekatan Anda.

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

Beberapa layanan juga mendukung penggunaan jenis , Response<T> yang merupakan kelas yang berisi model dan respons HTTP yang mengembalikannya. Untuk membuat instans pengujian Response<T>, gunakan metode statis Response.FromValue.

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

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

Menjelajahi halaman

Kelas Page<T> digunakan sebagai blok penyusun dalam metode layanan yang memanggil operasi yang mengembalikan hasil di beberapa halaman. Page<T> jarang dikembalikan langsung dari API tetapi berguna untuk membuat instance AsyncPageable<T> dan Pageable<T> di bagian berikutnya. Untuk membuat instans Page<T>, gunakan metode Page<T>.FromValues dengan memasukkan daftar item, token kelanjutan, dan Response.

Parameter continuationToken digunakan untuk mengambil halaman berikutnya dari layanan. Untuk tujuan pengujian unit, harus diatur ke null untuk halaman terakhir dan harus tidak ada untuk halaman lain.

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

AsyncPageable<T> dan Pageable<T> merupakan kelas yang mewakili kumpulan model yang dikembalikan oleh layanan di halaman. Satu-satunya perbedaan di antara mereka adalah bahwa satu digunakan dengan metode sinkron sementara yang lain digunakan dengan metode asinkron.

Untuk membuat instans Pageable pengujian atau AsyncPageable, gunakan FromPages metode statis:

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

Menulis pengujian unit tiruan

Misalkan aplikasi Anda berisi kelas yang menemukan nama kunci yang akan kedaluwarsa dalam waktu tertentu.

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

Anda ingin menguji perilaku berikut dari AboutToExpireSecretFinder untuk memastikan mereka terus bekerja sebagaimana yang diharapkan:

  • Rahasia tanpa tanggal kedaluwarsa yang ditetapkan tidak dikembalikan.
  • Rahasia dengan tanggal kedaluwarsa yang lebih dekat ke tanggal saat ini daripada batas yang ditentukan akan dikembalikan.

Saat pengujian unit, Anda hanya ingin pengujian unit memverifikasi logika aplikasi dan bukan apakah layanan atau pustaka Azure berfungsi dengan benar. Contoh berikut menguji perilaku utama menggunakan pustaka xUnit populer:

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

Refaktor tipe Anda agar dapat diuji

Kelas yang perlu diuji harus dirancang untuk injeksi dependensi, yang memungkinkan kelas untuk menerima dependensinya alih-alih membuatnya secara internal. Ini adalah proses yang mulus untuk menggantikan SecretClient implementasi dalam contoh dari bagian sebelumnya karena itu adalah salah satu parameter konstruktor. Namun, mungkin ada kelas dalam kode Anda yang membuat dependensi mereka sendiri dan tidak mudah diuji, seperti kelas berikut:

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

Refaktorisasi paling sederhana yang dapat Anda lakukan untuk mengaktifkan pengujian dengan injeksi dependensi adalah dengan mengekspos klien sebagai parameter dan menjalankan kode pembuatan bawaan ketika tidak ada nilai yang diberikan. Pendekatan ini memungkinkan Anda untuk membuat kelas dapat diuji sambil tetap mempertahankan fleksibilitas menggunakan tipe tanpa prosedur yang rumit.

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

Opsi lain adalah memindahkan pembuatan dependensi sepenuhnya ke dalam kode panggilan:

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

Pendekatan ini berguna ketika Anda ingin menggabungkan pembuatan ketergantungan dan mengelola klien bersama antara beberapa kelas yang menggunakan.

Memahami klien Azure Resource Manager (ARM)

Di pustaka ARM, klien dirancang untuk menekankan hubungan mereka satu sama lain, mencerminkan hierarki layanan. Untuk mencapai tujuan tersebut, metode ekstensi banyak digunakan untuk menambahkan fitur tambahan ke klien.

Misalnya, komputer virtual Azure ada di grup sumber daya Azure. Namespace Azure.ResourceManager.Compute memodelkan mesin virtual Azure sebagai VirtualMachineResource. Namespace Azure.ResourceManager memodelkan grup sumber daya Azure sebagai ResourceGroupResource. Untuk mengkueri komputer virtual untuk grup sumber daya, Anda akan menulis:

VirtualMachineCollection virtualMachineCollection = resourceGroup.GetVirtualMachines();

Karena fungsionalitas terkait komputer virtual seperti GetVirtualMachines pada , diimplementasikan ResourceGroupResourcesebagai metode ekstensi, tidak mungkin untuk hanya membuat tiruan jenis dan mengambil alih metode . Sebagai gantinya, Anda juga harus membuat kelas tiruan untuk "sumber daya yang dapat ditiru" dan menghubungkannya bersama-sama.

Jenis sumber daya yang dapat di-mock selalu berada di Mocking sub-namespace metode ekstensi. Dalam contoh sebelumnya, jenis sumber daya yang dapat ditiru ada di Azure.ResourceManager.Compute.Mocking namespace. Jenis sumber daya Mockable selalu dinamai berdasarkan jenis sumber daya dengan "Mockable" dan nama pustaka sebagai prefiks. Dalam contoh sebelumnya, jenis sumber daya yang dapat ditiru diberi nama MockableComputeResourceGroupResource, di mana ResourceGroupResource adalah jenis sumber daya metode ekstensi, dan Compute merupakan nama pustaka.

Satu persyaratan lagi sebelum Anda dapat menjalankan pengujian unit adalah membuat tiruan metode GetCachedClient pada tipe sumber daya dari metode ekstensi. Menyelesaikan langkah ini menghubungkan metode ekstensi dengan metode pada jenis sumber daya yang dapat di-mock.

Berikut cara kerjanya:

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

Lihat juga