Egységtesztelés és modellálás az Azure SDK for .NET segítségével

Az egységtesztelés fontos része egy fenntartható fejlesztési folyamatnak, amely javíthatja a kódminőséget, és megelőzheti a regressziókat vagy hibákat az alkalmazásokban. Az egységtesztelés azonban kihívást jelent, amikor a tesztelt kód hálózati hívásokat hajt végre, például az Azure-erőforrásokra irányuló hívásokat. Az élő szolgáltatásokon futtatott tesztek olyan problémákat tapasztalhatnak, mint például a teszt végrehajtását lassító késés, az izolált teszten kívüli kódfüggőségek, valamint a szolgáltatásállapot és a költségek kezelésével kapcsolatos problémák minden alkalommal, amikor a teszt lefut. Az élő Azure-szolgáltatások tesztelése helyett cserélje le a szolgáltatás-ügyfeleket a szimulált vagy memórián belüli implementációkra. Ez elkerüli a fenti problémákat, és lehetővé teszi, hogy a fejlesztők a hálózattól és a szolgáltatástól független alkalmazáslogika tesztelésére összpontosítsanak.

Ebben a cikkben megtudhatja, hogyan írhat egységteszteket az Azure SDK for .NET-hez, amelyek elkülönítik a függőségeket, hogy a tesztek megbízhatóbbak legyenek. Azt is megtudhatja, hogyan cserélheti le a kulcsfontosságú összetevőket a memórián belüli tesztelési implementációkra a gyors és megbízható egységtesztek létrehozásához, és megtudhatja, hogyan tervezheti meg saját osztályait az egységtesztelés jobb támogatása érdekében. Ez a cikk olyan példákat tartalmaz, amelyek a Moq-t és az NSubstitute-ot használják, amelyek a .NET népszerű mintakódtárai.

Szolgáltatás-ügyfelek ismertetése

A szolgáltatásügyfél-osztály az Azure SDK-tár fejlesztőinek fő belépési pontja, és a legtöbb logikát implementálja az Azure-szolgáltatással való kommunikációhoz. Az egységtesztelési szolgáltatás ügyfélosztályainál fontos, hogy az ügyfél egy olyan példányát hozza létre, amely a várt módon viselkedik hálózati hívások nélkül.

Az Azure SDK-ügyfelek mindegyike olyan modellelési irányelveket követ, amelyek lehetővé teszik a viselkedésük felülbírálását:

  • Minden ügyfél legalább egy védett konstruktort kínál a teszteléshez szükséges öröklés engedélyezéséhez.
  • Minden nyilvános ügyfél tag virtuális, hogy lehessen felülírni.

Megjegyzés:

A cikkben szereplő példakódok az Azure.Security.KeyVault.Secrets kódtárból származó típusokat használnak az Azure Key Vault szolgáltatáshoz. A cikkben bemutatott fogalmak számos más Azure-szolgáltatás, például az Azure Storage vagy az Azure Service Bus szolgáltatás ügyfeleire is érvényesek.

Tesztszolgáltatás-ügyfél létrehozásához használhatja a mocking könyvtárat vagy a szokásos C#-funkciókat, például az öröklést. A modellezési keretrendszerek lehetővé teszik a tagok viselkedésének felülbírálásához megírt kód egyszerűsítését. (Ezek a keretrendszerek más hasznos funkciókkal is rendelkeznek, amelyek túlmutatnak a jelen cikk hatókörén.)

Ha egy tesztügyfél-példányt a C# használatával szeretne létrehozni egy kódtár használata nélkül, örökölje az ügyféltípust és a kódban meghívandó felülbírálási metódusokat egy olyan implementációval, amely tesztobjektumok készletét adja vissza. A legtöbb ügyfél szinkron és aszinkron metódusokat is tartalmaz a műveletekhez; csak azt bírálja felül, amelyet az alkalmazáskód hív.

Megjegyzés:

Nehézkes lehet manuálisan meghatározni a tesztosztályokat, különösen akkor, ha az egyes tesztek viselkedését eltérően kell testre szabnia. Fontolja meg egy olyan kódtár használatát, mint a Moq vagy az NSubstitute a tesztelés egyszerűsítése érdekében.

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

Szolgáltatásügyfél bemeneti és kimeneti modelljei

A modelltípusok az Azure-szolgáltatásokból küldött és fogadott adatokat tárolják. Három modelltípus létezik:

  • A bemeneti modelleket a fejlesztők a szolgáltatásmódszerek paramétereként kívánják létrehozni és átadni. Egy vagy több nyilvános konstruktoruk és írható tulajdonságaik vannak.
  • A kimeneti modelleket csak a szolgáltatás adja vissza, és nem rendelkeznek nyilvános konstruktorokkal vagy írható tulajdonságokkal.
  • A körutazási modellek kevésbé gyakoriak, de a szolgáltatás visszaadja, módosítja és bemenetként használja.

Egy bemeneti modell tesztpéldányának létrehozásához használja az egyik elérhető nyilvános konstruktort, és állítsa be a szükséges további tulajdonságokat.

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

A kimeneti modellek példányainak létrehozásához a rendszer egy modell-előállítót használ. Az Azure SDK ügyfélkönyvtárak egy statikus modellosztály-gyárat biztosítanak, amely nevében egy ModelFactory utótag található. Az osztály statikus metódusok készletét tartalmazza a kódtár kimeneti modelltípusainak inicializálásához. Például a SecretClient modellgyára a SecretModelFactory:

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

Megjegyzés:

Egyes bemeneti modellek írásvédett tulajdonságokkal rendelkeznek, amelyek csak akkor vannak feltöltve, amikor a modellt visszaadja a szolgáltatás. Ebben az esetben elérhető lesz egy modell-előállító metódus, amely lehetővé teszi ezeknek a tulajdonságoknak a beállítását. Például: 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);

Választípusok felfedezése

Az Response osztály egy absztrakt osztály, amely EGY HTTP-választ jelöl, és a legtöbb szolgáltatásügyfél-metódus visszaadja. Tesztpéldányokat Response hozhat létre mock könyvtár vagy szabványos C# öröklés használatával.

Az Response osztály absztrakt, ami azt jelenti, hogy sok tagot felül kell bírálni. Érdemes lehet egy kódtárat használni a megközelítés gördülékenyebbé tételéhez.

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

Egyes szolgáltatások a típus használatát Response<T> is támogatják, amely egy modellt és a visszaadott HTTP-választ tartalmazó osztály. A tesztpéldány Response<T>létrehozásához használja a statikus Response.FromValue metódust:

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

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

Lapozási lehetőségek felfedezése

Az Page<T> osztály építőelemként használatos olyan szolgáltatásmetóciókban, amelyek több oldalon visszaadott műveleteket hívnak meg. A Page<T> ritkán jelenik meg közvetlenül az API-kból, de hasznos a következő szakaszban található AsyncPageable<T> és Pageable<T> példányok létrehozásához. Példány létrehozásához használja a Page<T> metódust, adja át az elemek listáját, a folytatás tokenjét és a Page<T>.FromValues.

A continuationToken paraméter a következő lap lekérésére szolgál a szolgáltatásból. Egységtesztelés céljából az utolsó oldal esetén null-ra kell beállítani, más oldalaknál pedig nem maradhat üres.

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

AsyncPageable<T> és Pageable<T> olyan osztályok, amelyek a szolgáltatás által a lapokban visszaadott modellek gyűjteményeit jelölik. Az egyetlen különbség közöttük az, hogy az egyiket szinkron metódusokkal, a másikat pedig aszinkron metódusokkal használják.

A Pageable statikus metódust használja a AsyncPageable vagy FromPages tesztpéldány létrehozásához.

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

Szimulált egységteszt írása

Tegyük fel, hogy az alkalmazás tartalmaz egy osztályt, amely megkeresi az adott időn belül lejáró kulcsok nevét.

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

A következő viselkedéseket szeretné tesztelni AboutToExpireSecretFinder, hogy megbizonyosodjon arról, hogy továbbra is a várt módon működnek.

  • A lejárati dátummal nem rendelkező titkos kulcsokat a rendszer nem adja vissza.
  • A rendszer az aktuális dátumhoz közelebbi lejárati dátummal rendelkező titkos kulcsokat ad vissza.

Az egységtesztelés során csak az egységtesztek ellenőrzik az alkalmazás logikáját, és nem azt, hogy az Azure-szolgáltatás vagy a kódtár megfelelően működik-e. Az alábbi példa a népszerű xUnit-kódtár használatával teszteli a kulcs viselkedését:

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

A típusok újrabontása a tesztelhetőség érdekében

A tesztelni kívánt osztályokat függőséginjektálásra kell tervezni, ami lehetővé teszi, hogy az osztály megkapja a függőségeit ahelyett, hogy belsőleg hozza létre őket. Zökkenőmentes folyamat volt az SecretClient előző szakaszban szereplő példában szereplő implementáció lecserélése, mivel ez volt az egyik konstruktorparaméter. Előfordulhat azonban, hogy a kódban vannak olyan osztályok, amelyek saját függőségeket hoznak létre, és nem tesztelhetők könnyen, például a következő osztály:

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

A legegyszerűbb újrabontással engedélyezheti a függőséginjektálással végzett tesztelést, ha az ügyfelet paraméterként teszi elérhetővé, és alapértelmezett létrehozási kódot futtat, ha nincs megadva érték. Ez a megközelítés lehetővé teszi, hogy az osztály tesztelhető legyen, miközben továbbra is megtartja a típus használatának rugalmasságát anélkül, hogy sok ceremónia nélkül.

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

Egy másik lehetőség a függőség létrehozása teljes egészében a hívókódba való áthelyezése:

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

Ez a módszer akkor hasznos, ha össze szeretné konszolidálni a függőség létrehozását, és meg szeretné osztani az ügyfelet több fogyasztó osztály között.

Az Azure Resource Manager (ARM) ügyfelek megértése

Az ARM-kódtárakban az ügyfelek úgy lettek kialakítva, hogy hangsúlyozzák a kapcsolatukat egymással, tükrözve a szolgáltatáshierarchiát. A cél elérése érdekében a bővítménymetelyeket széles körben használják további funkciók ügyfelekhez való hozzáadásához.

Egy Azure-beli virtuális gép például létezik egy Azure-erőforráscsoportban. A Azure.ResourceManager.Compute névtér az Azure-beli virtuális gépet VirtualMachineResourcea következőképpen modellozza: . A Azure.ResourceManager névtér az Azure-erőforráscsoportot ResourceGroupResourcea következőképpen modellozza: . Egy erőforráscsoport virtuális gépeinek lekérdezéséhez a következőt kell írnia:

VirtualMachineCollection virtualMachineCollection = resourceGroup.GetVirtualMachines();

Mivel a virtuális géppel kapcsolatos funkciók, például GetVirtualMachines az on ResourceGroupResource, bővítménymetódusként lettek implementálva, lehetetlen egyszerűen létrehozni egy ilyen típusú mintapéldányt, és felülbírálni a metódust. Ehelyett létre kell hoznia egy modellosztályt a "modellezhető erőforráshoz", és össze kell fűznie őket.

A modellezhető erőforrástípus mindig a Mocking bővítménymetódus alnévterében található. Az előző példában a modellezhető erőforrástípus a Azure.ResourceManager.Compute.Mocking névtérben található. A modellezhető erőforrástípus neve mindig a "Mockable" és az erőforrástár neve előtagként szerepel az erőforrástípus neve előtt. Az előző példában a modellezhető erőforrástípus neve MockableComputeResourceGroupResource, ahol ResourceGroupResource a bővítménymetódus erőforrástípusa, és Compute az erőforrástár neve.

Az egységteszt futtatása előtt még egy követelmény a metódus szimulálása a GetCachedClient bővítménymetódus erőforrástípusán. A lépés végrehajtása összekapcsolja a bővítménymetódust és a metódust a modellezhető erőforrástípuson.

Így működik:

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

Lásd még