Кастомизация фабрик подделок

Определение правил для отдельных полей

ТК (Тестируемый Код) иногда ставит перед нами весьма нетривиальные задачи, чаще всего такие "особенные" ситуации связаны с недостатками самого ТК, но не всю бизнес-логику можно безболезненно скорректировать в ограниченное время, а потому приходится работать с тем, что имеем.

В иных случаях ТК корректен, но не может быть использован в рамках тестирования без дополнительных настроек. Может случиться, что некоторая модель содержит весьма специфическое поле DbGeography, которое AutoFixture не умеет подделывать. Возможно, что в одном из полей БД хранится json какого-то объекта в виде string... Такие ситуации могли бы доставить много боли, но, к счастью, у нас есть возможность кастомизировать механизм создания подделок.

Рассмотрим для примера три ситуации:

  • Необходимо, чтобы все экземпляры класса User имели поле Name == "John";

  • Класс Farm содержит поле Farmer типа string, представляющее собой сериализованный объект типа Farmer;

  • Класс Country содержит поле Polygon типа DbGeography;

Внутри нашего FakeFactory в произвольном месте добавим

    static FakeFactory()
    {
       // ...
       Fixture.Customize<User>(s => s.With(f1 => f1.Name, "John"));
       Fixture.Customize<Farm>(c => c.With(f1 => f1.Farmer, JsonSerializer.Serialize(Fixture.Create<Farmer>())));
       Fixture.Customize<Country>(c => c.With(f1 => f1.Polygon, DbGeography.PointFromText("POINT(0 0)", 4326)));
       // ...
    }

Если вам не понятно о каком FakeFactory идет речь, стоит обратиться к статье о подделке объектов.

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

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

Ситуация станет еще сложнее, если кто-то решит использовать знание об этих настройках в конкретном тесте. Ни читать, ни поддерживать такой тест станет совершенно невозможно.

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

Пример с User выглядит совершенно надуманным - мне сложно представить бизнес-логику, требующую подобного. Если же такое значение нужно для одного/двух тестов, то гораздо разумнее устанавливать требуемое значение внутри тестов, не заставляя коллег участвовать в квесте "Угадай откуда берется значение".

Сериализованный Farmer также, вероятно, выглядит оверкиллом. Но, дабы не наполнять тесты, которые не проверяют работу с этим полем, лишними установками, мы можем пойти на подобную оптимизацию. Однако, тестируя работу приложения, связанную с этим полем, мы всегда должны задавать его напрямую внутри теста.

Решение с DbGeography выглядит совершенно оправданным. Без этой настройки ни один тест с подделкой Country попросту не будет работать. Однако, и здесь мы исходим из того, что задаваемое значение не будет использовано ни одним тестом - тестируя работу с полем Polygon мы будем устанавливать значение напрямую внутри теста.

Inline фабрики объектов

При желании мы можем настроить не только создание отдельных полей объекта, но и создать полноценную фабрику. В качестве примера рассмотрим класс Student с полями Name и Age.

    static FakeFactory()
    {
       // ...
       Fixture.Customize<Student>(c => c.FromFactory(() => new Student{Age = 22, Name = "Name"}));
       // ...
    }

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

Абстрактные классы

Мы не можем создать экземпляр абстрактного класса в C#, AutoFixture, закономерно, также не способен справиться с подобной задачей, потому нам необходимо поменять механизм создания подделок, указав конкретный класс для создания.

Предположим, что у нас есть следующие классы:

    public class AbstractStudent
    {
       public string Name { get; set; }
    }
    
    public class Student : AbstractStudent
    {       
    }

Переопределим функцию создания AbstractStudent

    static FakeFactory()
    {
       // ...
       Fixture.Register<AbstractStudent>(() => Fixture.Create<Student>());
       // ...
    }

С этого момента везде где потребуется поддельный экземпляр AbstractStudent AutoFixture создаст для нас экземпляр Student. И вновь повторю основную идею: эта настройка - лишь заплатка, обеспечивающая работоспособность кода. Всякий раз тестируя логику работы с AbstractStudent мы будем создавать экземпляры вручную внутри теста, дабы избежать проблему читабельности тестов.

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

Рассуждая о подделке объектов мы уже избавились от угрозы циклических зависимостей:

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

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

Размер коллекций

Стандартный размер коллекций, создаваемых AutoFixture, составляет три элемента и данная установка покрывает 99,(9)% сценариев тестирования в любом проекте. Если по воле бизнес-логики отдельный тест нуждается в ином размере коллекции, я бы рекомендовал создавать подходящую коллекцию внутри конкретного теста с помощью CreateMany:

    [Fact]
    public void Some_Test()
    {
       var numbers = FakeFactory.Fixture.CreateMany<int>(25);
       // ...
    }   

В сети достаточно примеров настройки размера коллекции, я же не буду приводить здесь ни одного, ибо считаю подобные подходы весьма порочными; имея проект с 1000+ тестов, менять глобальную настройку подобного рода ради 10-15 из них означает ставить под удар весь тестовый проект. Чем больше ресурсов и времени требует запуск тестов, тем выше вероятность того, что в какой-то момент они станут мешать комфортному процессу разработки, а это явно не то, чего бы мы хотели добиться.

Использовать ли Build?

Общая рекомендация - не использовать эту конструкцию совсем. Рассмотрим два варианта подготовки данных в тесте:

    [Fact]
    public void Some_Test()
    {
       var student = FakeFactory.Fixture.Build<Student>().With(s => s.Name, "John");
       // ...
    }   
    [Theory]
    [AutoMoqData]
    public void Some_Test(Student student)
    {
       student.Name = "John";
       // ...
    }   

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

FromSeed

И снова начнем с примера:

   var someString = FakeFactory.Fixture.Build<string>().FromSeed(s => "some string").Create();
   var otherString = "other string";

Пример с примитивами, пожалуй, выглядит чересчур нелепо... Однако, подставь мы в эту конструкцию некоторый объект, ситуация мало поменяется. Зачем нам нужна эта фабрика прямо внутри теста?

  • Чтобы использовать статически заданный объект при всех генерациях? Не проще ли в таком случае формировать такой объект в конструкторе тестового класса? Но даже так это выглядит сомнительно - что за код требует статически заданного объекта? Одно-два поля мы можем переопределить внутри теста и это нормально, но целый объект... Вероятно, стоит задуматься об изменениях в тестируемой системе.

  • Чтобы задать значения отдельных полей? И снова: они устанавливаются внутри конкретного теста. Проходим мимо - этот код слишком сомнительно пахнет для промышленного использования.

P.S.

В качестве заключительного комментария, замечу, что высказанное выше мнение о вредности/бесполезности отдельных конструкций AutoFixture не является абсолютом. В некоторых случаях разработчики могут прибегать к использованию сомнительных стратегий создания объектов, однако, это требует весьма высокого уровня понимания процесса тестирования и применимо только в весьма редких случаях. Данная же статья рассчитана на массового читателя и описанные запреты призваны уберечь его от массового применения сложных/сомнительных/редко применимых подходов.