Правильные модульные тесты (часть 2)

В предыдущей статье, 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>. Итак, что нам нужно проверить?

  1. Мы хотим убедиться, что этот метод возвращает пользователя по ID, если он не был удален.
  2. Метод возвращает null, если пользователь с заданным ID существует, но помечен как удаленный.
  3. Метод возвращает 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)

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

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

  1. Легко упустить из виду небольшие, но важные различия в типах данных. Например, когда свойство класса A имеет тип DateTimeOffset, а соответствующее свойство класса B имеет тип DateTime. Сопоставление по умолчанию не приведет к сбою, но даст неверный результат.
  2. Новые или удаленные свойства. С тестами отображения, всякий раз, когда мы рефакторим один из классов, невозможно забыть изменить другой (потому что хорошо написанные тесты не пройдут).
  3. Опечатки и другое написание. Мы все люди и часто не замечаем опечаток, что, в свою очередь, может привести к неправильным результатам отображения. Пример:
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));
        }
    }
}

Вход в полноэкранный режим Выход из полноэкранного режима

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

  1. Генерируем случайный экземпляр класса User.
  2. Сопоставляем его с типом UserHttpResponse.
  3. Проверьте свойство Name.
  4. Проверьте остальные свойства, сравнивая resultsource и sourceresult (чтобы ничего не пропустить). Обратите внимание, что мы исключаем каждое свойство, которое не присутствует ни в одном из классов, вместо того, чтобы использовать ExcludingMissingMembers(), которая исключает свойства с опечатками и отличным написанием (тест не сможет обнаружить проблему SerializedException vs SerialisedException).

Тесты отображения по умолчанию для классов с одинаковыми свойствами (например, 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), обеспечивают прочную основу и понимание ключевых принципов написания чистых, осмысленных, а главное, полезных модульных тестов.

Будьте здоровы!

Оцените статью
Procodings.ru
Добавить комментарий