Unit Testing einer Klasse, die Daten aus mehreren Quellen anfordert

Aug 17 2020

Kontext

Ich arbeite an einem Projekt, das Daten aus AWS mithilfe der verschiedenen AWS SDKs für .NET abruft. Dieses spezielle Beispiel befasst sich mit dem AWSSDK.IdentityManagementSDK

Ziel ist es, Informationen abzufragen IAmazonIdentityManagementServiceund einem Modell zuzuordnen, das für die Geschäftsdomäne, in der ich arbeite, hilfreich ist

Ich wurde beauftragt, Unit-Tests für die IamServiceKlasse zu schreiben .

Problem

Da die Unit-Test-Setups so ausführlich sind, kann ich nicht anders, als zu denken, dass die Methode, mit der ich Unit Testing ( GetIamSummaryAsync) bin, schlecht aufgebaut sein muss.

Ich habe nach Dingen wie "Entwurfsmuster zum Zuordnen mehrerer Datenquellen zu einzelnen Objekten" gegoogelt, aber der einzige Rat, den ich sehe, ist die Verwendung der Adapter- oder Proxy-Muster. Ich bin nicht sicher, wie ich sie auf dieses Szenario anwenden soll

Frage

  • Gibt es eine bessere Möglichkeit, meine IamServiceKlasse so aufzubauen, dass das Testen einfacher (prägnanter) wird?
  • Wie würden die Adapter- oder Proxy-Muster angewendet, wenn sie für diese Art von Szenario geeignet sind?
public class IamService : IIamService
{
    IAmazonIdentityManagementService _iamClient;

    public IamService(IAmazonIdentityManagementService iamClient)
    {
        _iamClient = iamClient;
    }

    public async Task<IamSummaryModel> GetIamSummaryAsync()
    {
        var getAccountSummaryResponse           = await _iamClient.GetAccountSummaryAsync();
        var listCustomerManagedPoliciesResponse = await _iamClient.ListPoliciesAsync();
        var listGroupsResponse                  = await _iamClient.ListGroupsAsync();
        var listInstanceProfilesResponse        = await _iamClient.ListInstanceProfilesAsync();
        var listRolesResponse                   = await _iamClient.ListRolesAsync();
        var listServerCertificatesResponse      = await _iamClient.ListServerCertificatesAsync();
        var listUsersResponse                   = await _iamClient.ListUsersAsync();

        IamSummaryModel iamSummary = new IamSummaryModel();

        iamSummary.CustomerManagedPolicies.Count = listCustomerManagedPoliciesResponse.Policies.Count;
        iamSummary.CustomerManagedPolicies.DefaultQuota = getAccountSummaryResponse.SummaryMap["PoliciesQuota"];

        iamSummary.Groups.Count = listGroupsResponse.Groups.Count;
        iamSummary.Groups.DefaultQuota = getAccountSummaryResponse.SummaryMap["GroupsQuota"];

        iamSummary.InstanceProfiles.Count = listInstanceProfilesResponse.InstanceProfiles.Count;
        iamSummary.InstanceProfiles.DefaultQuota = getAccountSummaryResponse.SummaryMap["InstanceProfilesQuota"];

        iamSummary.Roles.Count = listRolesResponse.Roles.Count;
        iamSummary.Roles.DefaultQuota = getAccountSummaryResponse.SummaryMap["RolesQuota"];

        iamSummary.ServerCertificates.Count = listServerCertificatesResponse.ServerCertificateMetadataList.Count;
        iamSummary.ServerCertificates.DefaultQuota = getAccountSummaryResponse.SummaryMap["ServerCertificatesQuota"];

        iamSummary.Users.Count = listUsersResponse.Users.Count;
        iamSummary.Users.DefaultQuota = getAccountSummaryResponse.SummaryMap["UsersQuota"];

        return iamSummary;
    }
}

Wo die Klasse IamSummaryModeldefiniert ist als:

public sealed class IamSummaryModel
{
    public ResourceSummaryModel CustomerManagedPolicies { get; set; } = new ResourceSummaryModel();
    public ResourceSummaryModel Groups { get; set; } = new ResourceSummaryModel();
    public ResourceSummaryModel InstanceProfiles { get; set; } = new ResourceSummaryModel();
    public ResourceSummaryModel Roles { get; set; } = new ResourceSummaryModel();
    public ResourceSummaryModel ServerCertificates { get; set; } = new ResourceSummaryModel();
    public ResourceSummaryModel Users { get; set; } = new ResourceSummaryModel();
}

public sealed class ResourceSummaryModel
{
    public int Count { get; set; }
    public int DefaultQuota { get; set; }
}

Das Problem, mit dem ich konfrontiert bin, ist, dass meine Unit-Tests im Assemble-Bereich zu einer Masse von Code werden. Ich muss jeden Aufruf verspotten, den ich an jede AWS SDK-Clientmethode mache.

Beispiel Unit Test

[Fact]
public async Task GetIamSummaryAsync_CustomerManagerPolicies_MapToModel()
{
    // Arrange
    var iamClientStub = new Mock<IAmazonIdentityManagementService>();
    
    iamClientStub.Setup(iam => iam.ListPoliciesAsync(It.IsAny<CancellationToken>()))
        .Returns(Task.FromResult(
            new ListPoliciesResponse()
            {
                Policies = new List<ManagedPolicy>()
                {
                    new ManagedPolicy(),
                    new ManagedPolicy()
                }
            }
        ));

    // Lots of other mocks, one for each dependency
    
    var sut = new IamService(iamClientStub.Object);

    // Act
    var actual = await sut.GetIamSummaryAsync();

    // Assert
    Assert.Equal(2, actual.CustomerManagedPolicies.Count);
}

Antworten

2 Flater Aug 17 2020 at 17:55

An dieser Methode ist nichts auszusetzen. Es werden viele Informationen abgerufen, aber manchmal muss dies getan werden (z. B. für die Berichterstellung oder die Vorbereitung einer großen Datenübertragung).
Es ist unvermeidlich, dass wenn Sie Ihre Datenquelle verspotten, je mehr Quellen Sie haben, desto mehr müssen Sie verspotten. Das ist nicht leicht zu vermeiden. Sie können jedoch Ihren Ansatz, der Sie hierher geführt hat, neu bewerten.

1. Müssen diese Daten kombiniert werden?

Die erste Frage, die Sie sich stellen müssen, ist, ob eine Kombination dieser Daten erforderlich ist. Wenn dies nicht der Fall ist und Sie diese Daten getrennt aufbewahren können, ist dies eine hervorragende Möglichkeit, Ihre Codebasis einfacher und leichter zu verspotten (und somit zu testen).
Wenn diese Daten irgendwann kombiniert werden müssen, verschiebt die Umgestaltung Ihrer Klasse die Datenkombinationslogik nur auf eine andere Ebene, auf der jetzt dieselbe Frage zum Testen von Einheiten auftaucht: Wie kann man sich in dieser Ebene verspotten ? Das Verschieben der Logik behebt das Problem nicht.

2. Muss ich dies einem Unit-Test unterziehen?

Zweitens sollten Sie sich fragen, ob hier ein Komponententest gerechtfertigt ist. Obwohl nicht alle zustimmen (persönlich bin ich am Zaun), gibt es ein vernünftiges Argument dafür IamService, nicht auf Einheiten getestet zu werden, da es sich nicht um eine Domänenlogikklasse handelt, sondern um einen Wrapper / Mapper einer externen Ressource .

Ebenso würde ich eine EntityFramework-Kontextklasse auch nicht testen, es sei denn, sie enthält benutzerdefinierte Geschäftslogik (z. B. automatische Überwachungsfelder), da diese Geschäftslogik getestet werden muss. Der Rest der Klasse ist nur die Implementierung von EF, die keine Tests rechtfertigt.

Sie haben IamServicederzeit keine wirkliche Geschäftslogik, daher ist das Argument, es nicht einem Unit-Test zu unterziehen, meiner Meinung nach ziemlich stark. Das Argument, dass die Zuordnung des IamSummaryModelObjekts als Geschäftslogik gilt, ist durchaus umstritten. Ich teste nicht immer triviale Zuordnungen, da trivialer Code nicht getestet werden sollte (Hinweis: Obwohl ich der Meinung bin, dass dies korrekt ist, ist mir bewusst, dass es sehr leicht ist, das "triviale" Etikett auf Code zu missbrauchen, der nicht wirklich trivial ist. ACHTUNG)

3. Wie minimiere ich den Spottaufwand?

Wenn Sie diesen Punkt erreicht haben, stimmen Sie zu, dass sowohl das Kombinieren der Daten als auch das Testen der Einheit Ihrer Klasse erforderlich sind. Dies führt logischerweise dazu, dass beim Testen dieser Klasse alle diese Datenquellen verspottet werden müssen. Es ist jetzt eine unvermeidliche Tatsache geworden.

Das heißt aber nicht, dass Sie Ihr Leben nicht einfacher machen können, indem Sie die Arrangierlogik wiederverwenden / vereinfachen. Lassen Sie Ihre Testklasse entweder von einer Basisklasse erben, die als Fixture verwendet wird, oder implementieren Sie eine Eigenschaft, die diese Fixture enthält. Für diese Antwort wähle ich die Vererbungsroute, aber beide funktionieren.

public class IamServiceTestFixture
{
    protected IamService GetService()
    {
        var mockedAmazonService = GetMockedAmazonService();

        return new IamService(mockedAmazonService);
    }

    private IAmazonIdentityManagementService GetMockedAmazonService()
    {
        var iamClientStub = new Mock<IAmazonIdentityManagementService>();

        // Set up your mocks

        return iamClientStub;
    }
}

public class IamServiceTests : IamServiceTestFixture
{
    [Test]
    public void MyTest()
    {
        // Arrange
        var sut = GetService();

        // Act
        var actual = await sut.GetIamSummaryAsync();

        // Assert
        Assert.Equal(2, actual.CustomerManagedPolicies.Count);
    }
}

Dies ist eine sehr schnelle Implementierung einer solchen Vorrichtung. Diese Leuchte kann den größten Teil der Arbeit für Sie erledigen. Wenn Sie mehr als einen Test haben, von dem ich sehr annehme, dass Sie dies tun werden, wird dies die Komplexität der Einrichtung für jeden einzelnen Test erheblich verringern.

Beim Einrichten des Modells können Sie sich auf ausgewählte Werte verlassen und diese über Eigenschaften zugänglich machen, die Sie später für Ihre Assertionslogik wiederverwenden können. Zum Beispiel:

public class IamServiceTestFixture
{
    protected ListPoliciesResponse ListPoliciesResponse { get; private set; }

    public IamServiceTestFixture()
    {
         this.ListPoliciesResponse = new ListPoliciesResponse()
         {
             Policies = new List<ManagedPolicy>()
             {
                 new ManagedPolicy(),
                 new ManagedPolicy()
             }
         }
    }

    protected IamService GetService()
    {
        var mockedAmazonService = GetMockedAmazonService();

        return new IamService(mockedAmazonService);
    }

    private IAmazonIdentityManagementService GetMockedAmazonService()
    {
        var iamClientStub = new Mock<IAmazonIdentityManagementService>();

        iamClientStub.Setup(iam => iam.ListPoliciesAsync(It.IsAny<CancellationToken>()))
            .Returns(Task.FromResult(this.ListPoliciesResponse));

        return iamClientStub;
    }
}

public class IamServiceTests : IamServiceTestFixture
{        
    [Test]
    public void MyTest()
    {
        // Arrange
        var sut = GetService();

        // Act
        var actual = await sut.GetIamSummaryAsync();

        // Assert
        Assert.Equal(
            this.ListPoliciesResponse.Policies.Count(), 
            actual.CustomerManagedPolicies.Count()
        );
    }
}

Beachten Sie, wie ich eine bestimmte verspottete Antwort eingerichtet habe und diese verspottete Antwort dann verwenden kann, um sie mit der tatsächlichen Antwort zu vergleichen, die von meinem zu testenden Gerät empfangen wurde.

Wenn Sie bestimmte Tests für bestimmte Richtlinien schreiben müssen, können Sie bei Bedarf Methodenparameter hinzufügen, z.

public class IamServiceTestFixture
{
    protected IamService GetService(IEnumerable<ManagedPolicy> policies)
    {
        var mockedAmazonService = GetMockedAmazonService(policies);

        return new IamService(mockedAmazonService);
    }

    private IAmazonIdentityManagementService GetMockedAmazonService(IEnumerable<ManagedPolicy> policies)
    {
        var iamClientStub = new Mock<IAmazonIdentityManagementService>();

        iamClientStub.Setup(iam => iam.ListPoliciesAsync(It.IsAny<CancellationToken>()))
            .Returns(Task.FromResult(new ListPoliciesResponse()
            {
                    Policies = policies
            }));

        return iamClientStub;
    }
}

public class IamServiceTests : IamServiceTestFixture
{
    [Test]
    public void MyTest()
    {
        var customPolicy = new ManagedPolicy();

        // Arrange
        var sut = GetService(new ManagedPolicy[] { customPolicy });

        // Act
        var actual = await sut.GetIamSummaryAsync();

        // Assert
        actual.CustomerManagedPolicies.Should().Contain(customPolicy);
     }
}

Sie werden wahrscheinlich eine komplexere Assertionslogik haben, wenn Sie benutzerdefinierte verspottete Werte verwenden, aber dies ist nur ein grundlegendes Beispiel.


Hinweis: Wie ich im Kommentar zur Antwort von candied_orange erwähnt habe, ist es ratsam, keine Schnittstellen aus Ihren Bibliotheken in Ihrer Domain zu verwenden (oder zumindest stark zu minimieren), aber das hat nichts mit dem Kern Ihrer Frage zu tun, also überspringe ich diesen Punkt.

4 candied_orange Aug 17 2020 at 15:03

Eine Klasse, die Datenquellen kennt, kann nicht Unit-getestet werden. Es kann nur integrationsgeprüft werden.

Eine Klasse, die Datenstrukturen kennt, kann Unit-getestet werden. Was Sie brauchen, ist eine Möglichkeit, die Datenstrukturen bereitzustellen, für die keine Kenntnisse der Datenquellen erforderlich sind.

Dies kann alles sein, von fest codierten Testdaten bis hin zu Speicherdatenbanken. Wenn Sie jedoch mit Ihren realen Datenquellen sprechen, werden keine Unit-Tests durchgeführt .