Мечтают ли разработчики о декларативных тестах

от автора

image
Завершение работы над прошлой публикацией (читать которую для понимания этой совсем не обязательно) принесло мне не мир, но меч мечту о мире. Мире, в котором можно писать более выразительные строго типизированные тесты и вместо

[TestCase(typeof(Impl), "command")] public void Test(Type impl, string cmd) =>     ((I)Activator.CreateInstance(impl)).Do(cmd); 

использовать

[TestCase<Impl>("command")] public void Test<TImpl>(string cmd) where TImpl : I, new() =>     new TImpl().Do(cmd); 

И он оказался ближе, чем я мог подумать. А дальше пошло-поехало…

Глава 1. Суровая реальность

Для начала создадим в Visual Studio (я пользуюсь 2022) проект Class Library со ссылками на необходимые библиотеки:

TestingDreams.csproj

<Project Sdk="Microsoft.NET.Sdk">   <PropertyGroup>     <TargetFramework>net6.0</TargetFramework>   </PropertyGroup>   <ItemGroup>     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />     <PackageReference Include="NUnit" Version="3.13.3" />     <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />   </ItemGroup> </Project> 

Добавим простой тест и убедимся, что он проходит:

using NUnit.Framework; using System;  public class A { } public class B : A { } public class C : A { } public class D : A { }  [TestFixture] public class SampleTests {     [TestCase(typeof(B), typeof(A), true)]     [TestCase(typeof(C), typeof(A), true)]     [TestCase(typeof(C), typeof(B), false)]     public void Test(Type tSub, Type tSuper, bool expected)     {         var actual = tSub.IsAssignableTo(tSuper);         Assert.AreEqual(expected, actual);     } } 

Затем изменим тест таким образом:

[TestCase(typeof(B), typeof(A), true)] [TestCase(typeof(C), typeof(A), true)] [TestCase(typeof(C), typeof(B), false)] public void Test<TSub, TSuper>(bool expected)     where TSub : A     where TSuper : A {     var actual = typeof(TSub).IsAssignableTo(typeof(TSuper));     Assert.AreEqual(expected, actual); } 

Все кейсы падают

image

Создадим аттрибут GenericCaseAttribute, унаследовав его от TestCaseAttribute и заново реализовав интерфейс ITestBuilder:

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(Type[] typeArguments, params object[] arguments)         : base(arguments) => this.typeArguments = typeArguments.ToArray();      public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) =>         base.BuildFrom(             method.IsGenericMethodDefinition || typeArguments.Any() ?             method.MakeGenericMethod(typeArguments.ToArray()) :             method,             suite); } 

И используем его вместо TestCaseAttribute в падающем тесте:

[GenericCase(new[] { typeof(B), typeof(A) }, true)] [GenericCase(new[] { typeof(C), typeof(A) }, true)] [GenericCase(new[] { typeof(C), typeof(B) }, false)] public void Test<TSub, TSuper>(bool expected)     where TSub : A     where TSuper : A {     var actual = typeof(TSub).IsAssignableTo(typeof(TSuper));     Assert.AreEqual(expected, actual); } 

Тесты снова зелёные (обратите внимание, как изменилось их отображение в Test Explorer)

image

Теперь разнообразим тесты так:

public interface I1 { } public interface I2 { } public interface I3 { } public interface I4 { } public class A : I1, I2 { } public class B : A, I3 { } public class C : A, I3 { } public class D : A, I4 { }  [TestFixture] public class SampleTests {     [GenericCase(new Type[] { }, false)]     [GenericCase(new[] { typeof(A) }, false)]     [GenericCase(new[] { typeof(C), typeof(B), typeof(A) }, false)]     [GenericCase(new[] { typeof(B), typeof(A) }, true)]     [GenericCase(new[] { typeof(C), typeof(B) }, false)]     [GenericCase(new[] { typeof(D), typeof(A) }, false)]     public void Test<TSub, TSuper>(bool expected)         where TSub : A, I3         where TSuper : I1, I2     {         var actual = typeof(TSub).IsAssignableTo(typeof(TSuper));         Assert.AreEqual(expected, actual);     }      [GenericCase(new Type[] { })]     [GenericCase(new[] { typeof(object) })]     public void Test() { } } 

Внезапно, они не только не запускаются, но даже не обнаруживаются

image

Это происходит из-за исключения, которое обусловлено несовместимостью типов-аргументов аттрибута и типов-параметров тестового метода. Проблему можно решить как-то так:

public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) {     if (IsIncompatible(method))     {         // return ...     }      return base.BuildFrom(         method.IsGenericMethodDefinition || typeArguments.Any() ?         method.MakeGenericMethod(typeArguments.ToArray()) :         method,         suite); } 

Но при проверке совместимости легко упустить какой-нибудь нюанс и получить кривой велосипед (я вот получил пару раз). Раз уж IMethodInfo IMethodInfo.MakeGenericMethod(params Type[]) сам всё лучше нас проверяет, оставим это ему:

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);     } } 

TestMethodExtensions

using NUnit.Framework.Interfaces; using NUnit.Framework.Internal; using System.Collections.Generic;  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;     } } 

Теперь другое дело.

Смущает, что Test Explorer вводит в заблуждение относительно количества тестов

image

Хотя, если копнуть глубже, раскрывает все карты

image

image

Зато Output > Tests и не помышляет об обмане

image

Глава 2. Мечта витает в воздухе

Танцуй, как будто никто не видит. Пой, как будто никто не слышит. Используй preview фичи, как будто они уже в релизе. Кажется, последний совет не самый лучший, но руки чешутся, так что ставим галку в Tool > Options > Environment > Preview Features > Use previews of the .NET SDK (requires restart) и выбираем версию языка preview.

TestingDreams.csproj

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net6.0</TargetFramework> <LangVersion>preview</LangVersion> <!--enable generic attributes--> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" /> <PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" /> </ItemGroup> </Project> 

В GenericCaseAttribute немного меняем конструктор:

public GenericCaseAttribute(params object[] arguments)     : base(arguments) => typeArguments = GetType().GetGenericArguments(); 

Добавляем обобщенные атрибуты:

public class GenericCaseAttribute<T> : GenericCaseAttribute {     public GenericCaseAttribute(params object[] arguments)         : base(arguments) { } }  public class GenericCaseAttribute<T1, T2> : GenericCaseAttribute {     public GenericCaseAttribute(params object[] arguments)         : base(arguments) { } }  public class GenericCaseAttribute<T1, T2, T3> : GenericCaseAttribute {     public GenericCaseAttribute(params object[] arguments)         : base(arguments) { } } 

И используем их в тестах:

[GenericCase(false)] [GenericCase<A>(false)] [GenericCase<C, B, A>(false)] [GenericCase<B, A>(true)] [GenericCase<C, B>(false)] [GenericCase<D, A>(false)] public void Test<TSub, TSuper>(bool expected)     where TSub : A, I3     where TSuper : I1, I2 {     var actual = typeof(TSub).IsAssignableTo(typeof(TSuper));     Assert.AreEqual(expected, actual); }  [GenericCase] [GenericCase<object>] public void Test() { } 

Ура! Работает!
А теперь попробуем более интересный пример. Абстрагируясь от деталей, в прошлой публикации (которая и натолкнула меня на эти фантазии) было что-то про исполняемые скрипты IScript.

IScript

public interface IScript {     void Execute(); } 

Которые можно проверять валидаторами IValidator.

IValidator

public interface IValidator {     void Validate(IScript script); } 

Перед выполнением внутри исполнителя Executor.

Executor

public class Executor {     readonly IValidator validator;      public Executor(IValidator validator) =>         this.validator = validator;      public void Execute(IScript script)     {         validator.Validate(script);         script.Execute();     } } 

При этом можно изменять какие-то важные данные Data.

Data

public class Data {     public bool IsChanged { get; private set; }      public void Change() =>         IsChanged = true; } 

Расположенные в хранилище Store.

Store

public static class Store {     private static readonly Dictionary<string, Data> store = new();      public static Data GetData(string id) =>         store.TryGetValue(id, out var data) ? data : (store[id] = new()); } 

Безопасные скрипты HarmlessScript не пытаются их изменить.

HarmlessScript

public class HarmlessScript : IScript {     void IScript.Execute() { } } 

В отличие от атак Attack, которые бывают обычные OrdinaryAttack, продвинутые AdvancedAttack и превосходные SuperiorAttack.

Attack, SuperiorAttack, AdvancedAttack, SuperiorAttack

public abstract class Attack : IScript {     void IScript.Execute() =>         Store.GetData($"{GetHashCode()}").Change(); }  public class OrdinaryAttack : Attack { }  public class AdvancedAttack : OrdinaryAttack { }  public class SuperiorAttack : AdvancedAttack { } 

Противостоять им призваны обычный валидатор OrdinaryValidator, способный отразить только обычную атаку, и продвинутый AdvancedValidator, способный соответственно пресечь даже продвинутую.

OrdinaryValidator, AdvancedValidator

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 && script is not SuperiorAttack)             throw new Exception("Attack detected.");     } } 

Взаимодействие этих сущностей проверялось тестами:

using NUnit.Framework; using System;  [TestFixture] public class DemoTests {     [TestCase(typeof(OrdinaryValidator), typeof(HarmlessScript), true, false)]     [TestCase(typeof(AdvancedValidator), typeof(HarmlessScript), true, false)]     [TestCase(typeof(OrdinaryValidator), typeof(OrdinaryAttack), false, false)]     [TestCase(typeof(AdvancedValidator), typeof(OrdinaryAttack), false, false)]     [TestCase(typeof(OrdinaryValidator), typeof(AdvancedAttack), true, true)]     [TestCase(typeof(AdvancedValidator), typeof(AdvancedAttack), false, false)]     [TestCase(typeof(OrdinaryValidator), typeof(SuperiorAttack), true, true)]     [TestCase(typeof(AdvancedValidator), typeof(SuperiorAttack), true, true)]     public void Test(Type validatorType, Type scriptType, bool hasExecuted, bool dataChanged)     {         // Arrange         IValidator validator = (IValidator)Activator.CreateInstance(validatorType);         IScript script = (IScript)Activator.CreateInstance(scriptType);          // Act         Exception exception = default;         try         {             new Executor(validator).Execute(script);         }         catch (Exception e)         {             exception = e;         }          // Asert         Assert.AreEqual(hasExecuted, exception is null);         Assert.AreEqual(dataChanged, Store.GetData($"{script.GetHashCode()}").IsChanged);     } } 

Теперь создадим отдельную сущность ICheck, чтобы разделить провеку факта выполнения скрипта HasExecuted и проверку изменения данных DataChanged.

ICheck, HasExecuted, DataChanged

public interface ICheck {     bool Check(IValidator validator, IScript script); }  public class HasExecuted : ICheck {     public bool Check(IValidator validator, IScript script)     {         try         {             new Executor(validator).Execute(script);             return true;         }         catch         {             return false;         }     } }  public class DataChanged : ICheck {     public bool Check(IValidator validator, IScript script)     {         try         {             new Executor(validator).Execute(script);         }         catch         {         }          return Store.GetData($"{script.GetHashCode()}").IsChanged;     } } 

И используем её, чтобы переписать тесты:

[TestCase(typeof(OrdinaryValidator), typeof(HarmlessScript), typeof(HasExecuted), true)] [TestCase(typeof(OrdinaryValidator), typeof(HarmlessScript), typeof(DataChanged), false)] [TestCase(typeof(AdvancedValidator), typeof(HarmlessScript), typeof(HasExecuted), true)] [TestCase(typeof(AdvancedValidator), typeof(HarmlessScript), typeof(DataChanged), false)] [TestCase(typeof(OrdinaryValidator), typeof(OrdinaryAttack), typeof(HasExecuted), false)] [TestCase(typeof(OrdinaryValidator), typeof(OrdinaryAttack), typeof(DataChanged), false)] [TestCase(typeof(AdvancedValidator), typeof(OrdinaryAttack), typeof(HasExecuted), false)] [TestCase(typeof(AdvancedValidator), typeof(OrdinaryAttack), typeof(DataChanged), false)] [TestCase(typeof(OrdinaryValidator), typeof(AdvancedAttack), typeof(HasExecuted), true)] [TestCase(typeof(OrdinaryValidator), typeof(AdvancedAttack), typeof(DataChanged), true)] [TestCase(typeof(AdvancedValidator), typeof(AdvancedAttack), typeof(HasExecuted), false)] [TestCase(typeof(AdvancedValidator), typeof(AdvancedAttack), typeof(DataChanged), false)] [TestCase(typeof(OrdinaryValidator), typeof(SuperiorAttack), typeof(HasExecuted), true)] [TestCase(typeof(OrdinaryValidator), typeof(SuperiorAttack), typeof(DataChanged), true)] [TestCase(typeof(AdvancedValidator), typeof(SuperiorAttack), typeof(HasExecuted), true)] [TestCase(typeof(AdvancedValidator), typeof(SuperiorAttack), typeof(DataChanged), true)] public void Test(Type validatorType, Type scriptType, Type checkType, bool expected) {     // Arrange     IValidator validator = (IValidator)Activator.CreateInstance(validatorType);     IScript script = (IScript)Activator.CreateInstance(scriptType);     ICheck check = (ICheck)Activator.CreateInstance(checkType);      // Act     bool actual = check.Check(validator, script);      // Assert     Assert.AreEqual(expected, actual); } 

А далее воспользуемся GenericCaseAttribute:

[GenericCase<OrdinaryValidator, HarmlessScript, HasExecuted>(true)] [GenericCase<OrdinaryValidator, HarmlessScript, DataChanged>(false)] [GenericCase<AdvancedValidator, HarmlessScript, HasExecuted>(true)] [GenericCase<AdvancedValidator, HarmlessScript, DataChanged>(false)] [GenericCase<OrdinaryValidator, OrdinaryAttack, HasExecuted>(false)] [GenericCase<OrdinaryValidator, OrdinaryAttack, DataChanged>(false)] [GenericCase<AdvancedValidator, OrdinaryAttack, HasExecuted>(false)] [GenericCase<AdvancedValidator, OrdinaryAttack, DataChanged>(false)] [GenericCase<OrdinaryValidator, AdvancedAttack, HasExecuted>(true)] [GenericCase<OrdinaryValidator, AdvancedAttack, DataChanged>(true)] [GenericCase<AdvancedValidator, AdvancedAttack, HasExecuted>(false)] [GenericCase<AdvancedValidator, AdvancedAttack, DataChanged>(false)] [GenericCase<OrdinaryValidator, SuperiorAttack, HasExecuted>(true)] [GenericCase<OrdinaryValidator, SuperiorAttack, DataChanged>(true)] [GenericCase<AdvancedValidator, SuperiorAttack, HasExecuted>(true)] [GenericCase<AdvancedValidator, SuperiorAttack, DataChanged>(true)] public 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); } 

По-моему, симпатично и соответствует форме, приведенной в начале публикации.

При желании тело метода можно даже к однострочнику привести

public void Test<TValidator, TScript, TCheck>(bool expected)     where TValidator : IValidator, new()     where TScript : IScript, new()     where TCheck : ICheck, new() =>     Assert.AreEqual(expected, new TCheck().Check(new TValidator(), new TScript())); 

Глава 3. Куда приводят мечты

Осторожно!!! Дальнейшие изыскания автора могут оказаться извращением!

Предположим, нам нужно разделить тесты по реализации IScript.

Получается так громоздко, что лучше под спойлер спрятать

[GenericCase<OrdinaryValidator, HarmlessScript, HasExecuted>(true)] [GenericCase<OrdinaryValidator, HarmlessScript, DataChanged>(false)] [GenericCase<AdvancedValidator, HarmlessScript, HasExecuted>(true)] [GenericCase<AdvancedValidator, HarmlessScript, DataChanged>(false)] public void TestHarmlessScript<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); }  [GenericCase<OrdinaryValidator, OrdinaryAttack, HasExecuted>(false)] [GenericCase<OrdinaryValidator, OrdinaryAttack, DataChanged>(false)] [GenericCase<AdvancedValidator, OrdinaryAttack, HasExecuted>(false)] [GenericCase<AdvancedValidator, OrdinaryAttack, DataChanged>(false)] public void TestOrdinaryAttack<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); }  [GenericCase<OrdinaryValidator, AdvancedAttack, HasExecuted>(true)] [GenericCase<OrdinaryValidator, AdvancedAttack, DataChanged>(true)] [GenericCase<AdvancedValidator, AdvancedAttack, HasExecuted>(false)] [GenericCase<AdvancedValidator, AdvancedAttack, DataChanged>(false)] public void TestAdvancedAttack<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); }  [GenericCase<OrdinaryValidator, SuperiorAttack, HasExecuted>(true)] [GenericCase<OrdinaryValidator, SuperiorAttack, DataChanged>(true)] [GenericCase<AdvancedValidator, SuperiorAttack, HasExecuted>(true)] [GenericCase<AdvancedValidator, SuperiorAttack, DataChanged>(true)] public void TestSuperiorAttack<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); } 

Но можно это исправить, выделив метод void Test<TValidator, TScript, TCheck>(bool):

[GenericCase<OrdinaryValidator, HarmlessScript, HasExecuted>(true)] [GenericCase<OrdinaryValidator, HarmlessScript, DataChanged>(false)] [GenericCase<AdvancedValidator, HarmlessScript, HasExecuted>(true)] [GenericCase<AdvancedValidator, HarmlessScript, DataChanged>(false)] public void TestHarmlessScript<TValidator, TScript, TCheck>(bool expected)     where TValidator : IValidator, new()     where TScript : IScript, new()     where TCheck : ICheck, new() {     Test<TValidator, TScript, TCheck>(expected); }  [GenericCase<OrdinaryValidator, OrdinaryAttack, HasExecuted>(false)] [GenericCase<OrdinaryValidator, OrdinaryAttack, DataChanged>(false)] [GenericCase<AdvancedValidator, OrdinaryAttack, HasExecuted>(false)] [GenericCase<AdvancedValidator, OrdinaryAttack, DataChanged>(false)] public void TestOrdinaryAttack<TValidator, TScript, TCheck>(bool expected)     where TValidator : IValidator, new()     where TScript : IScript, new()     where TCheck : ICheck, new() {     Test<TValidator, TScript, TCheck>(expected); }  [GenericCase<OrdinaryValidator, AdvancedAttack, HasExecuted>(true)] [GenericCase<OrdinaryValidator, AdvancedAttack, DataChanged>(true)] [GenericCase<AdvancedValidator, AdvancedAttack, HasExecuted>(false)] [GenericCase<AdvancedValidator, AdvancedAttack, DataChanged>(false)] public void TestAdvancedAttack<TValidator, TScript, TCheck>(bool expected)     where TValidator : IValidator, new()     where TScript : IScript, new()     where TCheck : ICheck, new() {     Test<TValidator, TScript, TCheck>(expected); }  [GenericCase<OrdinaryValidator, SuperiorAttack, HasExecuted>(true)] [GenericCase<OrdinaryValidator, SuperiorAttack, DataChanged>(true)] [GenericCase<AdvancedValidator, SuperiorAttack, HasExecuted>(true)] [GenericCase<AdvancedValidator, SuperiorAttack, DataChanged>(true)] public void TestSuperiorAttack<TValidator, TScript, TCheck>(bool expected)     where TValidator : IValidator, new()     where TScript : IScript, new()     where TCheck : ICheck, new() {     Test<TValidator, TScript, TCheck>(expected); }  private 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>, в котором заново реализуем ITestBuilder, а также перенесем в него void TestSuperiorAttack<TValidator, TScript, TCheck>(bool) из тестового класса:

using NUnit.Framework; using NUnit.Framework.Interfaces; using NUnit.Framework.Internal; using System.Collections.Generic;  public class DeclarativeCaseAttribute<TValidator, TScript, TCheck>     : GenericCaseAttribute<TValidator, TScript, TCheck>, ITestBuilder         where TValidator : IValidator, new()         where TScript : IScript, new()         where TCheck : ICheck, new() {     public DeclarativeCaseAttribute(params object[] arguments)         : base(arguments) { }      public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite)     {         return base.BuildFrom(method, suite);     }      private 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);     } } 

Тесты теперь ничего не делают и выглядят так:

[DeclarativeCase<OrdinaryValidator, HarmlessScript, HasExecuted>(true)] [DeclarativeCase<OrdinaryValidator, HarmlessScript, DataChanged>(false)] [DeclarativeCase<AdvancedValidator, HarmlessScript, HasExecuted>(true)] [DeclarativeCase<AdvancedValidator, HarmlessScript, DataChanged>(false)] public void TestHarmlessScript<TValidator, TScript, TCheck>(bool expected)     where TValidator : IValidator, new()     where TScript : IScript, new()     where TCheck : ICheck, new() { }  [DeclarativeCase<OrdinaryValidator, OrdinaryAttack, HasExecuted>(false)] [DeclarativeCase<OrdinaryValidator, OrdinaryAttack, DataChanged>(false)] [DeclarativeCase<AdvancedValidator, OrdinaryAttack, HasExecuted>(false)] [DeclarativeCase<AdvancedValidator, OrdinaryAttack, DataChanged>(false)] public void TestOrdinaryAttack<TValidator, TScript, TCheck>(bool expected)     where TValidator : IValidator, new()     where TScript : IScript, new()     where TCheck : ICheck, new() { }  [DeclarativeCase<OrdinaryValidator, AdvancedAttack, HasExecuted>(true)] [DeclarativeCase<OrdinaryValidator, AdvancedAttack, DataChanged>(true)] [DeclarativeCase<AdvancedValidator, AdvancedAttack, HasExecuted>(false)] [DeclarativeCase<AdvancedValidator, AdvancedAttack, DataChanged>(false)] public void TestAdvancedAttack<TValidator, TScript, TCheck>(bool expected)     where TValidator : IValidator, new()     where TScript : IScript, new()     where TCheck : ICheck, new() { }  [DeclarativeCase<OrdinaryValidator, SuperiorAttack, HasExecuted>(true)] [DeclarativeCase<OrdinaryValidator, SuperiorAttack, DataChanged>(true)] [DeclarativeCase<AdvancedValidator, SuperiorAttack, HasExecuted>(true)] [DeclarativeCase<AdvancedValidator, SuperiorAttack, DataChanged>(true)] public void TestSuperiorAttack<TValidator, TScript, TCheck>(bool expected)     where TValidator : IValidator, new()     where TScript : IScript, new()     where TCheck : ICheck, new() { } 

Для удобства упростим void Test<TValidator, TScript, TCheck>(bool) до:

private void Test(bool expected) {     // 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> подменить тесты таким нехитрым способом:

public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) {     var @base = this as TestCaseAttribute;     var type = GetType();     var test = type.GetMethod(nameof(Test), BindingFlags.NonPublic | BindingFlags.Instance);      return @base.BuildFrom(         new MethodWrapper(type, test),         new TestFixture(new TypeWrapper(type), Arguments)); } 

У тестов поменялись имена, и все они падают с сообщением «Method is not public», а по двойному клику на имени теста в Test Explorer происходит переход куда-то не туда. Кроме того появились какие-то лишние незапущенные тесты. Но Output > Tests всё же отображает правильное их количество.

Выглядит как-то так

image

image

Что ж, сделаем метод открытым. Заодно внесем еще одно небольшое изменение:

public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) {     var @base = this as TestCaseAttribute;     var type = GetType();      return @base.BuildFrom(         new MethodWrapper(type, nameof(Test)),         new TestFixture(new TypeWrapper(type), Arguments)); }  public void Test(bool expected) {     // 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); } 

Тесты снова падают, но сообщение изменилось на:

Message:      System.Reflection.TargetException : Object does not match target type.  Stack Trace:      RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)     MethodBase.Invoke(Object obj, Object[] parameters)     Reflect.InvokeMethod(MethodInfo method, Object fixture, Object[] args) 

Кажется, исполнитель тестов пытается вызвать подмененный метод на оригинальном тестовом классе. Но немного уличной магии решает проблему. Достаточно сделать void Test(bool) статическим, и тесты заработают. Для меня такое поведение неочевидно, также не уверен, что оно где-то внятно задокументированно, так что к этому месту мы еще вернемся. А пока добавим в DeclarativeCaseAttribute<TValidator, TScript, TCheck> метод string CreateName(TestMethod, Test, IMethodInfo, Func<Test, string>, Func<Type, string>) и используем его:

public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite) {     var @base = this as TestCaseAttribute;     var type = GetType();      return @base.BuildFrom(         new MethodWrapper(type, nameof(Test)),         new TestFixture(new TypeWrapper(type), Arguments))         .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 readonly IReadOnlyCollection<Type> types = new[] {     typeof(TValidator),     typeof(TScript),     typeof(TCheck) };  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)})"; 

Теперь Test Explorer ведет себя правильно

image

Сами тесты можно безболезненно редуцировать до:

[DeclarativeCase<OrdinaryValidator, HarmlessScript, HasExecuted>(true)] [DeclarativeCase<OrdinaryValidator, HarmlessScript, DataChanged>(false)] [DeclarativeCase<AdvancedValidator, HarmlessScript, HasExecuted>(true)] [DeclarativeCase<AdvancedValidator, HarmlessScript, DataChanged>(false)] public void TestHarmlessScript() { }  [DeclarativeCase<OrdinaryValidator, OrdinaryAttack, HasExecuted>(false)] [DeclarativeCase<OrdinaryValidator, OrdinaryAttack, DataChanged>(false)] [DeclarativeCase<AdvancedValidator, OrdinaryAttack, HasExecuted>(false)] [DeclarativeCase<AdvancedValidator, OrdinaryAttack, DataChanged>(false)] public void TestOrdinaryAttack() { }  [DeclarativeCase<OrdinaryValidator, AdvancedAttack, HasExecuted>(true)] [DeclarativeCase<OrdinaryValidator, AdvancedAttack, DataChanged>(true)] [DeclarativeCase<AdvancedValidator, AdvancedAttack, HasExecuted>(false)] [DeclarativeCase<AdvancedValidator, AdvancedAttack, DataChanged>(false)] public void TestAdvancedAttack() { }  [DeclarativeCase<OrdinaryValidator, SuperiorAttack, HasExecuted>(true)] [DeclarativeCase<OrdinaryValidator, SuperiorAttack, DataChanged>(true)] [DeclarativeCase<AdvancedValidator, SuperiorAttack, HasExecuted>(true)] [DeclarativeCase<AdvancedValidator, SuperiorAttack, DataChanged>(true)] public void TestSuperiorAttack() { } 

Шалость удалась! Теперь мы можем писать тесты без тела метода, описываемые аттрибутом и в нем же содержащиеся. Слабо представляю, где это в жизни может пригодиться, но выглядит занимательно.

Эпилог

Вернемся к грязному хаку со статическим методом при подмене теста и попробуем заменить его другим решением.

Добавим интерфейс IDeclarativeTest:

public interface IDeclarativeTest {     void Test<TValidator, TScript, TCheck>(bool expected)         where TValidator : IValidator, new()         where TScript : IScript, new()         where TCheck : ICheck, new(); } 

И в DeclarativeCaseAttribute<TValidator, TScript, TCheck> потребуем его реализации тестовым классом, чтобы при подмене теста гарантированно иметь возможность вызывать интерфейсный метод:

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         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;     }); } 

Для IDeclarativeTest создадим реализацию DefaultDeclarativeTest:

using NUnit.Framework;  public class DefaultDeclarativeTest : IDeclarativeTest {     public 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);     } } 

И используем ее при реализации IDeclarativeTest самим тестовым классом:

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() =>         new DefaultDeclarativeTest().Test<TValidator, TScript, TCheck>(expected);      // Tests... } 

И еще один момент. Если тестовый метод не пуст, то его содержимое все равно не выполнится. Поэтому во избежание когнитивного диссонанса в DeclarativeCaseAttribute<TValidator, TScript, TCheck> можно запретить применять его к непустым методам:

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;     }); } 

MethodInfoExtensions

using System.Collections.Generic; using System.Linq; using System.Reflection.Emit; using System.Reflection; using System.Runtime.CompilerServices;  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(); } 


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


Комментарии

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

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