Подделка объектов

В предыдущей статье мы рассмотрели варианты обеспечения тестирующего кода данными. Теперь же рассмотрим решение данной проблемы с помощью библиотеки Autofixture.

Не вдаваясь в детали работы самой библиотеки, ограничимся лишь тем, что для создания подделок используется механизм reflection. Естественно, такой подход на практике работает гораздо медленнее, чем определение значений на этапе компиляции, но в большинстве случаев он экономит столько времени при написании тестов, что замедление прогона становится несущественной мелочью. К тому же, речь не идет о катастрофическом падении производительности, - запуская по 300-400 тестов вы не будете замечать особой разницы.

Циклические зависимости

С чего же начать? Прежде всего хочу обратить внимание на возможность существования циклических связей внутри нашей модели данных. Даже банальная связь один ко многим, реализованная в моделях, уже содержит бесконечный цикл, если мы подходим к ней с позиции создания объекта через reflection. Так внутри класса User мы можем прописать поле Role, а внутри Role – коллекцию User. В такой ситуации создание роли приведет к необходимости создать список пользователей, и у каждого должна быть своя роль, которую тоже стоит создать…

И даже если таких зависимостей нет в проекте в данный момент, они могут появиться хоть завтра. К тому же тщательное изучение всех возможных связей между классами… не выглядит тем, на что хочется потратить время когда перед нами стоит простая задача поднять тестирующий проект. Потому я обыкновенно, реализую настройку запрета рекурсии сразу, не углубляясь в детали. Заодно сводя всю работу поддельной фабрики в один статический класс, дабы не плодить лишние объекты.

public static class FakeFactory
 {
    public static readonly Fixture Fixture = new Fixture();

    static FakeFactory()
    {
       Fixture.Behaviors.OfType<ThrowingRecursionBehavior>().ToList()
          .ForEach(b => Fixture.Behaviors.Remove(b));
       Fixture.Behaviors.Add(new OmitOnRecursionBehavior());
    }
 }

Если ваша тонкая душевная организация страдает от статических объектов с открытыми свойствами, вы вольны организовать что-нибудь более изящное…

Главную скрипку у нас играет экземпляр класса Fixture, который и сделает за нас всю работу по подделке объектов.

Происходящее внутри статического конструктора очевидно из названий методов, но все же… Во-первых, мы убираем выброс исключений при встрече с циклическими зависимостями при построении объектов, а во-вторых, запрещаем уходить в циклические зависимости. Последний пункт может быть настроен и можно указать сколько уровней циклической зависимости может быть реализовано.

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

Подделка на практике

Возьмем для примера класс Article:

public class ArticleModel
 {
    public int Id { get; set; }
    public string Text { get; set; }
    public string Preview { get; set; }
    public string Title { get; set; }
    public DateTime Created { get; set; }
    public bool Published { get; set; }
 }

и один метод в ArticleRepository:

public class ArticleRepository : IArticleRepository
 {
    public async Task<bool> Delete(int id)
    {
       var entity = await _context.Articles.FirstOrDefaultAsync(e => e.Id == id);
       if (entity == null) return false;
       _context.Articles.Remove(entity);
       await _context.SaveChangesAsync();
       return true;
    }
 }

Наша задача – протестировать удаление объекта из БД. Но, перефразируя одного известного кота, чтобы удалить что-нибудь ненужное, надо иметь что-нибудь ненужное, а потому мы должны прежде создать новую сущность в виртуальной базе данных. Рассмотрим этот подход на примере.

 public class ArticleRepositoryTests
 {
    private readonly DataContext _context;
    private readonly ArticleRepository _repository;

    public ArticleRepositoryTests()
    {
       _context = ContextFactory.GetContext();
       _repository = new ArticleRepository(_context);
    }

    [Fact]
    public async Task Delete()
    {
       var article = FakeFactory.Fixture.Create<Articlе>();
       _context.Articles.Add(article);
       _context.SaveChanges();
       //
       var result = await _repository.Delete(article.Id);
       //
       var expectedNull = _context.Articles.FirstOrDefault(e => e.Id == article.Id);
       Assert.Null(expectedNull);
       Assert.True(result);
    }
 }

При этом, если мы посмотрим на значения объекта article, то увидим примерно следующую картину:

Id = 78
Title = "Titleaa72344b-e0c2-4c9f-89c4-e05937f6ad72"
Text = "Text79ec47ea-83e3-4bfc-8902-91552d4eb931"
Created = 04.07.2020 15:25:52
Published = true
Preview = "Preview3578cc17-96c6-4b2a-ad91-e7a5c2eb228e"

Все значения определены, нигде нет пустых, дефолтных или null значений. Если нам для работы конкретного теста нужно, чтобы дата создания (Created) была непременно меньше текущей, то мы просто вводим определение именно этого поля

article.Created = DateTime.Now.AddDays(-1);

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