Завершение работы над прошлой публикацией (читать которую для понимания этой совсем не обязательно) принесло мне не мир, но
[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);
}
Все кейсы падают
Создадим аттрибут
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)
Теперь разнообразим тесты так:
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);
}
}
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 вводит в заблуждение относительно количества тестов
Хотя, если копнуть глубже, раскрывает все карты
Зато Output > Tests и не помышляет об обмане
Глава 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 всё же отображает правильное их количество.
Выглядит как-то так
Что ж, сделаем метод открытым. Заодно внесем еще одно небольшое изменение:
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 ведет себя правильно
Сами тесты можно безболезненно редуцировать до:
[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();
}