Pull to refresh

Упрощаем юнит-тесты с помощью связки AutoFixture и xUnit

Reading time 7 min
Views 44K
Все мы знаем, что юнит-тесты — это классно, что только коду, который так или иначе покрыт тестами, можно доверять и что если какой-нибудь неопытный senior developer старший программист что-нибудь сломает, тесты это сразу же покажут.

Тем не менее, написание тестов сложно назвать увлекательным процессом. Инициализация тестовых данных, инициализация моков, создание объекта тестирования… Пока доберешься до вызова метода, который ты собственно хотел проверить, тестировать уже и не хочется ничего. Я конечно утрирую, юнит-тест по своей природе не должен брать на себя слишком много и содержать пару сотен десятков строк инициализации (хотя и такое бывает), однако писать один и тот же код быстро надоедает. И вот уже появляются фабрики тестовых объектов, иерархия базовых классов для тестов и прочие ООП примочки, призванные «упростить» создание теста. Приводит это как правило к тому, что шансов быстро понять, что же делает тест, не путешествуя по этим самым объектам, практически не остается.

Собственно, инструмент, о котором я хочу рассказать, как раз и призван упростить, а в некоторых случаях и полностью убрать фазу инициализации или Arrange фазу теста.

Рассмотрим вот такой простенький тест:

        [Fact]
        public void CanSaveEntity()
        {
            // Arrange
            var validEntity = new Entity() { Name = "Some Name", Type = Type.Simple, Group = new EntityGroup() { Name = "12345" } };

            var validationServiceMock = new Mock<IValidationService>();
            {
                validationServiceMock.Setup(svc => svc.IsValid(validEntity)).Returns(true);
            }
            
            var repositoryMock = new Mock<IRepository<Entity>>();
            var loggingServiceMock = new Mock<ILoggingService>();

            var sut = new ApplicationService(repositoryMock.Object, validationServiceMock.Object, loggingServiceMock.Object);

            // Act
            sut.SaveEntity(validEntity);

            // Assert
            repositoryMock.Verify(r => r.Add(validEntity), Times.Once);
        }

Даже в таком простом тесте у нас 6 строчек инициализации. Расчехляем AutoFixture и вуаля:

        [Theory, AutoMoqData]
        public void CanSaveEntity(Entity validEntity, [Frozen]Mock<IValidationService> validationServiceMock, [Frozen]Mock<IRepository<Entity>> repositoryMock, ApplicationService sut)
        {
            // Arrange
            validationServiceMock.Setup(svc => svc.IsValid(validEntity)).Returns(true);

            // Act
            sut.SaveEntity(validEntity);

            // Assert
            repositoryMock.Verify(r => r.Add(validEntity), Times.Once);
        }

Размер Arrange фазы уменьшился в 6 раз, осталось только разобраться в том, что здесь происходит.

Инструмент №1 AutoFixture


В двух словах, AutoFixture умеет создавать экземпляры разных, в общем случае сколь угодно сложных типов. Примитивные типы создаются с помощью встроенных генераторов (например, строка это Guid.NewGuid с префиксом). Для пользовательских типов выбирается конструктор с минимальным количеством параметров, значение для каждого параметра рекурсивно генерится тем же AutoFixture. В дополнение к этому AutoFixture по умолчанию вызовет все публичные сеттеры свойств класса, в качестве значений передав опять же сгенерированные самим собой объекты. Тем самым на выходе мы получаем объект, заполненный некими случайными значениями. Выглядит подозрительно: впрочем, в большинстве случаев в качестве тестовых данных и используются некие случайные значения вида «TestName» и 42.

        [Fact]
        public void Generate()
        {
            // Old school
            var validEntity = new Entity() { Name = "Some Name", Type = Type.Simple, Group = new EntityGroup() { Name = "42" } };
            
            // AutoFixture
            var someEntity = new Fixture().Create<Entity>();
        }

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

        [Fact]
        public void Configure()
        {
            // Customize build
            var someEntity = new Fixture().Build<Entity>().With(e => e.Name, "Important For Test").Without(e => e.Group).Create();
        }

Еще одним важным преимуществом такого подхода является устойчивость тестов к изменениям объектов. Например, если в конструктор объекта Entity добавить пару параметров, то без AutoFixture нам нужно будет пробежаться по сотне-другой тестов, поправив конструктор в каждой из них (ну или использовать Resharper). Если же мы используем AutoFixture, то можно вообще ничего не делать (конечно, если значения этих параметров не важны для всех этих тестов).

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

Все это выглядит очень здорово, но всё еще достаточно многословно. Для простых объектов вызов конструктора будет короче, чем конструкция AutoFixture. Тут то нам на помощь и приходят xUnit data theories.

Инструмент №2 xUnit data theories


Опять же в двух словах, теории в xUnit отличаются от фактов тем, что метод теста можно параметризовать, а через атрибут теста так или иначе рассказать, с какими параметрами будет вызван тест. В простом случае можно просто передать значения явно, например так:

        [Theory]
        [InlineData(42)]
        [InlineData(3300)]
        public void Inline(int value)
        {
            Assert.True(value > 0);
        }

Но в комбинации с AutoFixture, который, как мы уже знаем, может генерировать что угодно, теории становятся еще мощнее, позволяя нам просто указать список входящих данных в параметрах теста вместо того, чтобы создавать их в Arrange фазе:

        [Theory, AutoData]
        public void TestEntity(Entity testEntity)
        {
            Assert.NotNull(testEntity);
        }

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

        [Theory, AutoData]
        public void TestEntityWithoutProperties([NoAutoProperties]Entity testEntity)
        {
            Assert.Null(testEntity.Group);
        }

Например с помощью атрибута NoAutoProperties можно запретить инициализацию свойств для testEntity.
Отлично! Arrange фаза всё меньше и меньше, время разобраться с громоздким кодом создания моков. И тут у AutoFixture есть решение.

Инструмент №3 AutoFixture + AutoMoqCustomization


Я уже упоминал, что AutoFixture позволяет себя всячески настраивать. AutoMoqCustomization это одна из настроек, которая «учит» AutoFixture делать простую вещь: если ни одним из способов не удалось создать требуемый экземпляр и тип объекта является либо интерфейсом, либо абстрактным классом, то AutoFixture создает мок, используя Moq. Звучит классно, создаем сервис через AutoFixture и все его зависимости уже проинициализированы. Одна проблема, иногда моки нужно настраивать и проверять, что там было вызвано. Где взять экземпляр мока собственно? Здесь на помощь приходит еще один механизм AutoFixture, называемый Freeze. Суть его в том, чтобы запомнить какое-то определенное значение для типа объекта и возвращать именно его (вместо генерации нового) каждый раз, когда он потребуется при генерации других объектов.
К примеру, в этом тесте:

        [Fact]
        public void Freeze()
        {
            var fixture = new Fixture();

            fixture.Freeze<int>(42);

            var value = fixture.Create<int>();

            Assert.Equal(42, value);
        }

Мы говорим, что если AutoFixture понадобится int для создания объекта, нужно каждый раз использовать именно 42.
В случае с моками мы используем Freeze на моке, и далее при создании объектов этот мок будет использоваться всегда, когда он потребуется:

        [Fact]
        public void FreezeMock()
        {
            // Arrange
            var fixture = new Fixture().Customize(new AutoMoqCustomization());

            var validEntity = fixture.Create<Entity>();

            var repositoryMock = fixture.Freeze<Mock<IRepository<Entity>>>();
            var validationServiceMock = fixture.Freeze<Mock<IValidationService>>();
            {
                validationServiceMock.Setup(svc => svc.IsValid(validEntity)).Returns(true);
            }

            var sut = fixture.Create<ApplicationService>();

            // Act
            sut.SaveEntity(validEntity);

            // Assert
            repositoryMock.Verify(r => r.Add(validEntity), Times.Once);
        }

Снова замечу, что с таким подходом добавление новой зависимости в конструктор класса не ведет за собой изменение всех тестов, где этот объект создавался.

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

Полный арсенал: AutoFixture + Moq + xUnit + data theories


Вот он тест из самого начала статьи, надеюсь, теперь более-менее понятно, что здесь происходит:

        [Theory, AutoMoqData]
        public void CanSaveEntity(Entity validEntity, [Frozen]Mock<IValidationService> validationServiceMock, [Frozen]Mock<IRepository<Entity>> repositoryMock, ApplicationService sut)
        {
            // Arrange
            validationServiceMock.Setup(svc => svc.IsValid(validEntity)).Returns(true);

            // Act
            sut.SaveEntity(validEntity);

            // Assert
            repositoryMock.Verify(r => r.Add(validEntity), Times.Once);
        }

Пара нюансов,
AutoMoqData — это наследник стандартного атрибута из AutoFixture AutoData, в котором применен AutoMoqCustomization.

Frozen — стандартный атрибут, который, как можно догадаться, просто вызывает Freeze на созданном объекте для параметра.

В итоге всё это работает как простой DI-контейнер, который к тому же умеет создавать зависимости, которые никто не объявил, по умолчанию.

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

P.S.


Немного про личный опыт: сам я наткнулся на AutoFixture относительно недавно, пролистывая список новых курсов PluralSite. Заинтересовался, заинтересовал коллег. Сначала попробовал без xUnit, потом перевел все тесты в проекте с MSUnit на xUnit, чтобы попробовать с xUnit, и теперь слабо представляю, как я раньше писал тесты без нее. Это как ReSharper, только AutoFixture — стоит попробовать и вскоре уже слабо представляешь, как ты раньше без этого жил.

Ссылки


Auto-Fixture cheet-sheet
Блог Марка Симана с рядом статей по AutoFixture
Tags:
Hubs:
+14
Comments 3
Comments Comments 3

Articles