Мечтают ли разработчики о декларативных тестах — 2. Особое мнение

от автора

По непроверенным данным, половина несчастных случаев происходит после слов «смотри, как я умею«, другая же половина — после «ерунда, смотри, как надо«.

Тут один приятель, увидев фокус с тестами без тестов с использованием обобщенных аттрибутов из 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 и используем его для предотвращения падения тестов с сообщением «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
Выбираем подходящий fody weaver в NuGet
Выбираем подходящий fody weaver в NuGet

Идем в пример работы с аспектом и по косвенным признакам видим, что он представляет из себя класс. А 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 и такой-то матери удалось реализовать концепцию декларативных тестов из первоначальной публикации, но по субъективным ощущениям решение получилось еще более костыльное. Возможно, написание собственного плагина могло бы улучшить ситуацию.

Fody выводит реализацию декларативных тестов на технически новый уровень (gif)


ссылка на оригинал статьи https://habr.com/ru/post/684056/


Комментарии

Добавить комментарий

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