Модульное тестирование класса, который запрашивает данные из нескольких источников

Aug 17 2020

Контекст

Я работаю над проектом, который извлекает данные из AWS с помощью различных пакетов SDK AWS для .NET. В этом конкретном примере рассматривается AWSSDK.IdentityManagementSDK.

Цель состоит в том, чтобы запросить информацию IAmazonIdentityManagementServiceи сопоставить ее с моделью, которая будет полезна для области бизнеса, в которой я работаю.

Мне было поручено написать модульные тесты для IamServiceкласса.

Проблема

Из-за того, что настройки модульного тестирования настолько подробны, я не могу не думать, что метод, который я использую для модульного тестирования ( GetIamSummaryAsync), должен быть построен плохо.

Я искал в Google такие вещи, как «Шаблоны проектирования для сопоставления нескольких источников данных с отдельными объектами», но единственный совет, который я получил, - это использовать шаблоны адаптера или прокси. Я не знаю, как применить их к этому сценарию

Вопрос

  • Есть ли лучший способ IamServiceсоздать свой класс, чтобы упростить (более кратко) его тестирование?
  • Если шаблоны адаптера или прокси подходят для этого типа сценария, как они будут применяться?
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;
    }
}

Где класс IamSummaryModelопределяется как:

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

Проблема, с которой я столкнулся, заключается в том, что мои модульные тесты превращаются в массу кода в разделе Assemble. Я должен имитировать каждый вызов, который я делаю для каждого клиентского метода AWS SDK.

Пример модульного теста

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

Ответы

2 Flater Aug 17 2020 at 17:55

В этом методе нет ничего плохого. Он собирает много информации, но иногда это нужно делать (например, для отчета или подготовки большой передачи данных).
Когда вы издеваетесь над своим источником данных, неизбежно, что чем больше у вас источников, тем больше вам придется имитировать. Этого нелегко избежать. Однако вы можете пересмотреть свой подход, который привел вас к этому.

1. Нужно ли объединять эти данные?

Первый вопрос, который следует задать себе, - нужно ли объединять эти данные. Если это не так, и вы можете хранить эти данные отдельно, то это отличный способ сделать вашу кодовую базу проще и легче подделывать (и, следовательно, тестировать).
Если эти данные необходимо объединить в какой-то момент, то рефакторинг вашего класса просто переводит логику объединения данных на другой уровень, где теперь возникает тот же вопрос модульного тестирования: как смоделировать на этом уровне? Перенос логики этого не исправляет.

2. Нужно ли мне это модульное тестирование?

Во-вторых, вы должны спросить, оправдано ли здесь модульное тестирование. Хотя не все согласны (лично я нахожусь на заборе), есть разумный аргумент, чтобы IamServiceне проходить модульное тестирование, поскольку это не класс логики предметной области, а вместо этого является оболочкой / картографом внешнего ресурса. .

Точно так же я бы не стал тестировать класс контекста EntityFramework, если он не содержит настраиваемую бизнес-логику (например, поля автоматического аудита), потому что эта бизнес-логика требует тестирования. Остальная часть класса - это просто реализация EF, которая не требует тестирования.

В IamServiceнастоящее время у вас нет реальной бизнес-логики, поэтому аргумент в пользу отказа от модульного тестирования, на мой взгляд, довольно сильный. Аргумент о том, что отображение IamSummaryModelобъекта считается бизнес-логикой, весьма спорен. Я не всегда тестирую тривиальные сопоставления, поскольку тривиальный код не следует тестировать (примечание: хотя я считаю, что это правильно, я знаю, что очень легко неправильно использовать метку «тривиальный» в коде, который на самом деле не является тривиальным. ОСТОРОЖНО)

3. Как свести к минимуму попытки издевательства?

Если вы достигли этого момента, вы соглашаетесь с тем, что необходимо как объединение данных, так и модульное тестирование вашего класса. Это логически приводит к необходимости имитировать все эти источники данных при тестировании этого класса. Теперь это стало неизбежным фактом.

Но это не значит, что вы не можете облегчить себе жизнь, повторно используя / упрощая логику аранжировки. Пусть ваш тестовый класс наследован от базового класса, используемого в качестве приспособления, или реализует свойство, содержащее указанное приспособление. Для этого ответа я выберу путь наследования, но любой из них работает.

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

Это очень быстрая реализация такого приспособления. Это приспособление может сделать за вас большую часть работы. Если у вас более одного теста, что, как я очень предполагаю, у вас будет, это значительно снизит сложность настройки этого теста для каждого отдельного теста.

При настройке макета вы можете полагаться на выбранные вами значения и сделать их доступными через свойства, которые впоследствии можно будет повторно использовать для своей логики утверждения. Например:

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

Обратите внимание, как я настроил конкретный фиктивный ответ, а затем могу использовать этот фиктивный ответ, чтобы сравнить его с фактическим ответом, полученным от моего тестируемого модуля.

Если вам нужно написать определенные тесты для определенных политик, вы можете добавить параметры метода, где это необходимо, например:

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

У вас, вероятно, будет более сложная логика утверждения при использовании настраиваемых фиктивных значений, но это всего лишь базовый пример.


Примечание: как я уже упоминал в комментарии к ответу Candied_orange, желательно не использовать интерфейсы из ваших библиотек в вашем домене (или, по крайней мере, сильно минимизировать его), но это не связано с сутью вашего вопроса здесь, поэтому я пропускаю этот момент.

4 candied_orange Aug 17 2020 at 15:03

Класс, который знает об источниках данных, не может быть протестирован на единицу. Это может быть только проверка интеграции.

Класс, который знает о структурах данных, может быть протестирован модулем. Что вам нужно, так это способ предоставить структуры данных, которые не требуют знания источников данных.

Это может быть что угодно, от жестко закодированных тестовых данных до баз данных в памяти. Но если вы говорите со своими реальными источниками данных, вы не используете модульное тестирование .