В предыдущей статье, Unit Tests Done Right (часть 1), мы рассмотрели некоторые из лучших практик модульного тестирования, а затем составили список обязательных библиотек, которые значительно улучшают качество тестов. Однако в нем не были рассмотрены некоторые распространенные сценарии, такие как тестирование LINQ и связок, поэтому я решил восполнить этот пробел еще одной статьей, основанной на примерах.
Готовы и дальше совершенствовать свои навыки юнит-тестирования? Давайте начнем🍿
Примеры в этой статье широко используют замечательные инструменты, описанные в предыдущем посте, поэтому будет хорошей идеей начать с первой части, чтобы код, который мы будем анализировать, имел больше смысла.
Тестирование LINQ
Всем разработчикам C# нравится LINQ, но мы должны относиться к нему с уважением и покрывать запросы тестами. Кстати, это одно из многих преимуществ LINQ перед SQL (вы когда-нибудь видели реального человека, который написал хотя бы один юнит-тест для SQL? Я тоже не видел).
Давайте посмотрим на пример.
public class UserRepository : IUserRepository
{
private readonly IDb _db;
public UserRepository(IDb db)
{
_db = db;
}
public Task<User?> GetUser(int id, CancellationToken ct = default)
{
return _db.Users
.Where(x => x.Id == id)
.Where(x => !x.IsDeleted)
.FirstOrDefaultAsync(ct);
}
// other methods
}
В этом примере мы имеем типичный репозиторий с методом, который возвращает пользователей по ID, и _db.Users
возвращает IQueryable<User>
. Итак, что нам нужно проверить?
- Мы хотим убедиться, что этот метод возвращает пользователя по ID, если он не был удален.
- Метод возвращает
null
, если пользователь с заданным ID существует, но помечен как удаленный. - Метод возвращает
null
, если пользователь с заданным ID не существует.
Другими словами, все вызовы методов Where
, OrderBy
и других должны быть покрыты тестами. Теперь давайте напишем и обсудим первый тест (💡 напоминаю, что структура тестов была описана в предыдущей статье):
public class UserRepositoryTests
{
public class GetUser : UserRepositoryTestsBase
{
[Fact]
public async Task Should_return_user_by_id_unless_deleted()
{
// arrange
var expectedResult = F.Build<User>()
.With(x => x.IsDeleted, false)
.Create();
var allUsers = F.CreateMany<User>().ToList();
allUsers.Add(expectedResult);
Db.Users.Returns(allUsers.Shuffle().AsQueryable());
// act
var result = await Repository.GetUser(expectedResult.Id);
// assert
result.Should().Be(expectedResult);
}
[Fact]
public async Task Should_return_null_when_user_is_deleted()
{
// see below
}
[Fact]
public async Task Should_return_null_when_user_doesnt_exist()
{
// see below
}
}
public abstract class UserRepositoryTestsBase
{
protected readonly Fixture F = new();
protected readonly UserRepository Repository;
protected readonly IDb Db;
protected UserRepositoryTestsBase()
{
Db = Substitute.For<IDb>();
Repository = new UserRepository(Db);
}
}
}
Прежде всего, мы создали пользователя, соответствующего требованиям (не удаленного), и добавили его к куче других пользователей (со случайными разными ID и значениями IsDeleted
). Затем мы имитировали источник данных, чтобы вернуть перетасованный набор данных. Обратите внимание, что мы перетасовали список пользователей, чтобы поместить expectedResult
в случайную позицию. Наконец, мы вызвали Repository.GetUser
и проверили результат.
Shuffle()
— это небольшой, но полезный метод расширения:
public static class EnumerableExtensions
{
private static readonly Random _randomizer = new();
public static T GetRandomElement<T>(this ICollection<T> collection)
{
return collection.ElementAt(_randomizer.Next(collection.Count));
}
public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> objects)
{
return objects.OrderBy(_ => Guid.NewGuid());
}
}
Второй тест практически идентичен первому.
[Fact]
public async Task Should_return_null_when_user_is_deleted()
{
// arrange
var testUser = F.Build<User>()
.With(x => x.IsDeleted, true)
.Create();
var allUsers = F.CreateMany<User>().ToList();
allUsers.Add(testUser);
Db.Users.Returns(allUsers.Shuffle().AsQueryable());
// act
var result = await Repository.GetUser(testUser.Id);
// assert
result.Should().BeNull();
}
Здесь мы помечаем нашего пользователя как удаленного и проверяем, что результат null
.
Для последнего теста мы генерируем список случайных пользователей и уникальный ID, который не принадлежит ни одному из них:
[Fact]
public async Task Should_return_null_when_user_doesnt_exist()
{
// arrange
var allUsers = F.CreateMany<User>().ToList();
var userId = F.CreateIntNotIn(allUsers.Select(x => x.Id).ToList());
Db.Users.Returns(allUsers.Shuffle().AsQueryable());
// act
var result = await Repository.GetUser(userId);
// assert
result.Should().BeNull();
}
CreateIntNotIn()
— еще один полезный метод, часто используемый в тестах:
public static int CreateIntNotIn(this Fixture f, ICollection<int> except)
{
var maxValue = except.Count * 2;
return Enumerable.Range(1, maxValue)
.Except(except)
.ToList()
.GetRandomElement();
}
Давайте запустим наши тесты:
Выглядит достаточно зелено, так что перейдем к следующему примеру.
Тестирование отображений (AutoMapper)
Нужны ли нам вообще тесты для маппинга?
Несмотря на то, что многие разработчики утверждают, что это скучно или пустая трата времени, я считаю, что модульное тестирование связок играет ключевую роль в процессе разработки по следующим причинам:
- Легко упустить из виду небольшие, но важные различия в типах данных. Например, когда свойство класса A имеет тип
DateTimeOffset
, а соответствующее свойство класса B имеет типDateTime
. Сопоставление по умолчанию не приведет к сбою, но даст неверный результат. - Новые или удаленные свойства. С тестами отображения, всякий раз, когда мы рефакторим один из классов, невозможно забыть изменить другой (потому что хорошо написанные тесты не пройдут).
- Опечатки и другое написание. Мы все люди и часто не замечаем опечаток, что, в свою очередь, может привести к неправильным результатам отображения. Пример:
public class ErrorInfo
{
public string StackTrace { get; set; }
public string SerializedException { get; set; }
}
public class ErrorOccurredEvent
{
public string StackTrace { get; set; }
public string SerialisedException { get; set; }
}
public class ErrorMappings : Profile
{
public ErrorMappings()
{
CreateMap<ErrorInfo, ErrorOccurredEvent>();
}
}
Довольно легко не заметить проблему разного написания в приведенном выше коде, и Rider / Resharper также не поможет с этим, потому что и Seriali z ed, и Seriali s ed выглядят нормально. В этом случае маппер всегда будет устанавливать свойство target в null
, что определенно нежелательно.
Надеюсь, мне удалось убедить вас и доказать ценность модульных тестов для маппинга, так что давайте перейдем к следующему примеру. Мы будем использовать AutoMapper, но с точки зрения тестирования выбор маппера не имеет никакого значения. Например, мы можем заменить AutoMapper на Mapster, и это никак не повлияет на наши тесты. Более того, существующие тесты покажут, был ли наш рефакторинг отображения успешным или нет, что является одним из пунктов наличия юнит-тестов 🙂 .
Допустим, у нас есть эти сущности:
public class User
{
public int Id { get; init; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public bool IsAdmin { get; set; }
public bool IsDeleted { get; set; }
}
public class UserHttpResponse
{
public int Id { get; init; }
public string Name { get; set; }
public string Email { get; set; }
public bool IsAdmin { get; set; }
}
public class BlogPost
{
public int Id { get; set; }
public int UserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Text { get; set; }
}
public class BlogPostDeletedEvent
{
public int Id { get; set; }
public int UserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Text { get; set; }
}
public class Comment
{
public int Id { get; set; }
public int BlogId { get; set; }
public int UserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Text { get; set; }
}
public class CommentDeletedEvent
{
public int Id { get; set; }
public int BlogId { get; set; }
public int UserId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string Text { get; set; }
}
И отображения:
public class MappingsSetup : Profile
{
public MappingsSetup()
{
CreateMap<User, UserHttpResponse>()
.ForMember(x => x.Name, _ => _.MapFrom(x => $"{x.FirstName} {x.LastName}"));
CreateMap<BlogPost, BlogPostDeletedEvent>();
CreateMap<Comment, CommentDeletedEvent>();
}
}
Ничего особенно причудливого: маппинг для User
>> UserHttpResponse
немного подстроен, а два других — это стандартные инструкции «map as is». Давайте напишем тесты для нашего профиля отображения.
Для начала, вот базовый класс, который вы можете использовать для всех модульных тестов для маппинга:
public abstract class MappingsTestsBase<T> where T : Profile, new()
{
protected readonly Fixture F;
protected readonly IMapper M;
public MappingsTestsBase()
{
F = new Fixture();
M = new MapperConfiguration(x => { x.AddProfile<T>(); }).CreateMapper();
}
}
И наш первый тест для маппинга User
>> UserHttpResponse
:
public class MappingsTests
{
public class User_TO_UserHttpResponse : MappingsTestsBase<MappingsSetup>
{
[Theory, AutoData]
public void Should_map(User source)
{
// act
var result = M.Map<UserHttpResponse>(source);
// assert
result.Name.Should().Be($"{source.FirstName} {source.LastName}");
result.Should().BeEquivalentTo(source, _ => _.Excluding(x => x.FirstName)
.Excluding(x => x.LastName)
.Excluding(x => x.Password)
.Excluding(x => x.IsDeleted));
source.Should().BeEquivalentTo(result, _ => _.Excluding(x => x.Name));
}
}
}
В этом тесте мы:
- Генерируем случайный экземпляр класса
User
. - Сопоставляем его с типом
UserHttpResponse
. - Проверьте свойство
Name
. - Проверьте остальные свойства, сравнивая
result
source
иsource
result
(чтобы ничего не пропустить). Обратите внимание, что мы исключаем каждое свойство, которое не присутствует ни в одном из классов, вместо того, чтобы использоватьExcludingMissingMembers()
, которая исключает свойства с опечатками и отличным написанием (тест не сможет обнаружить проблемуSerializedException
vsSerialisedException
).
Тесты отображения по умолчанию для классов с одинаковыми свойствами (например, BlogPost
>> BlogPostDeletedEvent
) могут быть написаны более общим и элегантным способом:
public class SimpleMappings : MappingsTestsBase<MappingsSetup>
{
[Theory]
[ClassData(typeof(MappingTestData))]
public void Should_map(Type sourceType, Type destinationType)
{
// arrange
var source = F.Create(sourceType, new SpecimenContext(F));
// act
var result = M.Map(source, sourceType, destinationType);
// assert
result.Should().BeEquivalentTo(source);
}
private class MappingTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
return new List<object[]>
{
new object[] { typeof(BlogPost), typeof(BlogPostDeletedEvent) },
new object[] { typeof(Comment), typeof(CommentDeletedEvent) }
}
.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
Вы, возможно, заметили, что прекрасный атрибут [ClassData(typeof(MappingTestData))]
. Это чистый способ отделить тестовые данные, генерируемые классом MappingTestData
, от реализации теста. Как видите, добавление нового теста для нового отображения по умолчанию — дело одной строки кода:
return new List<object[]>
{
new object[] { typeof(BlogPost), typeof(BlogPostDeletedEvent) },
new object[] { typeof(Comment), typeof(CommentDeletedEvent) }
}
.GetEnumerator();
Очень круто, не правда ли?
Заключительные слова
Похоже, вы дочитали до конца🎉 Надеюсь, это было не слишком скучно🙂
В любом случае, сегодня мы разобрали модульные тесты для LINQ и маппингов, которые в сочетании с техниками, описанными в предыдущем посте Unit Tests Done Right (Part 1), обеспечивают прочную основу и понимание ключевых принципов написания чистых, осмысленных, а главное, полезных модульных тестов.
Будьте здоровы!