Unit Testing di una classe che richiede dati da più origini

Aug 17 2020

Contesto

Sto lavorando a un progetto che estrae dati da AWS utilizzando i vari SDK AWS per .NET. Questo esempio specifico si occupa AWSSDK.IdentityManagementdell'SDK

L'obiettivo è eseguire query sulle informazioni IAmazonIdentityManagementServicee mapparle a un modello utile per il dominio aziendale in cui sto lavorando

Sono stato incaricato di scrivere unit test per la IamServiceclasse.

Problema

Dato che le configurazioni di Unit Test sono così dettagliate, non posso fare a meno di pensare che il metodo I'm Unit Testing ( GetIamSummaryAsync) debba essere costruito male.

Ho cercato su Google cose come "Modelli di progettazione per mappare più origini dati su singoli oggetti", ma l'unico consiglio che vedo è quello di utilizzare i modelli Adapter o Proxy. Non sono sicuro di come applicarli a questo scenario

Domanda

  • C'è un modo migliore per costruire la mia IamServiceclasse per rendere più facile (più succinto) il test?
  • Se i pattern Adapter o Proxy sono appropriati per questo tipo di scenario, come verrebbero applicati?
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;
    }
}

Dove la classe IamSummaryModelè definita come:

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

Il problema che sto affrontando è che i miei test unitari si trasformano in una massa di codice nella sezione Assemblaggio. Devo simulare ogni chiamata che effettuo a ciascun metodo client dell'SDK AWS.

Test unitario di esempio

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

Risposte

2 Flater Aug 17 2020 at 17:55

Non c'è niente di sbagliato in questo metodo. Richiede molte informazioni, ma a volte è qualcosa che deve essere fatto (ad esempio per la creazione di report o per la preparazione di un trasferimento di dati di grandi dimensioni).
È inevitabile che quando deridi la tua fonte di dati, più fonti hai, più devi deridere. Questo non è facilmente evitabile. Tuttavia, puoi rivalutare il tuo approccio che ti ha portato qui.

1. Questi dati devono essere combinati?

La prima domanda da porsi è se è necessario combinare questi dati. Se non lo è, e puoi mantenere questi dati separati, allora è un ottimo modo per mantenere la tua base di codice più semplice e facile da deridere (e quindi testare).
Se questi dati devono essere combinati a un certo punto, il refactoring della classe sposta semplicemente la logica di combinazione dei dati a un altro livello, dove ora si apre la stessa domanda di test di unità: come si fa il mock in quel livello? Il riposizionamento della logica non lo risolve.

2. Ho bisogno di testare l'unità?

In secondo luogo, dovresti chiederti se lo unit test è garantito qui. Sebbene non tutti siano d'accordo (personalmente, sono sul recinto), c'è una ragione ragionevole da fare per IamServicenon essere testato in unità poiché non è una classe logica di dominio, ma invece è un wrapper / mappatore di una risorsa esterna .

Allo stesso modo, non testerei nemmeno una classe di contesto EntityFramework, a meno che non contenga logica aziendale personalizzata (ad esempio campi di controllo automatico), perché tale logica aziendale deve essere testata. Il resto della classe è solo l'implementazione di EF, che non garantisce il test.

Il tuo IamServiceè attualmente privo di qualsiasi logica di business reale, quindi l'argomento per non testare l'unità è abbastanza forte, secondo me. L'argomento secondo cui la mappatura IamSummaryModeldell'oggetto conta come logica di business è, beh, discutibile. Non provo sempre mappature banali in quanto il codice banale non dovrebbe essere testato (nota: mentre credo che questo sia corretto, sono consapevole che è molto facile abusare dell'etichetta "banale" sul codice che in realtà non è banale. ATTENZIONE)

3. Come posso ridurre al minimo lo sforzo di deridere?

Se hai raggiunto questo punto, accetti che sia la combinazione dei dati che il test di unità della tua classe sono necessari. Questo logicamente si conclude con la necessità di deridere tutte queste fonti di dati durante il test di quella classe. Ora è diventato un fatto inevitabile.

Ma ciò non significa che non puoi semplificarti la vita riutilizzando / semplificando la logica di organizzazione. Lascia che la tua classe di test erediti da una classe base utilizzata come dispositivo o implementa una proprietà contenente tale dispositivo. Per questa risposta, sceglierò il percorso di ereditarietà, ma entrambi funzionano.

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

Questa è un'implementazione molto rapida di un tale dispositivo. Quel dispositivo può fare la maggior parte del lavoro di gambe per te. Se hai più di un test, cosa che presumo molto, ridurrà in modo significativo la complessità di doverlo impostare per ogni singolo test.

Quando imposti il ​​mock, puoi fare affidamento sui valori che hai scelto e rendere accessibile tramite le proprietà, che puoi poi riutilizzare per la tua logica di asserzione. Per esempio:

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

Si noti come ho impostato una risposta derisa specifica e sono quindi in grado di utilizzare quella risposta derisa per confrontarla con la risposta effettiva ricevuta dalla mia unità sottoposta a test.

Se è necessario scrivere test specifici per criteri specifici, è possibile aggiungere parametri di metodo dove necessario, ad esempio:

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

Probabilmente avrai una logica di asserzione più complessa quando utilizzi valori mocked personalizzati, ma questo è solo un esempio di base.


Nota: come ho menzionato nel commento alla risposta di candied_orange, è consigliabile non utilizzare le interfacce delle tue librerie nel tuo dominio (o almeno minimizzarlo pesantemente), ma questo non è correlato al nucleo della tua domanda qui, quindi sto saltando quel punto.

4 candied_orange Aug 17 2020 at 15:03

Una classe che conosce le origini dati non può essere testata in unità. Può essere solo testato per l'integrazione.

Una classe che conosce le strutture dei dati può essere testata in unità. Ciò di cui hai bisogno è un modo per fornire le strutture dati che non richiedono la conoscenza delle origini dati.

Questi possono essere qualsiasi cosa, dai dati di test hardcoded ai database in memoria. Ma se stai parlando con le tue fonti di dati reali, non sei un test unitario .