5
\$\begingroup\$

I was thinking about how unit tests require you to manually instantiate all your dependencies, even though many of them are the same for every test or don't really matter (such as logging, where people usually just inject mocks). As a possible solution to this problem, I wrote a unit test framework that injects any known dependencies into the class or method, or null if a dependency is not known. It also supports theory-type tests with nested methods. Here are some quick examples before we dive into the code:

Declaring a test

[Assertion]
public void MyTest() {}

Configuring dependencies

public interface IBar {}
public class Bar : IBar {}
public class Foo
{
   public Foo(IBar bar) {}
}

public class AssertionConfiguration : AssertionSet
{
   public override void Configure()
   {
       this.AddSingleton<Foo>();
       this.AddSingleton<IBar, Bar>();
   }
}

Using the AssertionSet attribute

This goes on either a class, where it applies to all tests, or on a method, where it overrides any assertion set declared on the class.

[AssertionSet(typeof(AssertionConfiguration))]
[Assertion]
public void MyTest(Foo foo, IBar bar) {}

Theory-type tests

public void MyTest([Callback] Action action)
{
    action();

    [Assertion]
    public void Foo() {}

    [Assertion]
    public void Bar() {}
}

Skipping a test

Apply the Skip attribute to the test method.

[Skip(Reason = "Doesn't work because ...")]

Assertions

A successful test is considered to be one that completes without exceptions, so while these assertion extension methods are provided, any assertion library will work.

obj.AssertThat<MyType>(o => o == 1);
obj.AssertThatAsync<MyType>(async o => (await o.GetSomething()) == 1);
obj.AssertException<MyType, Exception>(o => o.ThrowException());
obj.AssertExceptionAsync<MyType, Exception>(async o => await o.ThrowException());

Known Limitations

Each test is run in a new instance of its containing class. This prevents accidental shared state between tests and allows tests to override the assertion set used. It also prevents intentional shared state between tests, but that can be overcome by defining and injecting a singleton of state.

Test names must be unique; otherwise, the adapter won't be able to find a match, as it only looks methods up by name. You can't have Foo(IFoo foo, Bar bar) and Foo(Bar bar, IFoo foo), either as parent tests or nested theory tests. The test discoverer will find them, but the runner will crash running them; this could be mitigated with a VS analyzer, but I haven't written one yet.

Test method return types must be awaitable. void, Task, ValueTask, and other awaitable types are good. int and other non-awaitable types will crash.

Top-level methods (available in C# 9) cannot be theories. The reason behind this that nested methods are generated as top-level methods with a non-speakable name (something like g__Name|0__0). All top-level methods are given non-speakable names and treated as if they were a child of a Main method, so there is no way for me to identify which parent method they belong to.

Testing framework

namespace SmartUnit
{
    [AttributeUsage(AttributeTargets.Method)]
    public class AssertionAttribute : Attribute
    {
        public string? Name { get; set; }
    }

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
    public class AssertionSetAttribute : Attribute
    {
        public Type AssertionSetType { get; }

        public AssertionSetAttribute(Type assertionSetType)
        {
            if (assertionSetType.BaseType != typeof(AssertionSet))
            {
                throw new ArgumentException(null, nameof(assertionSetType));
            }

            AssertionSetType = assertionSetType;
        }
    }

    [AttributeUsage(AttributeTargets.Parameter)]
    public class CallbackAttribute : Attribute { }

    [AttributeUsage(AttributeTargets.Method)]
    public class SkipAttribute : Attribute
    {
        public string? Reason { get; set; }
    }

    public abstract class AssertionSet : ServiceCollection
    {
        public abstract void Configure();
    }

    internal class AssertionException : Exception
    {
        internal AssertionException(string? message) : base(message) { }
    }

    public static class AssertExtensions
    {
        public static T AssertThat<T>(this T obj, Func<T, bool> assertion, string? failureMessage = null)
        {
            if (assertion(obj))
            {
                return obj;
            }

            throw new AssertionException(failureMessage);
        }

        public static async Task<T> AssertThatAsync<T>(this T obj, Func<T, Task<bool>> assertion, string? failureMessage = null)
        {
            if (await assertion(obj))
            {
                return obj;
            }

            throw new AssertionException(failureMessage);
        }

        public static T AssertException<T, TException>(this T obj, Action<T> assertion, string? failureMessage = null) where TException : Exception
        {
            try
            {
                assertion(obj);
            }
            catch (TException)
            {
                return obj;
            }

            throw new AssertionException(failureMessage);
        }

        public static async Task<T> AssertExceptionAsync<T, TException>(this T obj, Func<T, Task> assertion, string? failureMessage = null) where TException : Exception
        {
            try
            {
                await assertion(obj);
            }
            catch (TException)
            {
                return obj;
            }

            throw new AssertionException(failureMessage);
        }
    }
}

Visual Studio test runner

namespace SmartUnit.TestAdapter
{
    [FileExtension(".dll")]
    [FileExtension(".exe")]
    [DefaultExecutorUri(ExecutorUri)]
    [ExtensionUri(ExecutorUri)]
    [Category("managed")]
    public class TestRunner : ITestDiscoverer, ITestExecutor
    {
        public const string ExecutorUri = "executor://SmartUnitExecutor";

        private CancellationTokenSource cancellationToken = new CancellationTokenSource();

        public void DiscoverTests(IEnumerable<string> sources, IDiscoveryContext discoveryContext, IMessageLogger logger, ITestCaseDiscoverySink discoverySink)
        {
            foreach (var testCase in DiscoverTestCases(sources))
            {
                discoverySink.SendTestCase(testCase);
            }
        }

        public void Cancel()
        {
            cancellationToken.Cancel();
        }

        public async void RunTests(IEnumerable<TestCase> tests, IRunContext runContext, IFrameworkHandle frameworkHandle)
        {
            foreach (var testCase in tests)
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    break;
                }

                await RunTestCase(testCase, frameworkHandle);
            }
        }

        public async void RunTests(IEnumerable<string> sources, IRunContext runContext, IFrameworkHandle frameworkHandle)
        {
            foreach (var testCase in DiscoverTestCases(sources))
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    break;
                }

                await RunTestCase(testCase, frameworkHandle);
            }
        }

        private IEnumerable<TestCase> DiscoverTestCases(IEnumerable<string> sources)
        {
            foreach (var source in sources)
            {
                var sourceAssemblyPath = Path.IsPathRooted(source) ? source : Path.Combine(Directory.GetCurrentDirectory(), source);

                var assembly = Assembly.LoadFrom(sourceAssemblyPath);
                var tests = assembly.GetTypes()
                    .SelectMany(s => s.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
                    .Where(w => w.GetCustomAttribute<AssertionAttribute>() is not null)
                    .ToList();

                foreach (var test in tests)
                {
                    var testDisplayName = test.Name;
                    if (test.Name.StartsWith('<') && !test.Name.StartsWith("<<Main>$>"))
                    {
                        var parentTestName = test.Name.Split('>')[0][1..];
                        testDisplayName = parentTestName + '.' + test.Name.Split('>')[1][3..].Split('|')[0];
                    }
                    if (test.Name.StartsWith("<<Main>$>"))
                    {
                        var parentTestName = test.Name.Split('>')[0][2..];
                        testDisplayName = parentTestName + '.' + test.Name.Split('>')[2][3..].Split('|')[0];
                    }

                    var assertionAttribute = test.GetCustomAttribute<AssertionAttribute>()!;
                    var testCase = new TestCase(test.DeclaringType!.FullName + "." + test.Name, new Uri(ExecutorUri), source)
                    {
                        DisplayName = string.IsNullOrEmpty(assertionAttribute.Name) ? testDisplayName : assertionAttribute.Name,
                    };

                    yield return testCase;
                }
            }
        }

        private MethodInfo GetTestMethodFromCase(TestCase testCase)
        {
            var sourceAssemblyPath = Path.IsPathRooted(testCase.Source) ? testCase.Source : Path.Combine(Directory.GetCurrentDirectory(), testCase.Source);
            var assembly = Assembly.LoadFrom(sourceAssemblyPath);

            var fullyQualifiedName = testCase.FullyQualifiedName;
            var nameSeparatorIndex = fullyQualifiedName.LastIndexOf('.');
            var typeName = fullyQualifiedName.Substring(0, nameSeparatorIndex);

            var testClass = assembly.GetType(typeName);
            return testClass.GetMethod(fullyQualifiedName.Substring(nameSeparatorIndex + 1), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance);
        }

        private void RecordSkippedTest(TestCase testCase, string? reason, ITestExecutionRecorder recorder)
        {
            var now = DateTime.Now;
            var testResult = new TestResult(testCase)
            {
                Outcome = TestOutcome.Skipped,
                StartTime = now,
                EndTime = now,
                Duration = new TimeSpan(),
                DisplayName = testCase.DisplayName,
                ErrorMessage = reason
            };

            recorder.RecordResult(testResult);
        }

        private void RecordPassedTest(TestCase testCase, DateTime start, DateTime end, ITestExecutionRecorder recorder)
        {
            var testResult = new TestResult(testCase)
            {
                Outcome = TestOutcome.Passed,
                StartTime = start,
                EndTime = end,
                Duration = end - start,
                DisplayName = testCase.DisplayName
            };

            recorder.RecordResult(testResult);
        }

        private void RecordFailedTest(TestCase testCase, DateTime start, DateTime end, Exception ex, ITestExecutionRecorder recorder)
        {
            var testResult = new TestResult(testCase)
            {
                Outcome = TestOutcome.Failed,
                StartTime = start,
                EndTime = end,
                Duration = end - start,
                DisplayName = testCase.DisplayName,
                ErrorMessage = ex.Message,
                ErrorStackTrace = ex.StackTrace
            };

            recorder.RecordResult(testResult);
        }

        private async ValueTask RunTestCase(TestCase testCase, ITestExecutionRecorder recorder)
        {
            var testMethod = GetTestMethodFromCase(testCase);
            if (testMethod.GetCustomAttribute<SkipAttribute>() is not null)
            {
                RecordSkippedTest(testCase, testMethod.GetCustomAttribute<SkipAttribute>()!.Reason, recorder);
                return;
            }

            recorder.RecordStart(testCase);
            var start = DateTime.Now;

            try
            {
                if (testMethod.Name.StartsWith('<') && !testMethod.Name.StartsWith("<<Main>$>"))
                {
                    await RunNestedTest(testMethod);
                }
                else
                {
                    await RunTest(testMethod);
                }

                var end = DateTime.Now;
                RecordPassedTest(testCase, start, end, recorder);
            }
            catch (Exception ex)
            {
                var end = DateTime.Now;
                RecordFailedTest(testCase, start, end, ex.InnerException ?? ex, recorder);
            }
        }

        private async ValueTask RunNestedTest(MethodInfo test)
        {
            var parentMethodName = test.Name.Split('>')[0].Substring(1);
            var parentMethod = test.DeclaringType!.GetMethod(parentMethodName);

            await RunTest(parentMethod, test);
        }

        private async ValueTask RunTest(MethodInfo test, MethodInfo? callback = null)
        {
            var assertionSetAttribute = test.DeclaringType!.GetCustomAttribute<AssertionSetAttribute>();
            if (test.GetCustomAttribute<AssertionSetAttribute>() is not null)
            {
                assertionSetAttribute = test.GetCustomAttribute<AssertionSetAttribute>();
            }

            if (assertionSetAttribute is null)
            {
                await RunTestWithoutAssertionSet(test, callback);
            }
            else
            {
                await RunTestWithAssertionSet(test, callback, assertionSetAttribute.AssertionSetType);
            }
        }

        private async ValueTask RunTestWithoutAssertionSet(MethodInfo test, MethodInfo? callback)
        {
            var parameters = test.GetParameters().Select(s =>
            {
                if (s.GetCustomAttribute<CallbackAttribute>() is not null && callback is not null)
                {
                    return Delegate.CreateDelegate(s.ParameterType, callback);
                }

                if (s.ParameterType.IsInterface)
                {
                    var mock = (Mock)Activator.CreateInstance(typeof(Mock<>).MakeGenericType(s.ParameterType))!;
                    return mock.Object;
                }

                return null;
            }).ToArray();

            var typeInstance = test.DeclaringType!.IsAbstract && test.DeclaringType.IsSealed ? null : Activator.CreateInstance(test.DeclaringType);
            if (test.DeclaringType.IsAbstract && test.DeclaringType.IsSealed)
            {
                await InvokeTest(test, typeInstance, parameters);
            }
            else
            {
                await InvokeTest(test, typeInstance, parameters);
            }
        }

        private async ValueTask RunTestWithAssertionSet(MethodInfo test, MethodInfo? callback, Type assertionSetType)
        {
            var assertionSetInstance = Activator.CreateInstance(assertionSetType) as AssertionSet;
            assertionSetInstance!.Configure();
            assertionSetInstance.AddSingleton(test.DeclaringType!);

            var provider = assertionSetInstance.BuildServiceProvider();
            var parameters = test.GetParameters().Select(s =>
            {
                if (s.GetCustomAttribute<CallbackAttribute>() is not null && callback is not null)
                {
                    return Delegate.CreateDelegate(s.ParameterType, callback);
                }

                var service = provider.GetService(s.ParameterType);
                if (service != null)
                {
                    return service;
                }

                if (s.ParameterType.IsInterface)
                {
                    var mock = (Mock)Activator.CreateInstance(typeof(Mock<>).MakeGenericType(s.ParameterType))!;
                    return mock.Object;
                }

                return null;
            }).ToArray();

            var typeInstance = test.DeclaringType!.IsAbstract && test.DeclaringType.IsSealed ? null : provider.GetRequiredService(test.DeclaringType);
            await InvokeTest(test, typeInstance, parameters);
        }

        private async ValueTask InvokeTest(MethodInfo methodInfo, object? typeInstance, object?[]? parameters)
        {
            var isAwaitable = methodInfo.ReturnType.GetMethod(nameof(Task.GetAwaiter)) != null;
            if (isAwaitable)
            {
                await (dynamic)methodInfo.Invoke(typeInstance, parameters)!;
            }
            else
            {
                methodInfo.Invoke(typeInstance, parameters);
            }
        }
    }
}

Additional Resources

GitHub

\$\endgroup\$
2
  • \$\begingroup\$ Are you expecting to review your whole project? \$\endgroup\$ Commented Apr 19, 2021 at 8:42
  • \$\begingroup\$ Just review what you feel like. \$\endgroup\$
    – Hosch250
    Commented Apr 20, 2021 at 12:47

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.