Рекомендации по правильному написанию модульных тестов (часть 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
вернуть _db.Пользователи
.Where(x => x.Id == id)
.Where(x => !x.IsDeleted)
.FirstOrDefaultAsync(ct);
// другие методы
В этом примере у нас есть типичный репозиторий с методом, который возвращает пользователей по идентификатору, а _db.Users
возвращает IQueryable<User>
. Итак, что нам нужно проверить здесь?
- Мы хотим убедиться, что этот метод возвращает пользователя по ID, если он не был удален.
- Метод возвращает
null
, если пользователь с данным ID существует, но помечен как удаленный.
- Метод возвращает
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
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
вернуть collection.ElementAt(_randomizer.Next(коллекция.Count));
открытый статический 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
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
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)
.Кроме (кроме)
.Составлять список()
.ПолучитьСлучайныйЭлемент();
Запустим наши тесты:
✅ Выглядит достаточно зеленым, так что давайте перейдем к следующему примеру.
Тестирование сопоставлений (AutoMapper)
Нужны ли нам вообще тесты для отображений?
Несмотря на то, что многие разработчики утверждают, что это скучно или это пустая трата времени, я считаю, что модульное тестирование отображений играет ключевую роль в процессе разработки по следующим причинам:
- Легко не заметить небольшие, но важные различия в типах данных. Например, когда свойство класса A имеет тип DateTimeOffset, а соответствующее свойство класса B имеет тип DateTime. Отображение по умолчанию не приведет к сбою, но даст неверный результат.
- Новые или удаленные свойства. С тестами сопоставления всякий раз, когда мы рефакторим один из классов, невозможно забыть изменить другой (потому что хорошо написанные тесты не пройдут).
- Опечатки и разное написание. Все мы люди и часто не замечаем опечаток, что, в свою очередь, может привести к некорректным результатам отображения. Пример:
```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
защищенное приспособление только для чтения F;
защищенный IMapper M только для чтения;
публичные MappingsTestsBase()
F = новый прибор();
M = new MapperConfiguration(x => { x.AddProfile
И наш первый тест для сопоставления 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));
В этом тесте мы:
- Создайте случайный экземпляр класса «Пользователь».
- Сопоставьте его с типом
UserHttpResponse
.
- Проверьте свойство Name.
- Проверьте остальные свойства, сравнив
result
≡source
иsource
≡result
(чтобы ничего не упустить). Обратите внимание, что мы исключаем все свойства, которых нет ни в одном из классов, вместо использования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), обеспечивают прочную основу и понимание ключевых принципов написания чистых, осмысленных и, что наиболее важно, полезных модульных тестов.
Ваше здоровье!
Оригинал