惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V2EX - 技术
V2EX - 技术
P
Privacy International News Feed
Security Latest
Security Latest
H
Hacker News: Front Page
T
Tenable Blog
The Hacker News
The Hacker News
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
S
Security @ Cisco Blogs
Project Zero
Project Zero
O
OpenAI News
AI
AI
Spread Privacy
Spread Privacy
C
CERT Recently Published Vulnerability Notes
The Last Watchdog
The Last Watchdog
G
GRAHAM CLULEY
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Scott Helme
Scott Helme
Application and Cybersecurity Blog
Application and Cybersecurity Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
C
CXSECURITY Database RSS Feed - CXSecurity.com
NISL@THU
NISL@THU
A
Arctic Wolf
T
Threat Research - Cisco Blogs
PCI Perspectives
PCI Perspectives
N
News and Events Feed by Topic
C
Cyber Attacks, Cyber Crime and Cyber Security
C
Cybersecurity and Infrastructure Security Agency CISA
Simon Willison's Weblog
Simon Willison's Weblog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Know Your Adversary
Know Your Adversary
Google Online Security Blog
Google Online Security Blog
罗磊的独立博客
L
LINUX DO - 最新话题
U
Unit 42
S
Security Affairs
有赞技术团队
有赞技术团队
WordPress大学
WordPress大学
博客园 - 【当耐特】
T
The Exploit Database - CXSecurity.com
S
Schneier on Security
月光博客
月光博客
Engineering at Meta
Engineering at Meta
腾讯CDC
F
Full Disclosure
Cyberwarzone
Cyberwarzone
S
SegmentFault 最新的问题
Recorded Future
Recorded Future
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
博客园 - 司徒正美
The Cloudflare Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Магия dotnet test. Как запускаются ваши тесты в .NET?
Indermove (D · 2026-04-28 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение18 мин

Охват и читатели114

Обзор

Эта статья могла начаться с драматической истории о том, как мне срочно понадобилось разобраться в работе тестовых фреймворков. И была бы она построена вокруг заковыристого кейса, а я вот взял и как поборол его! Но… Такого кейса просто не было.

Привет! Меня зовут Дима Афонченко, я — техлид в Dodo Engineering. Однажды мне стало интересно: а как вообще работают тестовые фреймворки?

Ну вот написал я функцию, ну кликнул в IDE на треугольник и… Через какое-то время получил пачку зелёных галочек — магия, не иначе.

Кстати, уже разобравшись в процессе, я выяснил, что доля магии в этом процессе порядочная. Сейчас расскажу, что именно я узнал, и причём тут магия, собственно. Надеюсь, будет интересно.

Заботливый дисклеймер: в этой статье будет много кода, а вам, возможно, захочется его открыть в какой-то IDE и почитать подробнее. Для такого случая я подготовил репозиторий со всем кодом из этой статьи.

Часть первая: пишу свой тестовый фреймворк

Так я устроен: когда хочу в чем-то разобраться, пишу миниатюрную версию того, что изучаю. Концептуально тестовые фреймворки состоят из:

  1. Библиотеки атрибутов. Она позволяет пометить некоторые классы и методы как тестовые, чтобы фреймворк знал, что он может запускать и в каком порядке.

  2. Библиотеки ассертов. Она позволяет в тесте проверить условие и по результатам проверки показать, прошел тест или нет.

  3. Ядра фреймворка. Это код, который позволяет найти в проекте все классы и методы, помеченные как тестовые, и запустить их в правильном порядке.

План реализации фреймворка у меня такой:

  1. Написать атрибуты.

  2. Накидать небольшую библиотеку ассертов.

  3. Создать класс калькулятор для тестирования.

  4. Написать тесты для этого класса.

  5. Реализовать ядро своего мини-фреймворка — то, что и будет как-то запускать эти тесты.

Почему начнём с атрибутов? Потому что начинать надо с малого, а они как раз пишутся в одну-две строчки:

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;

Простой и понятный набор — наш фреймворк легко поймёт, какие методы нужно запустить, и в каком порядке, ориентируясь на эти атрибуты:

  1. OneTimeSetUp запустится один раз — перед запуском тестов в тестовом классе.

  2. SetUp будет запускаться перед каждым тестом в тестовом классе.

  3. Test – понятное дело — это и есть запуск самого теста.

  4. TearDown будет запускаться после каждого теста.

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

С этим разобрались. Напишем код, который будет запускать тесты, опираясь на атрибуты и вызывая ассерты:

В идеале оно должно работать так:

  1. С помощью рефлексии фреймворк находит классы, помеченные TestFixture.

  2. Через рефлексию же фреймворк создаёт экземпляр класса теста.

  3. Фреймворк вызывает методы сетапов и тестов.

  4. Метод теста не выбрасывает исключение в случае успеха и выбрасывает в случае провала.

  5. Фреймворк подводит итоги и выводит результат в консоль.

Четвёртый пункт отвечает, почему ассерты кидают исключения: суть теста в простейшем случае — метод не падает, значит всё ок. Тестовому движку вообще до лампочки, сколько проверок ты сделал внутри — главное, чтобы исключение ни разу не вылетело. Напишем этот код:

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 — штука старая и запутанная. Концепция — а другого названия этой цепочки я не нахожу — их запуска выглядит примерно так:

  1. Запускаешь dotnet test → стартует процесс vstesthost (или testhost).

  2. Этот процесс ищет в твоей сборке специальные атрибуты DefaultExecutorUri и имплементации интерфейсов ITestDiscoverer и ITestExecutor.

  3. Этот же процесс находит и запускает «эксплоринг» тестов (discovery).

  4. Если тесты успешно найдены, они появляются в Test Explorer в IDE.

  5. Дальше можно запускать тесты по одному или пачкой, передавая команды прямо в vstesthost.

Если нарисовать временную диаграмму, получится примерно вот такая картинка:

Временная диаграмма запуска теста через VSTest

Временная диаграмма запуска теста через VSTest

Выглядит сложно, но на самом деле сделать наш фреймворк совместимым с 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:

Результат запуска фреймворка, совместимого с VSTest

Результат запуска фреймворка, совместимого с VSTest

Первый прикладной момент, который можно извлечь из этой теории: сам 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 — а это всегда хорошо:

Временная диаграмма запуска теста через MTP

Временная диаграмма запуска теста через MTP

Тесты теперь можно запускать и через dotnet run, и через dotnet test — как обычное приложение. Тут уже нет смысла приводить весь код фреймворка, совместимого с MTP, — вы можете посмотреть его в репозитории, который я сделал специально для этой статьи.

Интереснее поговорить о том, как, например, сидя на NUnit, переехать с VSTestHost на MTP. На самом деле, это оказалось несложно: в настройках тестового проекта убираете true и добавляете три вот эти строчки:

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
  <EnableNUnitRunner>true</EnableNUnitRunner>  <!-- или другой runner, если используете не NUnit -->
</PropertyGroup>

И всё. Если вы перешли, и у вас всё заработало, в райдере появится возможность запустить тестовый проект как обычное приложение:

Слева обычный тестовый проект на VSTest и NUnit. Справа проект на NUnit, который поддерживает MTP

Слева обычный тестовый проект на VSTest и NUnit. Справа проект на NUnit, который поддерживает MTP

Без подводных камней, правда, никуда. Например, в моём проекте есть интеграционные тесты — они поднимают сервис целиком, а потом дёргают его эндпоинты.

В этих тестах мне очень важны логи, чтобы видеть, что происходит внутри сервиса. Так что после перехода на MTP эти логи просто пропали.

Помучался пару часов, покрутил всевозможные ручки, настройки вывода, capture’ы — ничего вменяемого не получилось. Тесты падали, но вместо привычного «упал такой-то тест с такой-то ошибкой» на CI не было ничего — ни стека, ни сообщений, ни логов из сервиса.

Попытался разобраться, как это починить, но в итоге плюнул и остался пока на VSTestHost. Выигрыш от MTP пока не настолько большой, чтобы мучиться с этими багами.

Если вы уже перешли на MTP и у вас всё работает нормально — логи в порядке, ничего не пропадает —, напишите в комментариях, пожалуйста. Мне будет очень интересно попробовать ещё раз и понять, в чём я облажался :)

Часть четыре с половиной: ищу остальные отличия xUnit и NUnit

Маленький дисклеймер: я не знал, куда вставить этот блок, потому что он вроде относится ко второй части (про создание экземпляров классов), но для него нужны знания из третьей и четвёртой частей (про VSTest и MTP). Поэтому поставил его сюда.

Дело в том, что xUnit и NUnit различаются не только подходом к созданию тестовых классов, но и тем, как они запускаются под капотом. В xUnit есть дополнительный слой абстракции между адаптером и самим фреймворком, а в NUnit его нет. Схематично это выглядит так:

NUnit:

VSTestNUnitAdapterNUnitEngine → Тесты

xUnit:

VSTestxUnitAdapterxUnitExecutionEnginexUnitAbstractions → Тесты

Вот этот xUnitExecutionEngine позволяет писать свои собственные тестовые фреймворки поверх xUnit. А ещё отлаживать или расширять поведение xUnit.

Честно говоря, я бы сам никогда не додумался, для чего это нужно. Но, к счастью, у Эндрю Лока есть отличная статья, где он рассказывает, как написал свой мини-фреймворк на базе xUnit, чтобы отлаживать зависающие тесты.

Так что теперь вы тоже вооружены этим знанием :)

Послесловие

Тут можно было бы не останавливаться и рассказать про новенький TUnit. Он построен на MTP и поэтому использует Source Generators. Но я с ним пока не работал, так что ничего сказать не могу. Может быть в другой раз. На этом всё!

Мне вообще кажется, что тема тестов не очень интересна широкой разработке. Это не история про то, как минусанув один лок можно поднять пропускную способность сервера до небес.

Да и в целом мало кого волнует, как и что работает под капотом у тестов, сколько памяти жрёт, и почему вообще оно так. Но после этого кейса я точно стал увереннее разбираться в ситуации.

Например, не могу найти ни одного теста — понятно, сломался discovery. Падает только на CI, а локально всё ок — ясно, это просто я на Windows, нужно копать в эту сторону.

В общем, мне моё исследование помогло. Да и просто интересно было. Надеюсь, и вам тоже.

На этом всё. Своими менее длинными заметками о разработке я делюсь в Telegram-канале «С# Short Posts 18+». Подписывайтесь!

О том, как мы развиваем IT в Додо в целом, читайте в Telegram-канале Dodo Engineering. Там мы рассказываем о жизни нашей команды, культуре и последних разработках.