AutoFixture со слабыми типами

Мне нравится AutoFixture, но я столкнулся с очень повторяющимся кодом "аранжировки", который, как мне кажется, должен уметь обращаться - как-нибудь.

Вот мой сценарий, иллюстрированный реализациями IInterceptor из Castle Dynamic Proxy.

Сначала тестируемые системы:

public class InterceptorA : IInterceptor
{
    public void Intercept(IInvocation context)
    {
        object proxy = context.Proxy;
        object returnValue = context.ReturnValue;
        // Do something with proxy and returnValue
    }
}

public class InterceptorB : IInterceptor
{
    public void Intercept(IInvocation context)
    {
        object returnValue = context.ReturnValue;
        // Do something with different returnValue
    }
}

Теперь несколько простых тестов, использующих поддержку теорий данных для xUnit:

public class InterceptorATests
{
    [Theory, CustomAutoData]
    public void TestA1(InterceptorA sut, IInvocation context)
    {
        Mock.Get(context).Setup(c => c.Proxy).Returns("a");
        Mock.Get(context).Setup(c => c.ReturnValue).Returns("b");

        sut.Intercept(context);
        // assert
    }
}

public class InterceptorBTests
{
    [Theory, CustomAutoData]
    public void TestB1(InterceptorB sut, IInvocation context)
    {
        Mock.Get(context).Setup(c => c.ReturnValue).Returns("z");
        sut.Intercept(context);
        // assert
    }
}

Мой атрибут CustomAutoData на самом деле настраивает AutoFixture таким образом, чтобы внедренные экземпляры IInvocation были в основном настроены правильно, но поскольку каждая реализация IInterceptor предполагает совершенно разные типы свойств Proxy и ReturnValue, каждый тест должен устанавливать их на их. (Так звонит Mock.Get(context).Setup(...).)

Это нормально, за исключением того, что каждый тест в InterceptorATests должен повторять те же несколько строк аранжировки, как и каждый тест в InterceptorBTests.

Есть ли способ аккуратно удалить повторяющиеся вызовы Mock.Get(...)? Есть ли хороший способ получить доступ к экземпляру IFixture для данного тестового класса?


person nikmd23    schedule 19.12.2012    source источник


Ответы (2)


Вы можете сделать массу вещей — в зависимости от того, что вы действительно хотите протестировать.

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

Не устанавливайте заглушки, если они вам не нужны

Прежде всего, вам не нужно обязаны настраивать возвращаемые значения для свойств Proxy и ReturnValue, если они вам не нужны.

Способ, которым AutoFixture.AutoMoq устанавливает экземпляры Mock<T>, заключается в том, что он всегда устанавливает DefaultValue = DefaultValue.Mock. Поскольку возвращаемый тип обоих свойств — object, а object имеет конструктор по умолчанию, вы автоматически получите обратно объект (на самом деле, ObjectProxy).

Другими словами, эти тесты также проходят:

[Theory, CustomAutoData]
public void TestA2(InterceptorA sut, IInvocation context)
{
    sut.Intercept(context);
    // assert
}

[Theory, CustomAutoData]
public void TestB2(InterceptorB sut, IInvocation context)
{
    sut.Intercept(context);
    // assert
}

Назначить ReturnValue напрямую

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

Прежде всего, вы можете сократить тяжелый синтаксис Moq, назначив ReturnValue напрямую:

[Theory, Custom3AutoData]
public void TestA3(InterceptorA sut, IInvocation context)
{
    context.ReturnValue = "b";

    sut.Intercept(context);
    // assert
    Assert.Equal("b", context.ReturnValue);
}

[Theory, Custom3AutoData]
public void TestB3(InterceptorB sut, IInvocation context)
{
    context.ReturnValue = "z";

    sut.Intercept(context);
    // assert
    Assert.Equal("z", context.ReturnValue);
}

Однако это работает только для ReturnValue, так как это свойство доступно для записи. Он не работает со свойством Proxy, потому что он доступен только для чтения (он не будет компилироваться).

Чтобы это работало, вы должны указать Moq обрабатывать IInvocation свойств как «настоящие» свойства:

public class Customization3 : CompositeCustomization
{
    public Customization3()
        : base(
            new RealPropertiesOnInvocation(),
            new AutoMoqCustomization())
    {
    }

    private class RealPropertiesOnInvocation : ICustomization
    {
        public void Customize(IFixture fixture)
        {
            fixture.Register<Mock<IInvocation>>(() =>
                {
                    var td = new Mock<IInvocation>();
                    td.DefaultValue = DefaultValue.Mock;
                    td.SetupAllProperties();
                    return td;
                });
        }
    }
}

Обратите внимание на вызов SetupAllProperties.

Это работает, потому что AutoFixture.AutoMoq работает, ретранслируя все запросы интерфейсов в запрос Mock этого интерфейса, т. е. запрос IInvocation преобразуется в запрос Mock<IInvocation>.

Не устанавливайте тестовые значения; прочитать их обратно

В конце вы должны спросить себя: действительно ли мне нужно присваивать этим свойствам определенные значения (например, «a», «b» и «z»). Нельзя ли просто позволить AutoFixture создать необходимые значения? И если я это сделаю, мне нужно явно назначать их? Не мог бы я вместо этого просто прочитать присвоенное значение?

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

Введите тип сигнала для каждого свойства:

public class InvocationReturnValue
{
    private readonly object value;

    public InvocationReturnValue(object value)
    {
        this.value = value;
    }

    public object Value
    {
        get { return this.value; }
    }
}

public class InvocationProxy
{
    private readonly object value;

    public InvocationProxy(object value)
    {
        this.value = value;
    }

    public object Value
    {
        get { return this.value; }
    }
}

(Если вам требуется, чтобы значения всегда были строками, вы можете изменить сигнатуру конструктора, чтобы она требовала string вместо object.)

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

[Theory, Custom4AutoData]
public void TestA4(
    InterceptorA sut,
    [Frozen]InvocationProxy proxy,
    [Frozen]InvocationReturnValue returnValue,
    IInvocation context)
{
    sut.Intercept(context);
    // assert
    Assert.Equal(proxy.Value, context.Proxy);
    Assert.Equal(returnValue.Value, context.ReturnValue);
}

[Theory, Custom4AutoData]
public void TestB4(
    InterceptorB sut,
    [Frozen]InvocationReturnValue returnValue,
    IInvocation context)
{
    sut.Intercept(context);
    // assert
    Assert.Equal(returnValue.Value, context.ReturnValue);
}

Прелесть этого подхода в том, что в тех тестовых примерах, где вас не волнуют ReturnValue или Proxy, вы можете просто опустить эти аргументы метода.

Соответствующая Кастомизация является расширением предыдущей:

public class Customization4 : CompositeCustomization
{
    public Customization4()
        : base(
            new RelayedPropertiesOnInvocation(),
            new AutoMoqCustomization())
    {
    }

    private class RelayedPropertiesOnInvocation : ICustomization
    {
        public void Customize(IFixture fixture)
        {
            fixture.Register<Mock<IInvocation>>(() =>
                {
                    var td = new Mock<IInvocation>();
                    td.DefaultValue = DefaultValue.Mock;
                    td.SetupAllProperties();

                    td.Object.ReturnValue = 
                        fixture.CreateAnonymous<InvocationReturnValue>().Value;
                    td.Setup(i => i.Proxy).Returns(
                        fixture.CreateAnonymous<InvocationProxy>().Value);

                    return td;
                });
        }
    }
}

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

Этот подход можно обобщить, но в этом его суть.

person Mark Seemann    schedule 19.12.2012
comment
Спасибо за отличный ответ Марк. Я должен признать, что это было не то, на что я надеялся, и я использовал типы сигналов в прошлом, но это было очень информативно, и это то, от чего я должен отказаться прямо сейчас. Продолжайте в том же духе над AutoFixture! - person nikmd23; 21.12.2012
comment
На что вы надеялись? Я просмотрел предложенное вами решение, и хотя я понимаю, что это PoC, я не понимаю, как оно что-то улучшает. - person Mark Seemann; 22.12.2012

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

Теперь у моего теста есть дополнительный атрибут: Signal.

public class InterceptorATests
{
    [Theory, CustomAutoData]
    public void TestA1(InterceptorA sut, [Signal(typeof(SpecialContext))] IInvocation context)
    {
        // no more repetitive arrangement!
        sut.Intercept(context);
        // assert
    }
}

Класс SignalAttribute очень прост:

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class SignalAttribute : Attribute
{
    public ISignalType SignalType { get; set; }

    public SignalAttribute(Type customization)
    {
        SignalType = (ISignalType)Activator.CreateInstance(customization);
    }
}

Настоящая магия приходит в моем недавно обновленном классе CustomAutoData:

public class CustomAutoDataAttribute: AutoDataAttribute
{
    public CustomAutoDataAttribute() : base(new Fixture().Customize(new AutoMoqCustomization()))
    {
    }

    public override IEnumerable<object[]> GetData(MethodInfo methodUnderTest, Type[] parameterTypes)
    {
        Type input = null;
        ISignalType signalType = null;

        foreach (var parameter in methodUnderTest.GetParameters())
        {
            var attribute = parameter.GetCustomAttribute(typeof(SignalAttribute)) as SignalAttribute;

            if (attribute == null)
                continue;

            input = parameter.ParameterType;
            signalType = attribute.SignalType;

            break;
            // this proof of concept only supports one parameter at a time
        }

        var result = base.GetData(methodUnderTest, parameterTypes);

        if (input == null)
            return result;

        int index = Array.IndexOf(parameterTypes, input);

        foreach (var objectSet in result)
        {
            signalType.Customize(objectSet[index]);
        }

        return result;
    }
}

Наконец, я просто создаю свой файл SpecialContext. Я создаю его как вложенный класс в InterceptorATests, но он может жить где угодно:

public class SpecialContext : ISignalType
{
    public void Customize(object obj)
    {
        var input = (IInvocation)obj;
        Mock.Get(input).Setup(i => i.Proxy).Returns("a");
        Mock.Get(input).Setup(i => i.ReturnValue).Returns("b");
    }
}

Это позволяет мне эффективно подключиться после того, как AutoFixture выполнит большую часть работы по созданию IInvocation, но указать дополнительные настройки в одном месте.

ПРИМЕЧАНИЕ. Это доказательство концептуального кода! Он не обрабатывает многие сценарии должным образом. Используйте на свой риск.

person nikmd23    schedule 21.12.2012
comment
Рассматривали ли вы вместо этого свой настраиваемый атрибут с привязкой к параметрам из Ploeh.AutoFixture.Xunit.CustomizeAttribute? Разве это не было бы проще? - person Mark Seemann; 22.12.2012
comment
Атрибут [Customize] является базовым классом для атрибутов AutoFixture.Xunit, таких как [Frozen], [FavorArrays] и т. д. - person Mark Seemann; 22.12.2012
comment
Нет, не знал, я не знал о существовании этого класса. Я посмотрю и увижу! - person nikmd23; 22.12.2012
comment
Было ли вашей целью перенести настройку IInvocation в отдельный класс? Если да, то почему бы не использовать специальную пару [AutoData] и ICustomization? - person Mark Seemann; 22.12.2012
comment
Я бы предпочел перенести настройку IInvocation в метод и, возможно, в будущем. - person nikmd23; 30.12.2012
comment
Я не могу переместить код установки в ICustomization, потому что для каждой SUT нужна другая настройка. В этом примере InterceptorA и InterceptorB ожидают совершенно разные экземпляры IInvocation. - person nikmd23; 30.12.2012
comment
С подходом ISignalType, описанным выше, вам также потребуется определить класс, реализующий этот интерфейс для каждой ТРИ. Чем это лучше, чем определение класса, реализующего ICustomization для каждой ТРИ? - person Mark Seemann; 30.12.2012