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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
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/


Интересные статьи

Интересные статьи

Часто при разговорах с клиентами мы спрашиваем, как они ведут учет различных данных и используют ли они CRM-систему? Популярный ответ — мы работаем с Excel-файлами, а пот...
В конце августа мы выпустили зарплатный отчёт, в котором сравнили реальные зарплаты разработчиков с теми, что работодатели предлагают в вакансиях и выяснили, какие специализации поль...
За несколько дней до начала продаж в базах SiSoftware, GeekBench и UserBenchmark появились бенчмарки 64-ядерного процессора AMD Ryzen Threadripper 3990X. Теперь можно на фактах убедиться, нас...
Эта публикация написана после неоднократных обращений как клиентов, так и (к горести моей) партнеров. Темы обращений были разные, но причиной в итоге оказывался один и тот же сценарий, реализу...
Практически все коммерческие интернет-ресурсы создаются на уникальных платформах соответствующего типа. Среди них наибольшее распространение получил Битрикс24.