Юнит тестирование с применением IoC и Mock objects
Любой человек, начинающий изучать юнит тестирование рано или поздно сталкивается со следующей проблемой: согласно идеологии юнит тестирования каждый класс должен быть протестирован отдельно. В простых книжных примерах это выглядит легко, но на практике между многими классами имеется тесная взаимосвязь. Для разрыва такой связи как раз и может служить Ioc. А для облегчения тестирования используются Mock библиотеки — библиотеки, создающие объекты-заглушки.
Данная статья не рассматривает подробно ни mock библиотеку ни Ioc контейнер, а только показывает одно из возможных применений этих технологий для юнит тестирования.
Итак, представим у нас имеются следующие классы
public class FooEntityManager { private readonly FooEntityRepository _repository = new FooEntityRepository(); public void Save(FooEntity entity) { var validator = new FooEntityValidator(); if(validator.IsValid(entity) == false) { throw new InvalidOperationException("Foo not valid"); } _repository.Save(entity); } } public class FooEntityRepository { public void Save(FooEntity entity) { } } public class FooEntityValidator { public bool IsValid(FooEntity entity) { return entity.IsOk; } } public class FooEntity { public bool IsOk { get; set; } }
Теперь мы хотим протестировать FooEntityManager. Если мы просто напишем тест на данный класс то вместе с менеджером будет выполнятся код валидатора, и что совсем никуда не годно, репозитория.
Первым делом выделим интерфейсы валидатора и репозитория, для того чтоб при тестировании мы могли подменить их заглушками.
Теперь нам нужна возможность передавать менеджеру или настоящие реализации зависимых классов или заглушки.
Решение проблемы с репозиторием напрашивается само собой — сделаем его параметром конструктора. С валидатором ситуация несколько сложнее (предположим, что нам просто необходимо создавать его каждый раз новым, и решение с передачей инстанса в конструктор не подходит). Тогда, в принципе, мы можем применить фабрику:
public class FooEntityManager { public FooEntityManager(IFooEntityRepository repository) { _repository = repository; } private readonly IFooEntityRepository _repository; public void Save(FooEntity entity) { IFooEntityValidator validator = ValidatorsFactory.GetFactory<IFooEntityValidator>(); if(validator.IsValid(entity) == false) { throw new InvalidOperationException("Foo not valid"); } _repository.Save(entity); } } public interface IFooEntityRepository { void Save(FooEntity entity); } public interface IFooEntityValidator { bool IsValid(FooEntity entity); }
Теперь при каждом создании данного менеджера, а так же других классов, которые используют данный репозиторий мы будем вынуждены передавать инстанс репозитория в параметрах конструктора.
В чем минусы данного решения? Во-первых, придётся создавать фабрику для каждого класса, который используется в тестируемых классах. Во-вторых, репозиторий придётся протаскивать по всей иерархии вызовов. Помимо того возникает вопрос, а кто собственно будет инстанцировать репозиторий.
Чтоб избежать все этих проблем мы применим IoC контейнер Unity
private static void Main(string[] args) { BuildApplication(); var manager = ServiceContainer.Container.Resolve<FooEntityManager>(); manager.Save(new FooEntity()); } private static void BuildApplication() { ServiceContainer.Container.RegisterType(typeof (IFooEntityRepository), typeof (FooEntityRepository), new ContainerControlledLifetimeManager()); ServiceContainer.Container.RegisterType(typeof (IFooEntityValidator), typeof (FooEntityValidator), new TransientLifetimeManager()); } public class FooEntityManager { private readonly IFooEntityRepository _repository; public FooEntityManager(IFooEntityRepository repository) { _repository = repository; } public void Save(FooEntity entity) { var validator = ServiceContainer.Container.Resolve<IFooEntityValidator>(); if (validator.IsValid(entity) == false) { throw new InvalidOperationException("Foo not valid"); } _repository.Save(entity); } } public static class ServiceContainer { private static readonly UnityContainer _container = new UnityContainer(); public static UnityContainer Container { get { return _container; } } }
Основная идея следующая: создаётся статичный класс, который содержит инстанс UnityContainer. Этот инстанс будет одинажды конфигурироватся и в дальнейшем использоватся всеми классами в приложении.
Рассмотрим приведенный код подробнее. Первым делом в методе BuildApplication мы задаем соответствие между интерфейсами и их реализациями. Последний параметр в вызове RegisterType определяет длину жизни инстанса регистируемого класса. ContainerControlledLifetimeManager означает что регистрируемый класс будет вести себя как синглтон. При использованииTransientLifetimeManager регистриемый класс будет создаватся каждый раз при инжекции.
Теперь рассмотрим использование контейнера.
var manager = ServiceContainer.Container.Resolve<FooEntityManager>();
Этот вызов позволяет получить инстанс заданного класса. При этом обращении Unity просматривает список зарегестрированных типов. Если требуемый тип отсутствует в списке то создается инстанс класса, указанного в дженерик параметре. Так как класс FooEntityManager имеет в параметрах конструктора тип IFooEntityRepository Unity инстанцирует и его. Опять происходит просмотр списка зарегистрированных типов и в нашем случае инстанцируется FooEntityRepository (точнее инстанцируется только при первом обращении, в дальнейшем возвращается уже созданный инстанс)
Следующий вызов работает аналогично ранее рассмотренному с той только разницей, что при каждом обращении будет возвращатся новый инстанс
var validator = ServiceContainer.Container.Resolve<IFooEntityValidator>();
Теперь всё готово собственно для тестирования. Для того чтобы протестировать менеджер, нам нужны заглушки, реализующие интерфейсы репозитория и валидатора. В принципе мы можем просто создать 2 класса реализующие соответствующие интерфейсы. Но этот подход имеет несколько минусов. Во-первых, это лишняя писанина (а она может быть не тривиальной, если, к примеру методы интерфейсов имеют параметры и возвращаемое значение, и при различных значениях входных параметров заглушка должна возвращать разные значения. Или, к примеру, нам нужно убедится, что какой-либо метод был вызван или вызван ровно 3 раза). Во-вторых, нам придётся реализовать все методы, если даже для тестирования нужен только один.
Эти проблемы нам поможет решить mock библиотека. Я использую rhino mocks
[TestMethod] public void Save_Valid() { var entity = new FooEntity(); var repository = MockRepository.GenerateMock<IFooEntityRepository>(); repository.Expect(x => x.Save(entity)).Repeat.Once(); ServiceContainer.Container.RegisterInstance(repository); var validator = MockRepository.GenerateMock<IFooEntityValidator>(); validator.Stub(x => x.IsValid(null)).IgnoreArguments().Return(true); ServiceContainer.Container.RegisterInstance(validator); var target = new FooEntityManager(repository); target.Save(entity); repository.VerifyAllExpectations(); } [TestMethod] [ExpectedException(typeof(InvalidOperationException))] public void Save_NotValid() { var entity = new FooEntity(); var repository = MockRepository.GenerateMock<IFooEntityRepository>(); repository.Expect(x => x.Save(entity)).Repeat.Once(); ServiceContainer.Container.RegisterInstance(repository); var validator = MockRepository.GenerateMock<IFooEntityValidator>(); validator.Stub(x => x.IsValid(null)).IgnoreArguments().Return(false); ServiceContainer.Container.RegisterInstance(validator); var target = new FooEntityManager(repository); target.Save(entity); repository.VerifyAllExpectations(); }
Рассмотрим часть кода подробнее
var validator = MockRepository.GenerateMock<IFooEntityValidator>(); validator.Stub(x => x.IsValid(null)).IgnoreArguments().Return(true); ServiceContainer.Container.RegisterInstance(validator);
В первой строке мы создаём mock объект. Следующей строкой навешиваем заглушку на метод IsValid. И говорим какие бы аргументы не были бы переданы всегда возвращать true. Ну и последней строкой мы регистрируем объект в контейнере. Только в отличие от приложения, здесь мы указываем объект, а не тип.
Вызов repository.Expect не только создаёт заглушку, но и позволяет проверить, что метод был действительно вызван. Эта проверка осуществляется при вызове repository.VerifyAllExpectations()
.
Таким образом, совместное применение IoC контейнера и Mock библиотеки позволили нам протестировать класс «в вакуме».