Рекомендации по правильному написанию модульных тестов

Рекомендации по правильному написанию модульных тестов

12 мая 2022 г.

В Интернете есть бесчисленное множество статей о модульных тестах: [подход TDD] (https://www.browserstack.com/guide/what-is-test-driven-development), руководства для начинающих, фиктивные фреймворки, инструменты покрытия тестами и более. Тем не менее, подавляющее большинство этих статей либо слишком похожи на «Hello World», либо больше сосредоточены на инструментах, но упускают из виду ключевой момент — как писать модульные тесты, которые полезны и приносят наибольшую пользу?


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


Мы начнем с примера кода (класс C# плюс несколько модульных тестов для этого класса) и выделим некоторые важные моменты, связанные с тем, что мы хотим протестировать и как мы собираемся это делать. Затем мы рассмотрим один из многих способов (мой любимый) структурирования модульных тестов. Наконец, я поделюсь несколькими замечательными библиотеками, которые я использую во всех своих тестовых проектах.


Готовый?


Давайте начнем🍿


Пример 1


Менеджер пользователей


```csharp


открытый класс UserManager : IUserManager


частный только для чтения IUserRepository _userRepository;


частный только для чтения IUserGuard _userGuard;


общедоступный UserManager (IUserRepository userRepository, IUserGuard userGuard)


_userRepository = пользовательское хранилище;


_userGuard = защита пользователя;


общедоступная асинхронная задача ChangeEmail (int userId, string newEmail, CancellationToken ct = default)


var user = await _repository.GetUser (userId, ct);


_guard.UserExists(пользователь);


_guard.UserIsAllowedToChangeEmail(пользователь!);


_guard.EmailIsValid(newEmail);


_guard.EmailIsNew(пользователь!, newEmail);


пользователь!.Электронная почта = новая электронная почта;


ожидайте _repository.Save (пользователь, кт);


Тестовая стратегия


  1. Мы хотим убедиться, что все вызовы IUserGuard происходят в правильном порядке и, что наиболее важно, идут до _repository.Save. Другими словами, проверка должна пройти до того, как данные будут сохранены в базе данных.

  1. Убедитесь, что мы передаем правильные параметры всем методам, которые мы здесь вызываем.

  1. Наш метод ChangeEmail вызывает _repository.Save.

  1. Мы сохраним объект «пользователь» с новым адресом электронной почты.

Тесты


```csharp


открытый класс UserManagerTests


открытый класс ChangeEmail: UserManagerTestsBase


[Теория]


[Автоданные]


общедоступная асинхронная задача Should_change_email (int userId,


строка новая электронная почта,


Пользователь пользователь,


CancellationToken ct)


// договариваться


Repository.GetUser(userId, ct).Returns(пользователь);


// действовать


await Manager.ChangeEmail(userId, newEmail, ct);


// утверждать


Получено.ВПорядке(() =>


Guard.Received(1).UserExists(пользователь);


Guard.Received(1).UserIsAllowedToChangeEmail(пользователь);


Guard.Received(1).EmailIsValid(newEmail);


Guard.Received(1).EmailIsNew(пользователь, newEmail);


Repository.Received(1).Save(Arg.Is(x => x == user &&


x.Электронная почта == новая электронная почта),


КТ);


общедоступный абстрактный класс UserManagerTestsBase


защищенный менеджер UserManager только для чтения;


защищенный репозиторий IUserRepository только для чтения;


защищенный IUserGuard Guard только для чтения;


защищенный UserManagerTestsBase()


Репозиторий = Substitute.For();


Guard = Substitute.For();


Менеджер = новый UserManager (Репозиторий, Охранник);


Примечания


  1. Вместо жесткого кодирования тестовых данных мы генерировали случайные значения, используя библиотеку [AutoData] (AutoFixture.Xunit2).

  1. Для проверки порядка вызовов методов мы использовали Received.InOrder(...) (NSubstitute — лучшая моковая библиотека для .NET).

  1. Мы использовали Arg.Is<User>(x => x.Email == newEmail), чтобы убедиться, что мы изменили адрес электронной почты user перед сохранением этого объекта в базе данных.

Теперь мы собираемся добавить еще один метод в класс UserManager и протестировать его.


Пример 2


```csharp


открытый класс UserManager : IUserManager


частный только для чтения IUserRepository _repository;


приватный только для чтения IUserGuard _guard;


общедоступный UserManager (репозиторий IUserRepository, защита IUserGuard)


_repository = репозиторий;


_guard = охрана;


// общедоступная асинхронная задача ChangeEmail (int userId, string newEmail, CancellationToken ct = default)


общедоступная асинхронная задача ChangePassword (int userId, string newPassword, CancellationToken ct = default)


var user = await _repository.GetUser (userId, ct);


если (пользователь == ноль)


выбросить новое исключение ApplicationException($"Пользователь {userId} не найден");


user.Password = новый пароль;


ожидайте _repository.Save (пользователь, кт);


Тестовая стратегия


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

  1. Вместо того, чтобы игнорировать исключение, мы хотим проверить тип выброшенного исключения вместе с его сообщением.

  1. Как и в предыдущем примере, мы хотим убедиться, что мы передали объект user с обновленным паролем в метод _repository.Save.

  1. Если возникает исключение, то метод _repository.Save не должен вызываться.

Тесты


```csharp


открытый класс UserManagerTests


// общедоступный класс ChangeEmail: UserManagerTestsBase


открытый класс ChangePassword: UserManagerTestsBase


[Теория, Автоданные]


общедоступная асинхронная задача Should_throw_ApplicationException_when_user_not_found(int userId,


строка новый пароль,


CancellationToken ct)


// действовать


var action = () => Manager.ChangePassword(userId, newPassword, ct);


// утверждать


ждать действия. Должен ()


.ThrowAsync<исключение приложения>()


.WithMessage($"Пользователь {userId} не найден");


await Repository.DidNotReceiveWithAnyArgs().Save(Arg.Any(), Arg.Any());


[Теория, Автоданные]


общедоступная асинхронная задача Should_change_password_and_save (int userId,


строка новый пароль,


Пользователь пользователь,


CancellationToken ct)


// договариваться


Repository.GetUser(userId, ct).Returns(пользователь);


// действовать


await Manager.ChangePassword(userId, newPassword, ct);


// утверждать


await Repository.Received(1).Save(Arg.Is(x => x == user &&


x.Password == новый пароль),


КТ);


общедоступный абстрактный класс UserManagerTestsBase


защищенный менеджер UserManager только для чтения;


защищенный репозиторий IUserRepository только для чтения;


защищенный IUserGuard Guard только для чтения;


защищенный UserManagerTestsBase()


Репозиторий = Substitute.For();


Guard = Substitute.For();


Менеджер = новый UserManager (Репозиторий, Охранник);


Примечания


  1. Для метода ChangePassword у нас есть два теста: первый тест для проверки того, что исключение генерируется, когда пользователь не найден, а второй – для проверки того, что мы делаем вызов _repository.Save.

  1. Обратите внимание, как мы тестируем исключения: вместо try-catch мы создаем делегата для тестового метода, а затем выполняем action.Should().ThrowAsync<ApplicationException>().

Наши тесты завершены, давайте взглянем на покрытие юнит-тестами, чтобы убедиться, что мы ничего не пропустили и не осталось непроверенного кода.


Охват модульных тестов


Структура теста


В значительной степени макет тестового проекта — дело вкуса. Вот что я предпочитаю делать. Для классов с более чем одним методом (менеджеры, службы и т. д.) структура связанного класса с модульными тестами будет следующей:


```csharp


открытый класс MyClassTests


открытый класс Method1: MyClassTestsBase


[Теория]


[Автоданные]


общедоступная асинхронная задача Should_return_A_when_X (параметры)


// договариваться


// действовать


// утверждать


[Теория]


[Автоданные]


общедоступная асинхронная задача Should_throw_B_when_Y (параметры)


// договариваться


// действовать


// утверждать


открытый класс Method2: MyClassTestsBase


[Факт]


общедоступная асинхронная задача Should_return_A_when_X()


// договариваться


// действовать


// утверждать


[Факт]


общедоступная асинхронная задача Should_throw_B_when_Y()


// договариваться


// действовать


// утверждать


общедоступный абстрактный класс MyClassTestsBase


защищенный экземпляр MyClass только для чтения;


защищенный только для чтения IDependency1 Dependency1;


защищенный только для чтения IDependency2 Dependency2;


защищенный UserManagerTestsBase()


Зависимость1 = Substitute.For();


Зависимость2 = Substitute.For();


Экземпляр = новый MyClass (IDependency1, IDependency2);


Вот как это выглядит для бегуна тестов:


Тесты для класса с несколькими методами


Структура модульного теста для классов с одним методом (обработчики сообщений, фабрики и т. д.):


```csharp


открытый класс MyHandlerTests


частный доступ только для чтения MyHandler _handler;


общедоступные MyHandlerTests ()


_handler = новый MyHandler();


[Факт]


общественная пустота Should_do_A_when_X()


// договариваться


// действовать


// утверждать


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


Библиотеки и фреймворки


[xUnit] (https://github.com/xunit/xunit)


Обычно это выбор между xUnit, nUnit и MSTest, и лично я отдаю предпочтение первому. Некоторое время назад, когда Microsoft начала использовать xUnit в своих проектах, этот фреймворк стал вариантом по умолчанию.


[Автофиксация] (https://github.com/AutoFixture/AutoFixture) + [Шпаргалка] (https://github.com/AutoFixture/AutoFixture/wiki/Шпаргалка)


Вместо использования жестко заданных значений для тестов Autofixture может генерировать для нас случайные данные.


```csharp


var приспособление = новое приспособление();


var firstName = приспособление.Create();


var numOfUsers = приспособление.Create();


var сотрудников = приспособление.CreateMany();


Autofixture.Xunit2 nuget для атрибута [AutoData] (тот, который мы использовали в наших примерах).


[NSubstitute] (https://github.com/nsubstitute/nsubstitute)


Есть несколько имитационных библиотек, но я считаю эту наиболее явной, естественной и чистой. Небольшой бонус: NSubstitute.Analyzers — анализаторы Roslyn для обнаружения (во время компиляции) возможных ошибок с помощью NSubstitute.


FluentAssertions


Фантастическая структура утверждений с очень подробной и хорошо структурированной [документацией] (https://fluentassertions.com/introduction).


Несколько примеров, взятых из их документов:


```csharp


строка фактическая = "ABCDEFGHI";


фактическое.Should().StartWith("AB").And.EndWith("HI").And.Contain("EF").And.HaveLength(9);


IEnumerable numbers = new[] { 1, 2, 3 };


числа.Должен().OnlyContain(n => n > 0);


number.Should().HaveCount(4, "потому что мы думали, что поместили в коллекцию четыре элемента");


Разве это не красиво?


[Stryker.NET] (https://github.com/stryker-mutator/stryker-net)


Stryker предлагает мутационное тестирование для ваших проектов .NET Core и .NET Framework. Это позволяет вам тестировать свои тесты, временно вставляя ошибки в исходный код.


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


Резюме


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


Ваше здоровье!



Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE