SmartUnit: модульное тестирование с DI

Я думал о том, как модульные тесты требуют, чтобы вы вручную создавали экземпляры всех ваших зависимостей, хотя многие из них одинаковы для каждого теста или на самом деле не имеют значения (например, ведение журнала, когда люди обычно просто вводят макеты). В качестве возможного решения этой проблемы я написал платформу модульного тестирования, которая вводит любые известные зависимости в класс или метод или null, если зависимость неизвестна. Он также поддерживает тесты теоретического типа с вложенными методами. Вот несколько быстрых примеров, прежде чем мы погрузимся в код:

Объявление теста

[Assertion]
public void MyTest() {}

Настройка зависимостей

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

Использование атрибута AssertionSet

Это относится либо к классу, где он применяется ко всем тестам, либо к методу, где он переопределяет любой набор утверждений, объявленный в классе.

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

Теоретические тесты

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

    [Assertion]
    public void Foo() {}

    [Assertion]
    public void Bar() {}
}

Пропуск теста

Применить Skip атрибут метода тестирования.

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

Утверждения

Успешным считается тест, завершенный без исключений, поэтому, хотя эти методы расширения утверждений предоставлены, любая библиотека утверждений будет работать.

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

Известные ограничения

Каждый тест запускается в новом экземпляре содержащего его класса. Это предотвращает случайное совместное состояние между тестами и позволяет тестам переопределять используемый набор утверждений. Это также предотвращает намеренное совместное использование состояния между тестами, но это можно преодолеть, определив и внедрив одноэлемент состояния.

Имена тестов должны быть уникальными; в противном случае адаптер не сможет найти совпадение, так как он ищет методы только по имени. У тебя не может быть Foo(IFoo foo, Bar bar) а также Foo(Bar bar, IFoo foo)в виде родительских или вложенных теоретических тестов. Обнаружитель тестов найдет их, но бегун выйдет из строя, запустив их; это можно было бы смягчить с помощью анализатора VS, но я его еще не написал.

Типы, возвращаемые методом тестирования, должны быть ожидаемыми. void, Task, ValueTask, и другие ожидаемые типы хороши. int и другие не ожидаемые типы будут аварийно завершены.

Методы верхнего уровня (доступные в C # 9) не могут быть теориями. Причина в том, что вложенные методы генерируются как методы верхнего уровня с неразговорчивым именем (что-то вроде g__Name | 0__0). Всем методам верхнего уровня присваиваются неразговорчивые имена, и они обрабатываются так, как если бы они были дочерними по отношению к методу Main, поэтому я не могу определить, к какому родительскому методу они принадлежат.

Фреймворк для тестирования

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

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

Дополнительные ресурсы

GitHub

0

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *