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

Комментарии (0)