
Эта статья могла начаться с драматической истории о том, как мне срочно понадобилось разобраться в работе тестовых фреймворков. И была бы она построена вокруг заковыристого кейса, а я вот взял и как поборол его! Но… Такого кейса просто не было.
Привет! Меня зовут Дима Афонченко, я — техлид в Dodo Engineering. Однажды мне стало интересно: а как вообще работают тестовые фреймворки?
Ну вот написал я функцию, ну кликнул в IDE на треугольник и… Через какое-то время получил пачку зелёных галочек — магия, не иначе.
Кстати, уже разобравшись в процессе, я выяснил, что доля магии в этом процессе порядочная. Сейчас расскажу, что именно я узнал, и причём тут магия, собственно. Надеюсь, будет интересно.
Заботливый дисклеймер: в этой статье будет много кода, а вам, возможно, захочется его открыть в какой-то IDE и почитать подробнее. Для такого случая я подготовил репозиторий со всем кодом из этой статьи.
Часть первая: пишу свой тестовый фреймворк
Так я устроен: когда хочу в чем-то разобраться, пишу миниатюрную версию того, что изучаю. Концептуально тестовые фреймворки состоят из:
Библиотеки атрибутов. Она позволяет пометить некоторые классы и методы как тестовые, чтобы фреймворк знал, что он может запускать и в каком порядке.
Библиотеки ассертов. Она позволяет в тесте проверить условие и по результатам проверки показать, прошел тест или нет.
Ядра фреймворка. Это код, который позволяет найти в проекте все классы и методы, помеченные как тестовые, и запустить их в правильном порядке.
План реализации фреймворка у меня такой:
Написать атрибуты.
Накидать небольшую библиотеку ассертов.
Создать класс калькулятор для тестирования.
Написать тесты для этого класса.
Реализовать ядро своего мини-фреймворка — то, что и будет как-то запускать эти тесты.
Почему начнём с атрибутов? Потому что начинать надо с малого, а они как раз пишутся в одну-две строчки:
namespace MiniUnit.Basic; [AttributeUsage(AttributeTargets.Class)] public sealed class TestFixtureAttribute : Attribute; [AttributeUsage(AttributeTargets.Method)] public sealed class TestAttribute : Attribute { public string? Name { get; set; } } [AttributeUsage(AttributeTargets.Method)] public sealed class SetUpAttribute : Attribute; [AttributeUsage(AttributeTargets.Method)] public sealed class TearDownAttribute : Attribute; [AttributeUsage(AttributeTargets.Method)] public sealed class OneTimeSetUpAttribute : Attribute; [AttributeUsage(AttributeTargets.Method)] public sealed class OneTimeTearDownAttribute : Attribute;
Простой и понятный набор — наш фреймворк легко поймёт, какие методы нужно запустить, и в каком порядке, ориентируясь на эти атрибуты:
OneTimeSetUpзапустится один раз — перед запуском тестов в тестовом классе.SetUpбудет запускаться перед каждым тестом в тестовом классе.Test– понятное дело — это и есть запуск самого теста.TearDownбудет запускаться после каждого теста.OneTimeTearDownбудет очищать данные после запуска всех тестов в тестовом классе.
Перейдём к библиотеке ассертов. Каждый тест заканчивается проверкой условия, но как именно она происходит?
Если вы хотя бы раз залазили в код библиотек с ассертами, то знаете: это простые штуки, которые проверяют условие, и если оно не проходит, кидают исключение. Возникает другой вопрос: а почему именно исключение, а не сигнал, что тест упал?
На этот вопрос мы ответим позже, когда реализуем метод, который будет исполнять тесты. Пока что напишем свои ассерты:
namespace MiniUnit.Basic; public sealed class AssertionException(string msg) : Exception(msg); public static class Assert { public static void IsTrue(bool condition, string? message = null) { if (!condition) throw new AssertionException(message ?? "Expected true but was false."); } public static void IsFalse(bool condition, string? message = null) { if (condition) throw new AssertionException(message ?? "Expected false but was true."); } public static void AreEqual<T>(T expected, T actual, string? message = null) { if (!EqualityComparer<T>.Default.Equals(expected, actual)) throw new AssertionException(message ?? $"Expected: {expected}\nBut was: {actual}"); } public static void AreNotEqual<T>(T notExpected, T actual, string? message = null) { if (EqualityComparer<T>.Default.Equals(notExpected, actual)) throw new AssertionException(message ?? $"Did not expect: {notExpected}"); } public static TException Throws<TException>(Action action, string? message = null) where TException : Exception { try { action(); } catch (TException ex) { return ex; } catch (Exception ex) { throw new AssertionException(message ?? $"Expected {typeof(TException).Name}, but {ex.GetType().Name} was thrown."); } throw new AssertionException(message ?? $"Expected {typeof(TException).Name} to be thrown, but no exception was thrown."); } public static async Task<TException> ThrowsAsync<TException>(Func<Task> action, string? message = null) where TException : Exception { try { await action(); } catch (TException ex) { return ex; } catch (Exception ex) { throw new AssertionException(message ?? $"Expected {typeof(TException).Name}, but {ex.GetType().Name} was thrown."); } throw new AssertionException(message ?? $"Expected {typeof(TException).Name} to be thrown, but no exception was thrown."); } }
Ну и нам нужен класс, который мы будем тестировать. В моём случае — это Calculator, умеющий складывать, вычитать и делить:
using Microsoft.Extensions.Logging; namespace MiniUnit.CalculatorLib; public sealed class Calculator { private readonly ILogger<Calculator> _logger; public Calculator(ILogger<Calculator>? logger = null) { _logger = logger ?? LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger<Calculator>(); } public int Add(int a, int b) { _logger.LogInformation("Making sum of {a} and {b}", a, b); return a + b; } public int Div(int a, int b) { _logger.LogInformation("Making division of {a} and {b}", a, b); return a / b; } public Task<int> AddAsync(int a, int b) { _logger.LogInformation("Making async sum of {a} and {b}", a, b); return Task.FromResult(a + b); } }
Отлично! Теперь напишем и тесты к нему:
using MiniUnit.CalculatorLib; namespace MiniUnit.Basic; [TestFixture] public class CalculatorTests { private Calculator _calc = null!; [OneTimeSetUp] private void OneTimeSetUp() => Console.WriteLine("Fixture init once."); [SetUp] private void SetUp() => _calc = new Calculator(); [TearDown] private void TearDown() => Console.WriteLine("Fixture tear down once."); [Test(Name = "Addition works")] public void Add_Works() => Assert.AreEqual(5, _calc.Add(2, 3)); [Test] public void Div_ByZero_Throws() => Assert.Throws<DivideByZeroException>(() => _calc.Div(1, 0)); [Test] public async Task Async_Works() { var v = await _calc.AddAsync(10, 32); Assert.AreEqual(42, v); } }
С этим разобрались. Напишем код, который будет запускать тесты, опираясь на атрибуты и вызывая ассерты:
В идеале оно должно работать так:
С помощью рефлексии фреймворк находит классы, помеченные
TestFixture.Через рефлексию же фреймворк создаёт экземпляр класса теста.
Фреймворк вызывает методы сетапов и тестов.
Метод теста не выбрасывает исключение в случае успеха и выбрасывает в случае провала.
Фреймворк подводит итоги и выводит результат в консоль.
Четвёртый пункт отвечает, почему ассерты кидают исключения: суть теста в простейшем случае — метод не падает, значит всё ок. Тестовому движку вообще до лампочки, сколько проверок ты сделал внутри — главное, чтобы исключение ни разу не вылетело. Напишем этот код:
using System.Reflection; namespace MiniUnit.Basic; public static class MiniUnitRunner { public static async Task<int> RunAsync(Assembly asm, string? filter = null) { // I. Ищем тесты в сборке — все классы с атрибутом TestFixtureAttribute IEnumerable<Type> fixtures = asm.GetTypes() .Where(t => t.GetCustomAttribute<TestFixtureAttribute>() != null) .OrderBy(t => t.FullName) .ToArray(); // Инициализируем счётчики var total = 0; var passed = 0; var failed = 0; // II. Запускаем тесты — проходим по всем фикстурам foreach (var fixture in fixtures) { var fixtureName = fixture.FullName ?? fixture.Name; // 1. Находим все тестовые методы, сетапы и тирдауны var (oneTimeSetUp, oneTimeTearDown, setUp, tearDown, tests) = InspectFixture(fixture, filter); object? testClassInstance; // 2. Создаём экземпляр и запускаем OneTimeSetUp try { testClassInstance = Activator.CreateInstance(fixture); await InvokeAsync(testClassInstance, oneTimeSetUp); } catch (Exception e) { WriteRed($"[Fixture ERROR] {fixtureName}: {e.GetBaseException().Message}"); failed += tests.Count; continue; } // 3. Запускаем тесты по очереди foreach (var test in tests) { total++; var display = test.GetCustomAttribute<TestAttribute>()?.Name ?? test.Name; try { // 4. SetUp await InvokeAsync(testClassInstance, setUp); // 5. Сам тест await InvokeAsync(testClassInstance, test); // 6. TearDown await InvokeAsync(testClassInstance, tearDown); WriteGreen($"[PASS] {fixtureName}.{display}"); passed++; } catch (TargetInvocationException tie) { var ex = tie.InnerException ?? tie; WriteRed($"[FAIL] {fixtureName}.{display}\n{ex.GetType().Name}: {ex.Message}"); failed++; } catch (Exception ex) { WriteRed($"[FAIL] {fixtureName}.{display}\n{ex.GetType().Name}: {ex.Message}"); failed++; } } // 7. OneTimeTearDown в конце try { await InvokeAsync(testClassInstance, oneTimeTearDown); } catch (Exception e) { WriteRed($"[Fixture TearDown ERROR] {fixtureName}: {e.GetBaseException().Message}"); } } // Итог Console.WriteLine($"\nTotal: {total}, Passed: {passed}, Failed: {failed}"); return failed == 0 ? 0 : 1; } private static (MethodInfo? oneTimeSetUp, MethodInfo? oneTimeTearDown, MethodInfo? setUp, MethodInfo? tearDown, List<MethodInfo> tests) InspectFixture(Type testClass, string? filter) { MethodInfo? otsu = null; MethodInfo? otd = null; MethodInfo? su = null; MethodInfo? td = null; var tests = new List<MethodInfo>(); foreach (var m in testClass.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { if (m.GetCustomAttribute<OneTimeSetUpAttribute>() != null) otsu = m; else if (m.GetCustomAttribute<SetUpAttribute>() != null) su = m; else if (m.GetCustomAttribute<TearDownAttribute>() != null) td = m; else if (m.GetCustomAttribute<OneTimeTearDownAttribute>() != null) otd = m; else if (m.GetCustomAttribute<TestAttribute>() != null) { if (m.GetParameters().Length != 0) continue; if (string.IsNullOrWhiteSpace(filter) || m.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) { tests.Add(m); } } } return (otsu, otd, su, td, tests); } private static async Task InvokeAsync(object? instance, MethodInfo? method) { if (method == null) return; var result = method.Invoke(instance, null); if (result is Task t) await t; } private static void WriteGreen(string s) { var prev = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine(s); Console.ForegroundColor = prev; } private static void WriteRed(string s) { var prev = Console.ForegroundColor; Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine(s); Console.ForegroundColor = prev; } }
Код запускается через dotnet run и показывает, сколько тестов прошло, а сколько упало:

Код тестового мини-фреймворка довольно простой, но даже в нём есть одна интересная особенность: посмотрите на код и подумайте, как его можно оптимизировать. Что вам приходит в голову?
Когда я показал этот код коллегам, почти все предложили вместо foreach сделать параллелизацию. Это круто, но… А как тогда создавать экземпляр класса для параллельных запусков? Один на все тесты? Свой собственный на каждый запуск?
Часть вторая: разбираюсь, как создавать тестовые классы и как с этим работают NUnit и xUnit
Вопрос «запускать один экземпляр класса на все тесты или по одному экземпляру на каждый тест?» — довольно важный и решается по-разному в разных тестовых фреймворках.
Например, в NUnit изначально создавался один инстанс на все запуски. Так можно было удобно шарить данные между тестами: создал словарик как поле в классе и всё — он виден в другом тесте. Это удобно, но каждый последующий тест запускается не в чистом состоянии, а со всем мусором от предыдущих.
xUnit пошёл другим путём: в нём на каждый запуск теста создавался новый экземпляр класса. Тесты получались действительно изолированными и параллельными. С одной стороны, запуская тест, ты можешь быть уверен, что поля инициализируются заново, но с другой — если нужно что-то пошарить, придётся отметить это атрибутами.
Вообще это интересный момент. Эти две парадигмы — «один экземпляр на всю фикстуру» (class per fixture / run) и «новый экземпляр на каждый тест» (class per test) — долгое время отличали NUnit от xUnit. Многие до сих пор думают, что так оно и осталось...
Но, к счастью, это давно не так. Начиная с версии NUnit 3.13, выпущенной в 2021 году, появился атрибут FixtureLifeCycle. С ним можно выбирать поведение: один экземпляр на все тесты или новый на каждый.
Важный нюанс: по умолчанию NUnit до сих пор использует старое поведение — один экземпляр на всю фикстуру (LifeCycle.SingleInstance). Чтобы получить поведение, как в xUnit (новый экземпляр на каждый тест), нужно явно поставить [FixtureLifeCycle(LifeCycle.InstancePerTestCase)]. В xUnit такого выбора нет — там всегда новый экземпляр.
Нужно ли сейчас выбирать между xUnit и NUnit? В целом, можно, но уже по другим причинам! Об этом расскажу чуть позже — в части четыре с половиной. Ну а пока что мы разобрались, как концептуально запускаются тесты.
Запускаем наш мини-фреймворк командой dotnet test и… Ничего не происходит: dotnet run сработает, а dotnet test — нет. И тут мы переходим к той части исследований, в которой, как по мне, как-то дохрена магии. К счастью, Microsoft уже исправляет ситуацию, но об этом далее.
Часть третья: пытаюсь подружить свой тестовый фреймворк с dotnet test и разбираюсь, что такое VSTest?
Вы когда-нибудь видели в диспетчере задач процесс vstesthost.exe? Убивали его, когда он зависал? А клали болт на вопрос: «А почему, черт возьми, мои тесты вообще запускаются через эту хрень»?
Если на все три вопроса ответ «да»,то мы похожи! И возможно, вам, как и мне, интересно разобраться, как это всё работает. Дело в том, что тесты в .NET — штука старая и запутанная. Концепция — а другого названия этой цепочки я не нахожу — их запуска выглядит примерно так:
Запускаешь
dotnet test→ стартует процессvstesthost(илиtesthost).Этот процесс ищет в твоей сборке специальные атрибуты
DefaultExecutorUriи имплементации интерфейсовITestDiscovererиITestExecutor.Этот же процесс находит и запускает «эксплоринг» тестов (
discovery).Если тесты успешно найдены, они появляются в Test Explorer в IDE.
Дальше можно запускать тесты по одному или пачкой, передавая команды прямо в
vstesthost.
Если нарисовать временную диаграмму, получится примерно вот такая картинка:

Выглядит сложно, но на самом деле сделать наш фреймворк совместимым с dotnet test совсем не сложно. Для этого нужно реализовать интерфейс ITestDiscoverer и пометить его следующими атрибутами:
[Export(typeof(ITestDiscoverer))] [FileExtension(".dll")] [DefaultExecutorUri(AdapterConstants.ExecutorUriString)]
Эти атрибуты нужны, чтобы VSTestHost нашёл нужные классы и использовал их при загрузке тестового адаптера через MEF.
Историческая справка: MEF — это Microsoft Extensibility Framework. Дедовский DI на стероидах — штука, которая позволяет подгружать dll-ки прямо во время работы приложения. Помните такую? Вот она всё ещё живёт в сердце большинства тестовых проектов.
Код поиска тестов остаётся почти таким же, как был раньше. Единственное отличие — все найденные тесты в конце нужно упаковать в специальный объект TestCase и отправить в discoverySink.SendTestCase(testCase).
Если у вас когда-нибудь тесты не отображались в Test Explorer, скорее всего, ломался этот механизм. В нашем случае его код выглядит так:
[Export(typeof(ITestDiscoverer))] [FileExtension(".dll")] [DefaultExecutorUri(AdapterConstants.ExecutorUriString)] public sealed class MiniUnitDiscoverer : ITestDiscoverer { public void DiscoverTests(IEnumerable<string> sources, IDiscoveryContext discoveryContext, IMessageLogger logger, ITestCaseDiscoverySink discoverySink) { foreach (var source in sources) { Assembly? asm; try { asm = Assembly.LoadFrom(source); } catch (Exception e) { logger.SendMessage(TestMessageLevel.Warning, $"MiniUnit.Reflection: can't load {source}: {e.GetBaseException().Message}"); continue; } var allTestTypes = asm.GetTypes(); foreach (var testType in allTestTypes) { var tests = testType.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .Where(m => m.GetCustomAttribute<TestAttribute>() != null && m.GetParameters().Length == 0); foreach (var m in tests) { var fullyQualifiedName = $"{testType.FullName}.{m.Name}"; var display = m.GetCustomAttribute<TestAttribute>()?.Name ?? m.Name; var testCase = new TestCase(fullyQualifiedName, AdapterConstants.ExecutorUri, source) { DisplayName = display }; discoverySink.SendTestCase(testCase); } } } } }
После реализации discovery (обнаружения тестов) можно перейти к механизму запуска. Создаём имплементацию интерфейса ITestExecutor и помечаем её двумя атрибутами:
[Export(typeof(ITestExecutor))] [ExtensionUri(AdapterConstants.ExecutorUriString)]
Здесь главное — реализовать метод RunTests. Он принимает список тестов, которые нужно запустить. Благодаря этому методу можно гонять не все найденные тесты, а только выбранные в окошке Test Explorer или переданные через консоль (фильтр, --filter и т.п.).
Главное отличие этого кода от нашего мини-фреймворка в том, что теперь нужно не только считать упавшие или прошедшие тесты, но и для каждого из них формировать объект TestResult и отдавать его в frameworkHandle?.RecordResult(tr).
Полный код — ниже. Суть в нём та же, что и в минимальном раннере: создаём экземпляр тестового класса и в нужном порядке дёргаем [OneTimeSetUp], [SetUp], сам тест и [TearDown]:
[Export(typeof(ITestExecutor))] [ExtensionUri(AdapterConstants.ExecutorUriString)] public sealed class MiniUnitExecutor : ITestExecutor { private volatile bool _cancel; public void Cancel() => _cancel = true; public void RunTests(IEnumerable<string>? sources, IRunContext? runContext, IFrameworkHandle? frameworkHandle) { var sink = new MiniUnitDiscovererCollectingSink(); if (sources != null && runContext != null && frameworkHandle != null) { new MiniUnitDiscoverer().DiscoverTests(sources, runContext, frameworkHandle, sink); } RunTests(sink.Collected, runContext, frameworkHandle); } public void RunTests(IEnumerable<TestCase>? tests, IRunContext? runContext, IFrameworkHandle? frameworkHandle) { if (tests == null || frameworkHandle == null) return; var groupByTestFile = tests.GroupBy(t => t.Source); foreach (var testFile in groupByTestFile) { Assembly asm; try { asm = Assembly.LoadFrom(testFile.Key); } catch (Exception e) { foreach (var tc in testFile) { var tr = new TestResult(tc) { Outcome = TestOutcome.Failed, ErrorMessage = e.GetBaseException().Message }; frameworkHandle?.RecordResult(tr); } continue; } var groupByClass = testFile.GroupBy(tc => Split(tc.FullyQualifiedName).typeName); foreach (var testClassGroup in groupByClass) { var testClass = asm.GetType(testClassGroup.Key); if (testClass == null) continue; try { var instance = Activator.CreateInstance(testClass); var oneTimeSetUp = FindSingle(testClass, typeof(OneTimeSetUpAttribute)); var oneTimeTearDown = FindSingle(testClass, typeof(OneTimeTearDownAttribute)); var setUp = FindSingle(testClass, typeof(SetUpAttribute)); var tearDown = FindSingle(testClass, typeof(TearDownAttribute)); Invoke(instance, oneTimeSetUp); foreach (var testCase in testClassGroup) { if (_cancel) return; var (_, methodName) = Split(testCase.FullyQualifiedName); var test = testClass.GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); var result = new TestResult(testCase); frameworkHandle?.RecordStart(testCase); var sw = Stopwatch.StartNew(); using var capture = new TestOutputCapture(line => frameworkHandle?.SendMessage(TestMessageLevel.Informational, $"[{testCase.DisplayName}] {line}")); TestLog.Current.Value = capture; try { if (test == null) throw new InvalidOperationException($"Test not found: {testCase.FullyQualifiedName}"); Invoke(instance, setUp); Invoke(instance, test); Invoke(instance, tearDown); result.Outcome = TestOutcome.Passed; } catch (TargetInvocationException tie) when (tie.InnerException is AssertionException aex) { result.Outcome = TestOutcome.Failed; result.ErrorMessage = aex.Message; result.ErrorStackTrace = aex.StackTrace; } catch (Exception e) { result.Outcome = TestOutcome.Failed; result.ErrorMessage = e.GetBaseException().Message; result.ErrorStackTrace = e.ToString(); } finally { sw.Stop(); var all = capture.GetBufferedText(); if (!string.IsNullOrWhiteSpace(all)) { result.Messages.Add(new TestResultMessage(TestResultMessage.StandardOutCategory, all)); } result.Duration = sw.Elapsed; frameworkHandle?.RecordResult(result); frameworkHandle?.RecordEnd(testCase, result.Outcome); TestLog.Current.Value = null; } } Invoke(instance, oneTimeTearDown); } catch (Exception e) { foreach (var testCase in testClassGroup) { var tr = new TestResult(testCase) { Outcome = TestOutcome.Failed, ErrorMessage = e.GetBaseException().Message }; frameworkHandle?.RecordResult(tr); } } } } } private static MethodInfo? FindSingle(Type t, Type attr) => t.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .FirstOrDefault(mi => mi.GetCustomAttribute(attr) != null); private static void Invoke(object? instance, MethodInfo? m) { if (m == null) return; var ret = m.Invoke(instance, null); if (ret is Task task) task.GetAwaiter().GetResult(); } private static (string typeName, string methodName) Split(string fq) { var i = fq.LastIndexOf('.'); return (fq.Substring(0, i), fq.Substring(i + 1)); } private sealed class MiniUnitDiscovererCollectingSink : ITestCaseDiscoverySink, IMessageLogger, IFrameworkHandle { public readonly List<TestCase> Collected = []; public void SendTestCase(TestCase discoveredTest) => Collected.Add(discoveredTest); void IMessageLogger.SendMessage(TestMessageLevel testMessageLevel, string message) { } public bool EnableShutdownAfterTestRun { get => false; set { } } public int LaunchProcessWithDebuggerAttached(string filePath, string? workingDirectory, string? arguments, IDictionary<string, string?>? environmentVariables) => -1; public void RecordAttachment(AttachmentSet attachmentSet) { } public void RecordAttachments(IList<AttachmentSet> attachmentSets) { } public void RecordEnd(TestCase testCase, TestOutcome outcome) { } public void RecordResult(TestResult testResult) { } public void RecordStart(TestCase testCase) { } } }
Тестовый адаптер не заработает без ссылки на пакет Microsoft.TestPlatform.ObjectModel. Из него подтягиваются интерфейсы ITestDiscoverer и ITestExecutor:
<PackageReference Include="Microsoft.TestPlatform.ObjectModel" Version="17.14.0" />
Отлично! Теперь наши тесты запускаются через команду dotnet test:

Первый прикладной момент, который можно извлечь из этой теории: сам VSTestHost — это подвижная часть. И это не очень хорошо.
Почему? Потому что вы можете гонять тесты локально на Windows, и у вас будет своя версия vstesthost.exe. На CI (часто Linux) — уже другая версия. У коллеги на Mac — третья.
Все это может привести к классическому сценарию: на CI все тесты зелёные, у коллеги тоже проходит, а у тебя локально всё падает. И поди разберись, в чём дело. А когда тесты падают только у тебя, по-моему, и есть самая стрёмная ситуация.
Если сломается CI, вся команда подключится чинить. А твои локальные проблемы на Windows? Ну, они только твои. Зачем тебе Винда, если .NET уже давно спокойно запускается на маке?
Эта ситуация повергла меня в шок во время моего исследования. Но кое-что хорошее тоже было: оказывается, есть более современная штука, чем VSTest. И она не так давно получила обновления.
Часть четвёртая: разбираюсь, как Microsoft Testing Platform делает всё проще и почему на неё пока что может быть сложно переехать
Я не следил, но оказывается в январе 2024 года Microsoft зарелизил новую тестовую платформу. Она отличается от предыдущей тем, что не требует никаких промежуточных запускаторов вроде VSTestHost.
По большей части, это просто библиотека. Она помогает правильно сформировать контракт для тестового фреймворка и объясняет, как отдавать результаты, а дальше сама их форматирует.
То есть теперь ваши тесты — это не специальная библиотека, которую дёргает VSTestHost. Это самостоятельное приложение, которое умеет формировать правильный формат отчёта по тестам.
Из плюсов обновлённой платформы: теперь вы можете использовать Source Generators в своих тестовых фреймворках! В теории это ускоряет discovery и запуск тестов. Вот в этой статье эксплоринг 3500 тестов занял аж на 5 секунд меньше — ваще красота! По правде говоря, не разрыв шаблонов, но основные преимущества, уверен, ещё раскроются в будущем.
Временная диаграмма не сильно поменялась, но в ней теперь отсутствует лишнее промежуточное звено в виде VSTestHost — а это всегда хорошо:

Тесты теперь можно запускать и через dotnet run, и через dotnet test — как обычное приложение. Тут уже нет смысла приводить весь код фреймворка, совместимого с MTP, — вы можете посмотреть его в репозитории, который я сделал специально для этой статьи.
Интереснее поговорить о том, как, например, сидя на NUnit, переехать с VSTestHost на MTP. На самом деле, это оказалось несложно: в настройках тестового проекта убираете true и добавляете три вот эти строчки:
<PropertyGroup> <OutputType>Exe</OutputType> <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport> <EnableNUnitRunner>true</EnableNUnitRunner> <!-- или другой runner, если используете не NUnit --> </PropertyGroup>
И всё. Если вы перешли, и у вас всё заработало, в райдере появится возможность запустить тестовый проект как обычное приложение:

Без подводных камней, правда, никуда. Например, в моём проекте есть интеграционные тесты — они поднимают сервис целиком, а потом дёргают его эндпоинты.
В этих тестах мне очень важны логи, чтобы видеть, что происходит внутри сервиса. Так что после перехода на MTP эти логи просто пропали.
Помучался пару часов, покрутил всевозможные ручки, настройки вывода, capture’ы — ничего вменяемого не получилось. Тесты падали, но вместо привычного «упал такой-то тест с такой-то ошибкой» на CI не было ничего — ни стека, ни сообщений, ни логов из сервиса.
Попытался разобраться, как это починить, но в итоге плюнул и остался пока на VSTestHost. Выигрыш от MTP пока не настолько большой, чтобы мучиться с этими багами.
Если вы уже перешли на MTP и у вас всё работает нормально — логи в порядке, ничего не пропадает —, напишите в комментариях, пожалуйста. Мне будет очень интересно попробовать ещё раз и понять, в чём я облажался :)
Часть четыре с половиной: ищу остальные отличия xUnit и NUnit
Маленький дисклеймер: я не знал, куда вставить этот блок, потому что он вроде относится ко второй части (про создание экземпляров классов), но для него нужны знания из третьей и четвёртой частей (про VSTest и MTP). Поэтому поставил его сюда.
Дело в том, что xUnit и NUnit различаются не только подходом к созданию тестовых классов, но и тем, как они запускаются под капотом. В xUnit есть дополнительный слой абстракции между адаптером и самим фреймворком, а в NUnit его нет. Схематично это выглядит так:
NUnit:
VSTest → NUnitAdapter → NUnitEngine → Тесты
xUnit:
VSTest → xUnitAdapter → xUnitExecutionEngine → xUnitAbstractions → Тесты
Вот этот xUnitExecutionEngine позволяет писать свои собственные тестовые фреймворки поверх xUnit. А ещё отлаживать или расширять поведение xUnit.
Честно говоря, я бы сам никогда не додумался, для чего это нужно. Но, к счастью, у Эндрю Лока есть отличная статья, где он рассказывает, как написал свой мини-фреймворк на базе xUnit, чтобы отлаживать зависающие тесты.
Так что теперь вы тоже вооружены этим знанием :)
Послесловие
Тут можно было бы не останавливаться и рассказать про новенький TUnit. Он построен на MTP и поэтому использует Source Generators. Но я с ним пока не работал, так что ничего сказать не могу. Может быть в другой раз. На этом всё!
Мне вообще кажется, что тема тестов не очень интересна широкой разработке. Это не история про то, как минусанув один лок можно поднять пропускную способность сервера до небес.
Да и в целом мало кого волнует, как и что работает под капотом у тестов, сколько памяти жрёт, и почему вообще оно так. Но после этого кейса я точно стал увереннее разбираться в ситуации.
Например, не могу найти ни одного теста — понятно, сломался discovery. Падает только на CI, а локально всё ок — ясно, это просто я на Windows, нужно копать в эту сторону.
В общем, мне моё исследование помогло. Да и просто интересно было. Надеюсь, и вам тоже.
На этом всё. Своими менее длинными заметками о разработке я делюсь в Telegram-канале «С# Short Posts 18+». Подписывайтесь!
О том, как мы развиваем IT в Додо в целом, читайте в Telegram-канале Dodo Engineering. Там мы рассказываем о жизни нашей команды, культуре и последних разработках.
navferty
В дополнение к test discovery через source generators, рекомендую попробовать ещё один фреймворк - TUnit. https://tunit.dev/docs/guides/philosophy