По непроверенным данным, половина несчастных случаев происходит после слов "смотри, как я умею", другая же половина — после "ерунда, смотри, как надо".
Тут один приятель, увидев фокус с тестами без тестов с использованием обобщенных аттрибутов из preview версии C# и особенностей экосистемы NUnit, отметил, что все сделано транс-ректально, а сам бы он применил Fody, и вышло бы гораздо лучше. Демонстрировать, к сожалению, ничего не стал. А мне вспомнился комментарий к описанию другого преодоления концептуального ограничения языка. Тогда руки не дошли попробовать, а сейчас вот решил глянуть, что это за птица, и проверить, поможет ли она написать более элегантное решение.
Дано
Итак, если еще больше упростить то, что использовалось в фокусе, то имеем следующее:
Некоторый скрипт `IScript`, который может быть атакой `Attack` или продвинутой атакой `AdvancedAttack`
public interface IScript { }
public class Attack : IScript { }
public class AdvancedAttack : Attack { }
Проверяющий скрипт валидатор `IValidator`, который может быть простым `OrdinaryValidator` или продвинутым `AdvancedValidator`
public interface IValidator
{
void Validate(IScript script);
}
public class OrdinaryValidator : IValidator
{
void IValidator.Validate(IScript script)
{
if (script is Attack && script is not AdvancedAttack)
throw new Exception("Attack detected.");
}
}
public class AdvancedValidator : IValidator
{
void IValidator.Validate(IScript script)
{
if (script is Attack)
throw new Exception("Attack detected.");
}
}
И проверка `ICheck`. Мы будем использовать проверку на обнаружение атаки `AttackDetected`
public interface ICheck
{
bool Check(IValidator validator, IScript script);
}
public class AttackDetected : ICheck
{
public bool Check(IValidator validator, IScript script)
{
try
{
validator.Validate(script);
return false;
}
catch
{
return true;
}
}
}
Тесты для проверки взаимодействия валидаторов (простого OrdinaryValidator
и продвинутого AdvancedValidator
) с продвинутой атакой AdvancedAttack
можно написать так:
using NUnit.Framework;
[TestFixture]
public class DemoTests : IDeclarativeTest
{
public void Test<TValidator, TScript, TCheck>(bool expected)
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new() =>
DefaultDeclarativeTest.Test<TValidator, TScript, TCheck>(expected);
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedAttack() { }
}
Фокус в том, что это тесты без тела метода, описываемые аттрибутом и в нем же содержащиеся.
Под капотом `DeclarativeCaseAttribute<TValidator, TScript, TCheck>` выглядит так
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> : GenericCaseAttribute, ITestBuilder
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
private static readonly IReadOnlyCollection<Type> types = new[]
{
typeof(TValidator),
typeof(TScript),
typeof(TCheck)
};
public DeclarativeCaseAttribute(bool expected)
: base(expected) { }
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite)
{
IEnumerable<TestMethod> tests;
var type = suite.TypeInfo.Type;
if (!typeof(IDeclarativeTest).IsAssignableFrom(type))
tests = base.BuildFrom(method, suite)
.SetNotRunnable($"{type} does not implement {typeof(IDeclarativeTest)}.");
else if (!method.MethodInfo.IsIdle())
tests = base.BuildFrom(method, suite)
.SetNotRunnable("Method is not idle, i.e. does something.");
else
tests = base.BuildFrom(new MethodWrapper(type, nameof(IDeclarativeTest.Test)), suite);
return tests.Select(test =>
{
test.FullName = CreateName(test, suite, method,
suite => suite.FullName, type => type.FullName);
test.Name = CreateName(test, suite, method,
suite => suite.Name, type => type.Name);
return test;
});
}
private static string CreateName(
TestMethod test,
Test suite,
IMethodInfo method,
Func<Test, string> suitNameSelector,
Func<Type, string> typeNameSelector) =>
$"{suitNameSelector(suite)}.{method.Name}<{
string.Join(",", types.Select(typeNameSelector))}>({
string.Join(',', test.Arguments)})";
}
Он наследует от GenericCaseAttribute
, который в отличие от своего базового класса TestCaseAttribute
умеет в обобщенные методы через повторную реализацию ITestBuilder
.
`GenericCaseAttribute`
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public class GenericCaseAttribute : TestCaseAttribute, ITestBuilder
{
private readonly IReadOnlyCollection<Type> typeArguments;
public GenericCaseAttribute(params object[] arguments)
: base(arguments) => typeArguments = GetType().GetGenericArguments();
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite)
{
try
{
return base.BuildFrom(
method.IsGenericMethodDefinition || typeArguments.Any() ?
method.MakeGenericMethod(typeArguments.ToArray()) :
method,
suite);
}
catch (Exception ex)
{
return base.BuildFrom(method, suite).SetNotRunnable(ex.Message);
}
}
}
Сам DeclarativeCaseAttribute<TValidator, TScript, TCheck>
тоже повторно реализует ITestBuilder
, чтобы подменять тестовый метод на IDeclarativeTest.Test
, и для этого требует реализации IDeclarativeTest
тестовым классом. (Вариант с подменой тестового метода на метод аттрибута без необходимости реализации IDeclarativeTest
тестовым классом возможен, но только с использование неочевидного и недокументориованного поведения NUnit, поэтому он был отвергнут в окончательной версии). Дополнительно над именами тестов совершаются магические действия для красивой работы Test Explorer.
Еще всякая мелочь происходит в методах расширения
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;
public static class TestMethodExtensions
{
public static IEnumerable<TestMethod> SetNotRunnable(this IEnumerable<TestMethod> tests, string message)
{
foreach(var test in tests)
yield return test.SetNotRunnable(message);
}
public static TestMethod SetNotRunnable(this TestMethod test, string message)
{
test.RunState = RunState.NotRunnable;
test.Properties.Set(PropertyNames.SkipReason, message);
return test;
}
}
public static class MethodInfoExtensions
{
private static readonly IReadOnlyCollection<byte> idle = new[]
{
OpCodes.Nop,
OpCodes.Ret
}.Select(code => (byte)code.Value).ToArray();
public static bool IsIdle(this MethodInfo method)
{
var body = method.GetMethodBody();
if (body.LocalVariables.Any())
return false;
if (body.GetILAsByteArray().Except(idle).Any())
return false;
if (method.DeclaringType.GetMethods(BindingFlags.Static | BindingFlags.NonPublic)
.Any(candidate => IsLocalMethod(candidate, method)))
return false;
return true;
}
private static bool IsLocalMethod(MethodInfo method, MethodInfo container) =>
method.Name.StartsWith($"<{container.Name}>") &&
method.GetCustomAttributes<CompilerGeneratedAttribute>().Any();
}
В тестовом классе DemoTests
реализация IDeclarativeTest
делегирует работу DefaultDeclarativeTest
, котрорый, фактически, и содержит код теста.
using NUnit.Framework;
public static class DefaultDeclarativeTest
{
public static void Test<TValidator, TScript, TCheck>(bool expected)
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
// Arrange
IValidator validator = new TValidator();
IScript script = new TScript();
ICheck check = new TCheck();
// Act
bool actual = check.Check(validator, script);
// Assert
Assert.AreEqual(expected, actual);
}
}
Лирическое отступление
DeclarativeCaseAttribute<TValidator, TScript, TCheck>
и DefaultDeclarativeTest
немного отличаются от своих версий из прошлой статьи благодаря self-review через некоторое время после публикации. Вообще, ревью - отличная штука. Помню, в одной небольшой команде у нас была практика, когда каждый ревьюил каждого. Это было добровольно, но обычно откликалось более одного человека. И пока кто-то погружался в глубины архитектуры, другие находили мелочи, ускользавшие от взгляда первых, и результат становился только лучше. Короче, братие, да ревьюите друг друга.
Решение
Для начала удаляем GenericCaseAttribute
с IDeclarativeTest
и их следы. Попутно в DeclarativeCaseAttribute<TValidator, TScript, TCheck>
заводим поле bool expected
и используем его для хранения аргумента без передачи в конструктор TestCaseAttribute
(для предотвращения падения тестов с сообщением "Arguments provided for method with no parameters").
Теперь `DeclarativeCaseAttribute<TValidator, TScript, TCheck>` выглядит так
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> : TestCaseAttribute, ITestBuilder
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
private static readonly IReadOnlyCollection<Type> types = new[]
{
typeof(TValidator),
typeof(TScript),
typeof(TCheck)
};
private readonly bool expected;
public DeclarativeCaseAttribute(bool expected) =>
this.expected = expected;
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite)
{
IEnumerable<TestMethod> tests;
if (!method.MethodInfo.IsIdle())
tests = base.BuildFrom(method, suite)
.SetNotRunnable("Method is not idle, i.e. does something.");
else
tests = base.BuildFrom(method, suite);
return tests.Select(test =>
{
test.FullName = CreateName(test, suite, method,
suite => suite.FullName, type => type.FullName);
test.Name = CreateName(test, suite, method,
suite => suite.Name, type => type.Name);
return test;
});
}
private string CreateName(
TestMethod test,
Test suite,
IMethodInfo method,
Func<Test, string> suitNameSelector,
Func<Type, string> typeNameSelector) =>
$"{suitNameSelector(suite)}.{method.Name}<{
string.Join(",", types.Select(typeNameSelector))}>({expected})";
}
А тестовый класс так (тесты, кстати, уже ничего не делают):
using NUnit.Framework;
[TestFixture]
public class DemoTests
{
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedAttack() { }
}
Пришло время выбрать weaver. Можно посмотреть через NuGet UI по фильтру "fody" и заметить, что потенциально подходят MethodBoundaryAspect.Fody и MethodDecorator. Они оба живые, но у первого в 1.5 раза больше загрузок, поэтому останавливаемся на нем. Есть еще одна причина, но о ней чуть позже.
NuGet UI
Идем в пример работы с аспектом и по косвенным признакам видим, что он представляет из себя класс. А DeclarativeCaseAttribute<TValidator, TScript, TCheck>
уже наследует TestCaseAttribute
. К счастью, для NUnit главное - реализация соответствующих интерфейсов (те же TestCaseAttribute
и TestAttribute
не связаны цепочкой наследования, но оба распознаются, как тестовые аттрибуты). Демонстрация этого нюанса и есть причина выбора weaver'а. Так что наследуем DeclarativeCaseAttribute<TValidator, TScript, TCheck>
от OnMethodBoundaryAspect
и переопределяем, например, void OnEntry(MethodExecutionArgs)
, а реализацию ITestBuilder
, ITestCaseData
, IImplyFixture
делаем с помощью поля TestCaseAttribute testCaseAttribute
. Заодно предотвращаем падение тестов с сообщением "Method is not idle, i.e. does something" (weaver переписывает метод до его добавления в тесты), удаляя соответствующую проверку (но потом хорошо бы ее вернуть).
`DeclarativeCaseAttribute<TValidator, TScript, TCheck>`
using MethodBoundaryAspect.Fody.Attributes;
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> :
OnMethodBoundaryAspect, ITestBuilder, ITestCaseData, IImplyFixture
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
private static readonly IReadOnlyCollection<Type> types = new[]
{
typeof(TValidator),
typeof(TScript),
typeof(TCheck)
};
private readonly bool expected;
private readonly TestCaseAttribute testCaseAttribute = new();
public DeclarativeCaseAttribute(bool expected) =>
this.expected = expected;
public override void OnEntry(MethodExecutionArgs arg) =>
DefaultDeclarativeTest.Test<TValidator, TScript, TCheck>(expected);
public IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) =>
testCaseAttribute.BuildFrom(method, suite).Select(test =>
{
test.FullName = CreateName(test, suite, method,
suite => suite.FullName, type => type.FullName);
test.Name = CreateName(test, suite, method,
suite => suite.Name, type => type.Name);
return test;
});
public object ExpectedResult => testCaseAttribute.ExpectedResult;
public bool HasExpectedResult => testCaseAttribute.HasExpectedResult;
public string TestName => testCaseAttribute.TestName;
public RunState RunState => testCaseAttribute.RunState;
public object[] Arguments => testCaseAttribute.Arguments;
public IPropertyBag Properties => testCaseAttribute.Properties;
private string CreateName(
TestMethod test,
Test suite,
IMethodInfo method,
Func<Test, string> suitNameSelector,
Func<Type, string> typeNameSelector) =>
$"{suitNameSelector(suite)}.{method.Name}<{
string.Join(",", types.Select(typeNameSelector))}>({expected})";
}
В награду за все эти приседания получаем падение тестов с сообщением:
Message:
System.InvalidOperationException : Could not execute the method because either the method itself or the containing type is not fully instantiated.
Stack Trace:
DeclarativeCaseAttribute`3.ctor(Boolean expected)
DemoTests.TestAdvancedAttack()
Думаю, пора проверить, что же там нагенерировалось в тестовый метод. Смотрим содержимое метода в каком-нибудь ILSpy и видим:
object[] __var_0 = new object[0];
MethodExecutionArgs __var_4 = new MethodExecutionArgs();
__var_4.Arguments = __var_0;
MethodBase __var_5 = (__var_4.Method = MethodInfos._methodInfo_2742FEFF28FE4F5C0DA05D8E6FB631BC053D523283F80EE7DFD2FA576C10BE40);
DemoTests __var_1 = (DemoTests)(__var_4.Instance = this);
DeclarativeCaseAttribute<OrdinaryValidator, AdvancedAttack, AttackDetected> __var_6 = (DeclarativeCaseAttribute<OrdinaryValidator, AdvancedAttack, AttackDetected>)(object)new DeclarativeCaseAttribute<, , >(expected: false);
DeclarativeCaseAttribute<AdvancedValidator, AdvancedAttack, AttackDetected> __var_8 = (DeclarativeCaseAttribute<AdvancedValidator, AdvancedAttack, AttackDetected>)(object)new DeclarativeCaseAttribute<, , >(expected: true);
((DeclarativeCaseAttribute<, , >)(object)__var_6).OnEntry(__var_4);
object __var_7 = __var_4.MethodExecutionTag;
FlowBehavior __var_2 = __var_4.FlowBehavior;
if (__var_2 != FlowBehavior.Return)
{
((DeclarativeCaseAttribute<, , >)(object)__var_8).OnEntry(__var_4);
object __var_9 = __var_4.MethodExecutionTag;
FlowBehavior __var_3 = __var_4.FlowBehavior;
if (__var_3 != FlowBehavior.Return)
{
$_executor_TestAdvancedAttack();
}
}
Очевидно, для каждого DeclarativeCaseAttribute<TValidator, TScript, TCheck>
добавляется дополнительный код. Это легко подтвердить, меня количество аттрибутов. Также присутствие в сгенерированном коде DeclarativeCaseAttribute<, , >(expected: false)
говорит о трудностях работы с обобщенными типами. Как-то пока не выходит каменный цветок.
Возвращаемся к наследованию от `TestCaseAttribute` и немного рефакторим `string CreateName(IMethodInfo, Func<Type, string>)`
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> : TestCaseAttribute, ITestBuilder
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
private static readonly IReadOnlyCollection<Type> types = new[]
{
typeof(TValidator),
typeof(TScript),
typeof(TCheck)
};
private readonly bool expected;
public DeclarativeCaseAttribute(bool expected) =>
this.expected = expected;
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) =>
base.BuildFrom(method, suite).Select(test =>
{
test.FullName = CreateName(method, type => type.FullName);
test.Name = CreateName(method, type => type.Name);
return test;
});
private string CreateName(IMethodInfo method, Func<Type, string> nameSelector) =>
$"{nameSelector(method.TypeInfo.Type)}.{method.Name}<{
string.Join(",", types.Select(nameSelector))}>({expected})";
}
Создаем аттрибут DeclarativeAttribute
:
using MethodBoundaryAspect.Fody.Attributes;
public class DeclarativeAttribute : OnMethodBoundaryAspect
{
public override void OnEntry(MethodExecutionArgs arg)
{
arg.FlowBehavior = FlowBehavior.Return; // original method's code won't execute
// ???
}
}
И применяем его к тестам:
[Declarative]
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedAttack() { }
Теперь возникает вопрос, что же делать в void OnEntry(MethodExecutionArgs)
и как узнать, в связи с каким DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>
он вызывается. Если вы хотите ответов - их есть у меня. Но сперва небольшой рефакторинг.
В `DeclarativeCaseAttribute<TValidator, TScript, TCheck>` реализуем `IDeclarativeTest`, помещая в интерфейсный метод код теста, класс `DefaultDeclarativeTest` же за ненадобностью удаляем.
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
public interface IDeclarativeTest
{
public void Test();
}
public class DeclarativeCaseAttribute<TValidator, TScript, TCheck> :
TestCaseAttribute, ITestBuilder, IDeclarativeTest
where TValidator : IValidator, new()
where TScript : IScript, new()
where TCheck : ICheck, new()
{
// ...
public void Test()
{
// Arrange
IValidator validator = new TValidator();
IScript script = new TScript();
ICheck check = new TCheck();
// Act
bool actual = check.Check(validator, script);
// Assert
Assert.AreEqual(expected, actual);
}
// ...
}
А теперь финт ушами. У NUnit.Framework.Internal.Test
есть замечательное свойство IPropertyBag Properties
. Используем его в DeclarativeCaseAttribute<TValidator, TScript, TCheck>
при создании тестов:
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) =>
base.BuildFrom(method, suite).Select(test =>
{
test.Properties.Add(typeof(DeclarativeCaseAttribute<TValidator, TScript, TCheck>).FullName, this);
test.FullName = CreateName(method, type => type.FullName);
test.Name = CreateName(method, type => type.Name);
return test;
});
Эти Properties
доступны в NUnit.Framework.TestContext.TestAdapter
через PropertyBagAdapter Properties
, а текущий тест доступен через NUnit.Framework.TestContext.CurrentContext.Test
. Для удобства можно упростить доступ к Properties
с помощью метода расширения:
using System.Collections.Generic;
using System.Linq;
using static NUnit.Framework.TestContext;
public static class TestAdapterExtensions
{
public static IEnumerable<object> GetProperties(this TestAdapter test) =>
test.Properties.Keys.SelectMany(key => test.Properties[key]);
}
И тогда реализация DeclarativeAttribute
выглядит так:
using MethodBoundaryAspect.Fody.Attributes;
using NUnit.Framework;
using System.Linq;
public class DeclarativeAttribute : OnMethodBoundaryAspect
{
public override void OnEntry(MethodExecutionArgs arg)
{
arg.FlowBehavior = FlowBehavior.Return; // original method's code won't execute
TestContext.CurrentContext.Test.GetProperties()
.OfType<IDeclarativeTest>().Single()
.Test();
}
}
Ранее была удалена защита от непустых тестовых методов. Настало время ее вернуть:
public override void OnEntry(MethodExecutionArgs arg)
{
var method = arg.Method.DeclaringType
.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance)
.Single(m => m.Name.Equals($"$_executor_{arg.Method.Name}"));
if (!method.IsIdle())
throw new InvalidOperationException("Test method is not idle, i.e. does something.");
TestContext.CurrentContext.Test.GetProperties()
.OfType<IDeclarativeTest>().Single()
.Test();
}
Чтобы не вешать DeclarativeAttribute
на каждый тестовый метод:
[TestFixture]
public class DemoTests
{
[Declarative]
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
public void TestOrdinaryValidator() { }
[Declarative]
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedValidator() { }
}
Можно применить его всего один раз ко всему тестовому классу:
[TestFixture, Declarative]
public class DemoTests
{
[DeclarativeCase<OrdinaryValidator, AdvancedAttack, AttackDetected>(false)]
public void TestOrdinaryValidator() { }
[DeclarativeCase<AdvancedValidator, AdvancedAttack, AttackDetected>(true)]
public void TestAdvancedValidator() { }
}
Но тогда пропатчатся все методы класса, и добавление такого теста:
[Test]
public void NormalTest()
{
Assert.Pass();
}
Приведет к его падению с сообщением "System.InvalidOperationException: Test method is not idle, i.e. does something", но, к счастью, это тоже лечится. Если вызывающий метод не помечен аттрибутом, реализующим IDeclarativeTest
, то досрочно выходим из void OnEntry(MethodExecutionArgs)
:
public override void OnEntry(MethodExecutionArgs arg)
{
var isIDeclarativeTest = arg.Method.CustomAttributes
.Select(attribute => attribute.AttributeType)
.Any(type => type.IsAssignableTo(typeof(IDeclarativeTest)));
if (!isIDeclarativeTest)
return;
var method = arg.Method.DeclaringType
.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance)
.Single(m => m.Name.Equals($"$_executor_{arg.Method.Name}"));
if (!method.IsIdle())
throw new InvalidOperationException("Test method is not idle, i.e. does something.");
TestContext.CurrentContext.Test.GetProperties()
.OfType<IDeclarativeTest>().Single()
.Test();
}
Конечно, void NormalTest()
все равно пропатчится, но внешне это будет незаметно.
Ответ
С помощью наиболее подходящего из доступных Fody-плагинов, особенностей NUnit и такой-то матери удалось реализовать концепцию декларативных тестов из первоначальной публикации, но по субъективным ощущениям решение получилось еще более костыльное. Возможно, написание собственного плагина могло бы улучшить ситуацию.