Do developers dream of declarative tests

https://habr.com/ru/post/681886/image

Completion of work on

last publication

(which it is not necessary to read to understand this one) brought me not peace, but

sword

dream of peace. A world where you can write more expressive strongly typed tests and instead of

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

use

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

And he was closer than I thought. And then it went and went…


Chapter 1

First, let’s create a Class Library project in Visual Studio (I use 2022) with links to the necessary libraries:

Let’s add a simple test and make sure it passes:

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

Then we change the test like this:

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

Create an attribute

GenericCaseAttribute

inheriting it from

TestCaseAttribute

and re-implementing the interface

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

And use it instead

TestCaseAttribute

in the falling test:

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

Now let’s diversify the tests like this:

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

This is due to an exception caused by an incompatibility between attribute argument types and test method parameter types. The problem can be solved somehow like this:

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

But when checking compatibility, it’s easy to miss some nuance and get a crooked bike (I’ve got a couple of times). Since

IMethodInfo IMethodInfo.MakeGenericMethod(params Type[])

he himself checks everything better than us, let’s leave it to him:

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

Now another matter.

Chapter 2

Dance like no one is watching. Sing like no one is listening. Use preview features as if they were already in the release. It seems that the last advice is not the best, but my hands itch, so we put a daw in Tool > Options > Environment > Preview Features > Use previews of the .NET SDK (requires restart) and select language version preview.

AT

GenericCaseAttribute

slightly change the constructor:

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

Adding generic attributes:

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

And we use them in tests:

[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() { }

Hooray! Works!

Now let’s try a more interesting example. Abstracting from the details, in the last publication (which prompted me to these fantasies) there was something about executable scripts

IScript

.

Which can be checked by validators

IValidator

.

Before execution inside the executor

Executor

.

In this case, you can change some important data

Data

.

located in storage

Store

.

Safe scripts

HarmlessScript

don’t try to change them.

Unlike attacks.

Attack

which are common

OrdinaryAttack

advanced

AdvancedAttack

and excellent

SuperiorAttack

.

An ordinary validator is called upon to resist them.

OrdinaryValidator

capable of repelling only a normal attack, and an advanced

AdvancedValidator

capable of stopping even an advanced one, respectively.

The interaction of these entities was verified by tests:

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

Now let’s create a separate entity

ICheck

to split the check that the script was executed

HasExecuted

and checking for data changes

DataChanged

.

And we use it to rewrite the tests:

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

And then we use

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

In my opinion, it is nice and corresponds to the form given at the beginning of the publication.

Chapter 3

Carefully!!! Further research of the author may be a perversion!

Suppose we need to separate tests by implementation IScript.

It turns out so cumbersome that it’s better to hide under the spoiler

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

But you can fix this by highlighting the method

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

Is it possible to get rid of his call?

Create an attribute

DeclarativeCaseAttribute<TValidator, TScript, TCheck>

in which we re-implement

ITestBuilder

and also transfer into it

void TestSuperiorAttack<TValidator, TScript, TCheck>(bool)

from test class:

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

The tests now do nothing and look like this:

[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()
{
}

For convenience, let’s simplify

void Test<TValidator, TScript, TCheck>(bool)

before:

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

And let’s get to the fun part. Let’s try in

DeclarativeCaseAttribute<TValidator, TScript, TCheck>

replace the tests in such a simple way:

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

Tests have changed names, and they all fail with the message

“Method is not public”

, and by double-clicking on the name of the test in the Test Explorer, a transition occurs somewhere in the wrong place. Besides there were any superfluous not started tests. But Output > Tests still displays the correct number of them.

Well, let’s make the method public. Let’s make one more small change:

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

The tests fail again, but the message has changed to:

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)

It looks like the test runner is trying to call the overridden method on the original test class. But a little street magic solves the problem. Enough to do

void Test(bool)

static, and the tests will work.

For me, this behavior is not obvious, I’m also not sure that it is clearly documented somewhere, so we’ll return to this place.

. For now, let’s add

DeclarativeCaseAttribute<TValidator, TScript, TCheck>

method

string CreateName(TestMethod, Test, IMethodInfo, Func<Test, string>, Func<Type, string>)

and use it:

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

The tests themselves can be painlessly reduced to:

[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() { }

Prank was a success! Now we can write tests without a method body, described by an attribute and contained in it. I have no idea where it can be useful in life, but it looks entertaining.

Epilogue

Let’s go back to the dirty hack with the static method when substituting the test and try to replace it with another solution.
Add an interface IDeclarativeTest:

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

And in

DeclarativeCaseAttribute<TValidator, TScript, TCheck>

we will require its implementation by the test class, so that when replacing the test, it is guaranteed to be able to call the interface method:

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

For

IDeclarativeTest

create an implementation

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

And we use it when implementing

IDeclarativeTest

the test class itself:

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...
}

And one moment. If the test method is not empty, then its contents will still not be executed. Therefore, in order to avoid cognitive dissonance in

DeclarativeCaseAttribute<TValidator, TScript, TCheck>

you can prevent it from being applied to non-empty methods:

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

Similar Posts

Leave a Reply