
Завершение работы над прошлой публикацией (читать которую для понимания этой совсем не обязательно) принесло мне не мир, но
[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 со ссылками на необходимые библиотеки:
<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); }
Создадим аттрибут 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); }
Теперь разнообразим тесты так:
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() { } }
Это происходит из-за исключения, которое обусловлено несовместимостью типов-аргументов аттрибута и типов-параметров тестового метода. Проблему можно решить как-то так:
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); } }
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; } }
Теперь другое дело.
Глава 2. Мечта витает в воздухе
Танцуй, как будто никто не видит. Пой, как будто никто не слышит. Используй preview фичи, как будто они уже в релизе. Кажется, последний совет не самый лучший, но руки чешутся, так что ставим галку в Tool > Options > Environment > Preview Features > Use previews of the .NET SDK (requires restart) и выбираем версию языка preview.
<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.
public interface IScript { void Execute(); }
Которые можно проверять валидаторами IValidator.
public interface IValidator { void Validate(IScript script); }
Перед выполнением внутри исполнителя 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.
public class Data { public bool IsChanged { get; private set; } public void Change() => IsChanged = true; }
Расположенные в хранилище 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 не пытаются их изменить.
public class HarmlessScript : IScript { void IScript.Execute() { } }
В отличие от атак Attack, которые бывают обычные OrdinaryAttack, продвинутые 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, способный соответственно пресечь даже продвинутую.
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.
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 всё же отображает правильное их количество.
Что ж, сделаем метод открытым. Заодно внесем еще одно небольшое изменение:
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)})";
Сами тесты можно безболезненно редуцировать до:
[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; }); }
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/
Добавить комментарий