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

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

19 мая 2022 г.

В предыдущей статье [Передовые практики правильного написания модульных тестов (часть 1)] (https://hackernoon.com/best-practices-to-write-unit-tests-the-right-way) мы рассмотрели некоторые из лучших практик для модульного тестирования, а затем составил список обязательных библиотек, которые значительно улучшают качество тестов. Однако в нем не были рассмотрены некоторые распространенные сценарии, такие как тестирование LINQ и сопоставлений, поэтому я решил восполнить этот пробел еще одним постом, основанным на примерах.


Готовы еще больше улучшить свои навыки модульного тестирования? Давайте начнем🍿


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


Тестирование LINQ


Дело в том, что все разработчики C# любят LINQ, но мы также должны относиться к нему с уважением и покрывать запросы тестами. Кстати, это одно из многих преимуществ LINQ перед SQL (вы когда-нибудь видели реального человека, который написал хотя бы один модульный тест для SQL? Я тоже не встречал).


Давайте посмотрим на пример.


```csharp


открытый класс UserRepository: IUserRepository


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


общедоступный UserRepository (IDb db)


_дб = дб;


public Task GetUser(int id, CancellationToken ct = default)


вернуть _db.Пользователи


.Where(x => x.Id == id)


.Where(x => !x.IsDeleted)


.FirstOrDefaultAsync(ct);


// другие методы


В этом примере у нас есть типичный репозиторий с методом, который возвращает пользователей по идентификатору, а _db.Users возвращает IQueryable<User>. Итак, что нам нужно проверить здесь?


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

  1. Метод возвращает null, если пользователь с данным ID существует, но помечен как удаленный.

  1. Метод возвращает null, если пользователь с данным ID не существует.

Другими словами, все вызовы Where, OrderBy и других методов должны быть покрыты тестами. Теперь давайте напишем и обсудим первый тест (💡напоминание: структура теста была описана в предыдущей статье):


```csharp


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


открытый класс GetUser : UserRepositoryTestsBase


[Факт]


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


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


var ожидаемый результат = F.Build()


.With(x => x.IsDeleted, false)


.Создавать();


var allUsers = F.CreateMany().ToList();


allUsers.Add (ожидаемый результат);


Db.Users.Returns(allUsers.Shuffle().AsQueryable());


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


var result = await Repository.GetUser(expectedResult.Id);


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


Результат.Должен().Быть(ожидаемыйРезультат);


[Факт]


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


// Смотри ниже


[Факт]


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


// Смотри ниже


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


защищенное приспособление только для чтения F = new();


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


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


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


Db = Substitute.For();


Репозиторий = новый пользовательский репозиторий (Db);


Прежде всего, мы создали пользователя, соответствующего требованиям (не удаленного), и добавили его к группе других пользователей (со случайными разными идентификаторами и значениями IsDeleted). Затем мы имитировали источник данных, чтобы вернуть перетасованный набор данных. Обратите внимание, что мы перетасовали список пользователей, чтобы поместить ожидаемый результат в случайную позицию. Наконец, мы вызвали Repository.GetUser и проверили результат.


Shuffle() — небольшой, но полезный метод расширения:


```csharp


общедоступный статический класс EnumerableExtensions


частный статический только для чтения Random _randomizer = new();


public static T GetRandomElement(эта коллекция ICollection)


вернуть collection.ElementAt(_randomizer.Next(коллекция.Count));


открытый статический IEnumerable Shuffle (это объекты IEnumerable)


вернуть objects.OrderBy(_ => Guid.NewGuid());


Второй тест практически идентичен первому.


```csharp


[Факт]


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


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


var testUser = F.Build<Пользователь>()


.With(x => x.IsDeleted, правда)


.Создавать();


var allUsers = F.CreateMany().ToList();


allUsers.Добавить (тестовый пользователь);


Db.Users.Returns(allUsers.Shuffle().AsQueryable());


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


var result = await Repository.GetUser(testUser.Id);


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


результат.Должен().BeNull();


Здесь мы помечаем нашего пользователя как удаленного и проверяем, что результат равен null.


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


```csharp


[Факт]


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


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


var allUsers = F.CreateMany().ToList();


var userId = F.CreateIntNotIn(allUsers.Select(x => x.Id).ToList());


Db.Users.Returns(allUsers.Shuffle().AsQueryable());


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


var result = await Repository.GetUser(userId);


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


результат.Должен().BeNull();


CreateIntNotIn() — еще один полезный метод, часто используемый в тестах:


```csharp


public static int CreateIntNotIn(это Fixture f, ICollection кроме)


var maxValue = кроме.Количество * 2;


вернуть Enumerable.Range(1, maxValue)


.Кроме (кроме)


.Составлять список()


.ПолучитьСлучайныйЭлемент();


Запустим наши тесты:


Результаты теста LINQ


✅ Выглядит достаточно зеленым, так что давайте перейдем к следующему примеру.


Тестирование сопоставлений (AutoMapper)


Нужны ли нам вообще тесты для отображений?


Несмотря на то, что многие разработчики утверждают, что это скучно или это пустая трата времени, я считаю, что модульное тестирование отображений играет ключевую роль в процессе разработки по следующим причинам:


  1. Легко не заметить небольшие, но важные различия в типах данных. Например, когда свойство класса A имеет тип DateTimeOffset, а соответствующее свойство класса B имеет тип DateTime. Отображение по умолчанию не приведет к сбою, но даст неверный результат.

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

  1. Опечатки и разное написание. Все мы люди и часто не замечаем опечаток, что, в свою очередь, может привести к некорректным результатам отображения. Пример:

```csharp


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


публичная строка StackTrace { получить; набор; }


общедоступная строка SerializedException {получить; набор; }


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


общедоступная строка StackTrace { получить; набор; }


публичная строка SerializedException {получить; набор; }


открытый класс ErrorMappings: профиль


публичные сопоставления ошибок ()


CreateMap();


Довольно легко упустить из виду проблему разного написания в коде выше, и Rider/Resharper тоже не поможет, потому что и Serialized, и Serialised выглядят нормально. В этом случае маппер всегда будет устанавливать целевое свойство в null, что определенно нежелательно.


Надеюсь, мне удалось убедить вас и доказать ценность юнит-тестов для отображений, так что давайте перейдем к следующему примеру. Мы собираемся использовать AutoMapper, но с точки зрения тестирования выбор картографа не имеет значения. Например, мы можем заменить AutoMapper на Mapster, и это никак не повлияет на наши тесты. Более того, существующие тесты покажут, был ли наш рефакторинг сопоставления успешным или нет, что является одним из пунктов наличия модульных тестов 🙂


Скажем, у нас есть эти сущности:


```csharp


Пользователь открытого класса


публичный идентификатор {получить; в этом; }


публичная строка Имя { получить; набор; }


общедоступная строка Фамилия { получить; набор; }


общедоступная строка Электронная почта {получить; набор; }


общедоступная строка Пароль { получить; набор; }


общественное логическое значение IsAdmin { получить; набор; }


общественное логическое значение IsDeleted { получить; набор; }


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


публичный идентификатор {получить; в этом; }


общедоступная строка Имя {получить; набор; }


общедоступная строка Электронная почта {получить; набор; }


общественное логическое значение IsAdmin { получить; набор; }


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


публичный идентификатор {получить; набор; }


публичный идентификатор пользователя {получить; набор; }


общественный DateTimeOffset CreatedAt { получить; набор; }


общедоступная строка Текст { получить; набор; }


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


публичный идентификатор {получить; набор; }


публичный идентификатор пользователя {получить; набор; }


общественный DateTimeOffset CreatedAt { получить; набор; }


общедоступная строка Текст { получить; набор; }


публичный класс Комментарий


публичный идентификатор {получить; набор; }


общественный интервал BlogId { получить; набор; }


публичный идентификатор пользователя {получить; набор; }


общественный DateTimeOffset CreatedAt { получить; набор; }


общедоступная строка Текст { получить; набор; }


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


публичный идентификатор {получить; набор; }


общественный интервал BlogId { получить; набор; }


публичный идентификатор пользователя {получить; набор; }


общественный DateTimeOffset CreatedAt { получить; набор; }


общедоступная строка Текст { получить; набор; }


И сопоставления:


```csharp


открытый класс MappingsSetup: профиль


общедоступные настройки сопоставлений ()


CreateMap<Пользователь, UserHttpResponse>()


.ForMember(x => x.Name, _ => _.MapFrom(x => $"{x.FirstName} {x.LastName}"));


CreateMap();


CreateMap();


Ничего особенно необычного: сопоставление для User >> UserHttpResponse немного изменено, в то время как два других являются инструкциями по умолчанию «сопоставить как есть». Давайте напишем тесты для нашего профиля сопоставления.


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


```csharp


общедоступный абстрактный класс MappingsTestsBase, где T: профиль, new()


защищенное приспособление только для чтения F;


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


публичные MappingsTestsBase()


F = новый прибор();


M = new MapperConfiguration(x => { x.AddProfile(); }).CreateMapper();


И наш первый тест для сопоставления User >> UserHttpResponse:


```csharp


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


открытый класс User_TO_UserHttpResponse : MappingsTestsBase


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


public void Should_map (источник пользователя)


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


результат var = M.Map(источник);


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


result.Name.Should().Be($"{source.FirstName} {source.LastName}");


result.Should().BeEquivalentTo(источник, _ => _.Excluding(x => x.FirstName)


.Excluding(x => x.LastName)


.Excluding(x => x.Password)


.Excluding(x => x.IsDeleted));


source.Should().BeEquivalentTo(результат, _ => _.Excluding(x => x.Name));


В этом тесте мы:


  1. Создайте случайный экземпляр класса «Пользователь».

  1. Сопоставьте его с типом UserHttpResponse.

  1. Проверьте свойство Name.

  1. Проверьте остальные свойства, сравнив resultsource и sourceresult (чтобы ничего не упустить). Обратите внимание, что мы исключаем все свойства, которых нет ни в одном из классов, вместо использования ExcludingMissingMembers(), который исключает свойства с опечатками и отличным написанием (тест не сможет обнаружить SerializedException и SerializedException проблема).

Тесты сопоставления по умолчанию для классов с одинаковыми свойствами (например, BlogPost >> BlogPostDeletedEvent) можно написать более общим и элегантным способом:


```csharp


открытый класс SimpleMappings: MappingsTestsBase


[Теория]


[Данные класса (тип (MappingTestData))]


public void Should_map (тип источника, тип назначения)


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


var source = F.Create (исходный тип, новый SpecimenContext (F));


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


var result = M.Map (источник, тип источника, тип назначения);


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


результат.Должен().BeEquivalentTo(источник);


частный класс MappingTestData: IEnumerable


общедоступный IEnumerator<объект[]> GetEnumerator()


вернуть новый список<объект[]>


новый объект [] { typeof (BlogPost), typeof (BlogPostDeletedEvent)},


новый объект [] { typeof (комментарий), typeof (CommentDeletedEvent) }


.ПолучитьПеречислитель();


IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();


Возможно, вы заметили этот прекрасный атрибут [ClassData(typeof(MappingTestData))]. Это простой способ отделить тестовые данные, сгенерированные классом MappingTestData, от реализации теста. Как видите, добавление нового теста для нового сопоставления по умолчанию — это вопрос одной строки кода:


```csharp


вернуть новый список<объект[]>


новый объект [] { typeof (BlogPost), typeof (BlogPostDeletedEvent)},


новый объект [] { typeof (комментарий), typeof (CommentDeletedEvent) }


.ПолучитьПеречислитель();


Довольно круто, не правда ли?


Заключительные слова


Похоже, вы дочитали до этого места🎉 Надеюсь было не слишком скучно🙂


В любом случае, сегодня мы рассмотрели модульные тесты для LINQ и сопоставления, которые в сочетании с методами, описанными в предыдущем посте Лучшие методы написания модульных тестов правильным способом (часть 1), обеспечивают прочную основу и понимание ключевых принципов написания чистых, осмысленных и, что наиболее важно, полезных модульных тестов.


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



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