0. Лирика


Поговорим про unit тестирование. Для больших и возрастных проектов весьма актуальна проблема «толстых» сервисов. Я сейчас говорю про большое количество зависимостей передаваемых в конструктор. Если к этому добавить несколько десятков методов, которые необходимо тестировать, становится очевидно, что тратится много времени на мокирования ненужных частей. Решить проблему поможет автоматизация,. т.е. создание экземпляра необходимого типа и мокирование неиспользованных зависимостей в процессе выполнения.

Получается нам нужно

var myService = new MyService(A.Fake<ISevice1>(), new Sevice2(), 
               A.Fake<ISevice3>(), A.Fake<ISevice4>(), 
               A.Fake<ISevice5>(), A.Fake<ISevice6>())

заменить на нечто похожее. Напоминает паттерн builder, не так ли?

 var myService = GetInstance<MyService>().With(new Sevice2()).Subject;

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

Разумеется нам не обойтись без рефлексии.

1. Получаем всю необходимую информацию о типе


Для начала уточним, что моки, которые мы будем использовать при создании экземпляра, будут храниться в _overriddenTypes поле, используя следующую логику.

 public ObjectBuilder<T> With<TParam>(TParam param)
        { 
           _overriddenTypes.Add(typeof(TParam), param);
            return this;
        }

Дальше рассмотрим код, который подготавливает информацию для creator. Тут нам как раз-таки пригодится рефлексия.

private T Build()
        {
            var type = typeof(T);
            var constructors = type.GetConstructors().Where(x => x.IsPublic).ToList();
            var parameterizedConstructors = constructors.Where(x => x.GetParameters().Any()).ToList();

            if (!parameterizedConstructors.Any())
            {
                // тут можно выбросить исключение
            }

            var constructor = parameterizedConstructors.Single();
            var parametersType = constructor.GetParameters().Select(x => x.ParameterType).ToList();
            var arguments = parametersType.Select(x => _overriddenTypes.ContainsKey(x) ? _overriddenTypes[x] : Create.Fake(x)).ToArray();

            return GetObject(constructor, parametersType, arguments);
        }

В выше представленном коде видно, что для мокирования я использовал FakeItEasy библиотеку.

2. Система кеширования


private T GetObject(ConstructorInfo constructor, List<Type> constructorParametersType, object[] arguments)
        {
            if (_objectCreatorCache != null)
            {
                return _objectCreatorCache(arguments);
            }

            var creator = GetObjectCreator(constructor, constructorParametersType);
            _objectCreatorCache = creator;

            return creator(arguments);
        }

Здесь может быть не совсем понятно, как это работает, и по какой причине для кеширования я использую поле в этом же классе. Будем учитывать, что определение класса выглядит следующим образом:

public class ObjectBuilder<T>
    {...}

А так же для кеширования используется статическое поле:

private static Func<object[], T> _objectCreatorCache;

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

3. Создание экземпляра в рантайме


Первый приходящий на ум вариант (на самом деле какое-то время он был единственным) — это использование класса Activator и его метода CreateInstance().

Activator.CreateInstance<T>();

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

После внедрения expression's в платформу, появился ещё один, возможно более объёмный способ создания экземпляров типов в рантайме. Его мы и применим.

3.1 Expression object creator


Чем хорош этот подход? В конечном итоге мы получаем скомпилированную лямбду, а не экземпляр объекта. Это позволит использовать кеширование. Я не рекомендую применять данный подход есть требуется единовременное получение экземпляра объекта, Activator справиться с этой задачей значительно быстрее.

private Func<object[], T> GetObjectCreator(ConstructorInfo constructor, List<Type> constructorParametersType)
        {
            var param = Expression.Parameter(typeof(object[]), "parameters");

            var argsExpressions = new Expression[constructorParametersType.Count];

            for (var index = 0; index < constructorParametersType.Count; index++)
            {
                var constantIndex = Expression.Constant(index);
                var paramAccessorExp = Expression.ArrayIndex(param, constantIndex);
                var paramCastExp = Expression.Convert(paramAccessorExp, constructorParametersType[index]);
                argsExpressions[index] = paramCastExp;
            }

            var newExpression = Expression.New(constructor, argsExpressions);
            var lambda = Expression.Lambda(typeof(Func<object[], T>), newExpression, param);

            return (Func<object[], T>)lambda.Compile();
        }

P.S. Если будет интересно, я могу провести сравнение производительности, а также в деталях описать работу с expression's.

P.S.S. Ниже представлен полный код данного builder'a


public class ObjectBuilder<T>
    {
        private static Func<object[], T> _objectCreatorCache;
        private readonly Dictionary<Type, object> _overriddenTypes;
        private readonly Lazy<T> _subject;

        public ObjectBuilder()
        {
            _overriddenTypes = new Dictionary<Type, object>();
            _subject = new Lazy<T>(Build);
        }

        public T Subject => _subject.Value;

        public ObjectBuilder<T> With<TParam>(TParam param)
        {
            if (_subject.IsValueCreated)
            {
                throw new Exception("Can't change builder options after first call to Object. Please create new one");
            }

            _overriddenTypes.Add(typeof(TParam), param);
            return this;
        }

        private T Build()
        {
            var type = typeof(T);
            var constructors = type.GetConstructors().Where(x => x.IsPublic).ToList();
            var parameterizedConstructors = constructors.Where(x => x.GetParameters().Any()).ToList();

            if (!parameterizedConstructors.Any())
            {
                // тут пожалуй можно выбросить исключение
            }

            var constructor = parameterizedConstructors.Single();
            var constructorParametersType = constructor.GetParameters().Select(x => x.ParameterType).ToList();
            var arguments = constructorParametersType.Select(x => _overriddenTypes.ContainsKey(x) ? _overriddenTypes[x] : Create.Fake(x)).ToArray();

            return GetObject(constructor, constructorParametersType, arguments);
        }

        private T GetObject(ConstructorInfo constructor, List<Type> constructorParametersType, object[] arguments)
        {
            if (_objectCreatorCache != null)
            {
                return _objectCreatorCache(arguments);
            }

            var creator = GetObjectCreator(constructor, constructorParametersType);
            _objectCreatorCache = creator;

            return creator(arguments);
        }
                                                      
        private Func<object[], T> GetObjectCreator(ConstructorInfo constructor, List<Type> constructorParametersType)
        {
            var param = Expression.Parameter(typeof(object[]), "parameters");

            var argsExpressions = new Expression[constructorParametersType.Count];

            for (var index = 0; index < constructorParametersType.Count; index++)
            {
                var constantIndex = Expression.Constant(index);
                var paramAccessorExp = Expression.ArrayIndex(param, constantIndex);
                var paramCastExp = Expression.Convert(paramAccessorExp, constructorParametersType[index]);
                argsExpressions[index] = paramCastExp;
            }

            var newExpression = Expression.New(constructor, argsExpressions);
            var lambda = Expression.Lambda(typeof(Func<object[], T>), newExpression, param);

            return (Func<object[], T>)lambda.Compile();
        }
    }

Комментарии (5)


  1. Veikedo
    07.03.2018 17:36

    AutoMoq AutoFixture


    А вообще, мне кажется, проблемы у вас поглубже


    1. savelievser
      09.03.2018 19:41

      Жажда писать свои велосипеды, чтобы заюзать любимую фичу, видимо не победима)


  1. ilya-chumakov
    07.03.2018 19:46
    +1

    Для создания иерархии объектов и разрешения зависимостей можно было просто использовать DI-контейнер. Он для этого и предназначен.


    1. Interreto
      09.03.2018 14:45
      -1

      Да, причем тот же что и в проекте, и это было-бы более органично, поскольку паралельно тестировалась интеграция контейнера с юнитами.


      1. dimedroll2211 Автор
        09.03.2018 14:54
        +2

        Проблема использования DI это скорость работы. Все работает достаточно нерасторопно. Связанно это с пересозданием контейнера каждый раз при изменении мокируемых сервисов. А к главным чертами юнит тестирования можно отнести быстроту выполнения, краткость, читаемость и тестирование одного изолированного юнита. Так же хочется напомнить что для тестирования интеграции применяется интеграционное тестирование.