АШШумилкин А. О.ОАиП · ПиТПМ
ПиТПМ · Лекция 02

Лекция №2. Модульное тестирование (Unit Testing). Тестирование методом «белого ящика». Метрики покрытия кода (Code Coverage).

5 299 слов12 разделов0 иллюстраций
↓ Скачать Markdown

Введение

В первой лекции мы рассмотрели подходы «черного» и «серого» ящика, при которых тестировщик взаимодействует с системой через внешние интерфейсы — пользовательский интерфейс, API, базы данных. Эти подходы незаменимы на этапах системного и приемочного тестирования, однако они обладают фундаментальным ограничением: они позволяют обнаружить наличие дефекта, но не предоставляют информации о его локализации и причинах на уровне исходного кода.

§2.1 Введение в модульное тестирование: зачем тестировать код изнутри

Модульное тестирование (Unit Testing) решает эту задачу, смещая фокус внимания на самый низкий уровень декомпозиции — отдельные модули программного обеспечения. Под модулем (unit) понимается минимальный тестируемый компонент системы, как правило, метод или класс, который может быть изолирован от остальной системы. Изоляция достигается за счет замены всех внешних зависимостей (базы данных, сетевые вызовы, файловая система) на специальные заглушки (stubs) или имитации (mocks). Это позволяет проверять не интеграцию компонентов, а исключительно корректность алгоритмической логики внутри модуля.

Ключевая идея модульного тестирования может быть представлена следующей схемой:

Система модульного тестирования

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

  1. Локализация дефектов. Если упал тест модуля А — проблема именно в нем, а не в модуле В или С.
  2. Скорость выполнения. Юнит-тесты работают в памяти, без обращения к диску или сети, и выполняются за миллисекунды.
  3. Документирование кода. Тесты являются живой спецификацией того, как должен вести себя модуль в различных ситуациях.

Рассмотрим практический пример на С#. Пусть у нас есть класс, вычисляющий итоговую цену товара с учетом скидки и налога:

csharp

public class PriceCalculator
{
    public decimal CalculateFinalPrice(decimal basePrice, decimal discountPercent, bool isTaxable)
    {
        if (basePrice < 0)
            throw new ArgumentException("Цена не может быть отрицательной");

        decimal priceAfterDiscount = basePrice * (1 - discountPercent / 100);
        decimal finalPrice = isTaxable ? priceAfterDiscount * 1.2m : priceAfterDiscount;
        return Math.Round(finalPrice, 2);
    }
}

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

Компетенции тестировщика

Модульное тестирование традиционно находится в зоне ответственности разработчика, а не тестировщика. Однако тестировщик, понимающий основы юнит-тестирования, приобретает несколько важных компетенций:

  • Глубже понимает архитектуру
  • Эффективнее взаимодействует с разработчиками
  • Способен оценивать качество кода

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

§2.2 Основы Unit Testing: структура теста

Любой модульный тест, независимо от языка программирования и используемого фреймворка, подчиняется единому структурному паттерну, известному как AAA (Arrange — Act — Assert). Этот паттерн задает логическую последовательность действий, которая делает тест понятным, воспроизводимым и легким для отладки. Нарушение этой структуры приводит к «мусорным» тестам, в которых перемешаны подготовка данных, выполнение кода и проверка результатов — такие тесты сложно читать и поддерживать.

Структура ААА может быть представлена следующей схемой:

Паттерн AAA: структура модульного теста

Рассмотрим практическую реализацию на примере метода CalculateFinalPrice, который мы использовали в предыдущем параграфе. Для написания тестов будем использовать фреймворк xUnit — один из наиболее распространенных инструментов в экосистеме .NET.

csharp

using Xunit;

public class PriceCalculatorTests
{
    [Fact]
    public void CalculateFinalPrice_WithDiscountAndTax_ReturnsCorrectRoundedValue()
    {
        // ARRANGE: подготовка данных
        var calculator = new PriceCalculator();
        decimal basePrice = 100.0m;
        decimal discountPercent = 10.0m; // 10% скидка
        bool isTaxable = true; // облагается налогом
        decimal expected = 108.00m; // 100 * 0.9 * 1.2 = 108.0

        // ACT: вызов тестируемого метода
        decimal actual = calculator.CalculateFinalPrice(basePrice, discountPercent, isTaxable);

        // ASSERT: проверка результата
        Assert.Equal(expected, actual);
    }
}

В этом примере четко выделены три блока. В секции Arrange мы создаем экземпляр тестируемого класса и определяем входные параметры. В секции Act мы вызываем метод и сохраняем возвращенное значение. В секции Assert мы сравниваем фактический результат с ожидаемым при помощи встроенного метода Assert.Equal. Если значения не совпадут, xUnit сгенерирует исключение, и тест будет помечен как проваленный.

Помимо проверки корректных сценариев, модульные тесты должны покрывать и негативные случаи — например, передачу недопустимых аргументов:

csharp

[Fact]
public void CalculateFinalPrice_NegativeBasePrice_ThrowsArgumentException()
{
    // ARRANGE
    var calculator = new PriceCalculator();
    decimal invalidPrice = -50.0m;

    // ACT & ASSERT: проверяем, что метод выбрасывает ожидаемое исключение
    Assert.Throws<ArgumentException>(() => calculator.CalculateFinalPrice(invalidPrice, 10.0m, true));
}

Здесь мы не вызываем метод напрямую, а передаем его в делегате методу Assert.Throws, который перехватывает исключение и считает тест успешным, если исключение было выброшено, и проваленным — если метод завершился без ошибок.

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

  1. Setup (инициализация)

    — код, который выполняется перед каждым тестовым методом. Используется для создания общих объектов, чтобы избежать дублирования в каждом тесте. В xUnit для этого используются конструкторы класса-теста (в отличие от NUnit или MSTest, где применяются специальные атрибуты [SetUp]).

  2. Test (выполнение)

    — сам тестовый метод с структурой ААА, который мы рассмотрели выше. Каждый тест должен быть независимым от других — порядок выполнения не должен влиять на результат.

  3. Teardown (очистка)

    — код, который выполняется после каждого теста. Используется для освобождения ресурсов (закрытие соединений, удаление временных файлов и т.п.). В xUnit для этого реализуется интерфейс IDisposable.

Пример с использованием жизненного цикла в xUnit:

csharp

public class PriceCalculatorTests : IDisposable
{
    private PriceCalculator _calculator;

    // Это конструктор – он выполняет роль Setup
    public PriceCalculatorTests()
    {
        _calculator = new PriceCalculator();
        // Здесь можно инициализировать общие ресурсы
    }

    [Fact]
    public void CalculateFinalPrice_ValidInput_ReturnsCorrectResult()
    {
        // Используем _calculator, созданный в конструкторе
        var result = _calculator.CalculateFinalPrice(100m, 10m, true);
        Assert.Equal(108.00m, result);
    }

    // Это метод Dispose – он выполняет роль Teardown
    public void Dispose()
    {
        // Здесь можно освободить ресурсы, если они использовались
        // Например, закрыть файловые потоки или соединения с БД
    }
}

Вывод

Соблюдение структуры ААА и понимание жизненного цикла теста являются базовыми навыками, которые отличают профессиональное модульное тестирование от хаотичного «проверил и забыл». Тест, написанный по этим правилам, легко читается, поддерживается и модифицируется, а его провал сразу указывает на конкретную проблему.

§2.3 Тестирование методом «белого ящика»

От черного к белому ящику

В первой лекции мы рассмотрели подходы «черного» и «серого» ящика, при которых тестировщик работает с системой как с внешним объектом, наблюдая лишь входные и выходные данные. Эти подходы эффективны для проверки соответствия продукта требованиям пользователя, однако они имеют фундаментальное ограничение: тестировщик не видит внутреннюю структуру кода и не может целенаправленно проверить все возможные пути выполнения.

Белый ящик (White-box testing), также известный как структурное или прозрачное тестирование, устраняет это ограничение, предоставляя тестировщику полный доступ к исходному коду и внутренней архитектуре системы.

Ключевое отличие белого ящика от черного можно представить следующей схемой:

При тестировании черным ящиком мы видим только вход и выход. При белом ящике мы видим всю внутреннюю структуру и можем целенаправленно проверять каждый путь выполнения.

Рассмотрим практический пример на C# с использованием фреймворка xUnit. Пусть у нас есть метод, определяющий категорию клиента на основе его возраста и суммы покупок:

csharp

public string GetCustomerCategory(int age, decimal totalPurchases)
{
    if (age < 0)
        throw new ArgumentException("Возраст не может быть отрицательным");
    if (age < 18)
        return "Minor";
    if (totalPurchases > 10000m)
        return "Premium";
    if (totalPurchases > 5000m)
        return "Regular";
    return "Standard";
}

При тестировании черным ящиком мы бы подобрали несколько наборов входных данных и проверили выходные значения. Однако мы не знали бы, все ли ветви кода были выполнены. Например, если мы протестируем только случаи age = 25, purchases = 6000 и age = 30, purchases = 2000, мы никогда не проверим ветвь age < 18 и ветвь totalPurchases > 10000. При белом ящике мы видим структуру кода и целенаправленно создаем тесты для каждой ветви.

Прежде чем перейти к примеру, необходимо разобраться с базовыми атрибутами xUnit, которые определяют, как тестовый фреймворк должен выполнять наши проверки:

  • [Fact] — маркирует метод как тест, который выполняется один раз и не принимает параметров. Используется для проверки конкретного, фиксированного сценария.
  • [Theory] — маркирует параметризованный тест, который выполняется многократно с разными наборами входных данных. В сочетании с атрибутом [InlineData] позволяет передавать в тестовый метод различные аргументы. xUnit запустит тест отдельно для каждого [InlineData], что значительно сокращает дублирование кода.

Теперь напишем тесты, покрывающие все ветви метода GetCustomerCategory:

csharp

public class CustomerServiceTests
{
    [Theory]
    [InlineData(-5, 1000, typeof(ArgumentException))] // age < 0
    [InlineData(15, 5000, "Minor")] // age < 18
    [InlineData(25, 15000, "Premium")] // purchases > 10000
    [InlineData(25, 7500, "Regular")] // purchases > 5000
    [InlineData(25, 3000, "Standard")] // все остальное
    public void GetCustomerCategory_VariousInputs_ReturnsCorrectCategory(
        int age, decimal purchases, object expected
    )
    {
        // ARRANGE
        var service = new CustomerService();

        // ACT & ASSERT
        if (expected is Type exceptionType)
        {
            Assert.Throws(exceptionType,
                () => service.GetCustomerCategory(age, purchases));
        }
        else
        {
            var result = service.GetCustomerCategory(age, purchases);
            Assert.Equal(expected, result);
        }
    }
}

Этот тест с [Theory] будет выполнен пять раз — по одному запуску для каждого [InlineData]. При первом запуске xUnit подставит значения -5, 1000, typeof(ArgumentException) в параметры метода и проверит, что выбрасывается исключение. При втором запуске — 15, 5000, "Minor" — и проверит, что метод возвращает строку "Minor", и так далее. Таким образом, один метод покрывает все пять логических путей выполнения, что гарантирует, что каждая ветвь кода проверена хотя бы одним тестовым случаем.

Области применения белого ящика

Белый ящик применяется преимущественно на следующих уровнях тестирования:

  1. Модульное тестирование (Unit Testing) — основной сценарий использования
  2. Интеграционное тестирование сложной логики — проверка взаимодействия модулей
  3. Тестирование безопасности (Security Testing) — анализ кода на наличие уязвимостей

Белый ящик, однако, имеет ограничения. Он требует доступа к исходному коду и глубокого понимания алгоритмов, что делает его дорогим и трудоемким для сложных систем. Кроме того, он не позволяет оценить, соответствует ли продукт ожиданиям пользователя — это остается за рамками структурного подхода. Поэтому на практике белый ящик никогда не используется изолированно: он дополняет черный и серый ящики, формируя комплексную стратегию тестирования.

Профессиональный рост

Белый ящик — это не «удел разработчиков»; современный инженер по тестированию обязан уметь читать код, понимать логику ветвлений и проектировать тесты на основе этого знания. Это позволяет ему не только находить дефекты, но и участвовать в код-ревью, предлагать улучшения архитектуры и эффективно коммуницировать с разработчиками на их языке.

§2.4 Основные техники белого ящика

Тестирование белым ящиком опирается на формальные метрики, позволяющие оценить, насколько полно набор тестов покрывает внутреннюю структуру кода. Эти метрики называются покрытием кода (Code Coverage) и делятся на несколько уровней в зависимости от того, что именно проверяется: отдельные операторы, логические ветвления или все возможные маршруты выполнения. Понимание этих уровней критически важно, поскольку каждый из них дает различную степень уверенности в качестве тестирования и требует разных затрат на разработку тестов.

Рассмотрим три основные техники на примере метода, определяющего размер скидки в зависимости от суммы заказа и статуса клиента:

csharp

public decimal CalculateDiscount(decimal orderAmount, bool isPremium)
{
    decimal discount = 0m;
    if (orderAmount > 1000m)
    {
        discount = 50m;
    }
    if (isPremium)
    {
        discount += 20m;
    }
    if (discount > 100m)
    {
        discount = 100m;
    }
    return discount;
}

Теперь последовательно рассмотрим, какие метрики покрытия можно применить к этому методу.

Метод №1. Покрытие операторов (Statement Coverage)

Покрытие операторов отвечает на вопрос: «Был ли выполнен каждый оператор в коде хотя бы один раз?» Это самая простая и грубая метрика, которая применяется при тестировании. Для достижения 100% покрытия операторов необходимо, чтобы каждый исполняемый оператор (каждая строка кода, кроме объявлений) был задействован хотя бы в одном тесте.

Рассмотрим один тест:

csharp

[Fact]
public void CalculateDiscount_PremiumClientLargeOrder_ReturnsCappedDiscount()
{
    var service = new DiscountService();
    var result = service.CalculateDiscount(2000m, true);
    Assert.Equal(70m, result); // 50 + 20 = 70
}

Этот тест выполнит следующие операторы:

  • decimal discount = 0m;
  • if (orderAmount > 1000m) — условие истинно → discount = 50m;
  • if (isPremium) — условие истинно → discount += 20m;
  • if (discount > 100m) — условие ложно → пропускаем тело
  • return discount;

Все операторы метода были выполнены. Покрытие операторов = 100%.

Плюсы: Простота достижения, минимальные усилия по написанию тестов.

Минусы: Не гарантирует проверку всех логических условий. В нашем примере мы не проверили ни одного случая, когда условия ложны. Дефект в ветви else (которой в коде нет, но логика могла бы быть другой) останется незамеченным.

Метод №2. Покрытие ветвей (Branch Coverage)

Покрытие ветвей отвечает на вопрос: «Было ли каждое логическое условие проверено как на истину, так и на ложь?» Это более строгая метрика, чем покрытие операторов. Для 100% покрытия ветвей необходимо, чтобы каждый if, switch, тернарный оператор и т.п. были протестированы в обоих состояниях.

В нашем методе три условных оператора. Чтобы достичь 100% покрытия ветвей, нужно проверить все комбинации истинности/ложности:

csharp

[Theory]
[InlineData(2000, true, 70)]   // >1000 ∧ isPremium
[InlineData(2000, false, 50)]  // >1000 ∧ !isPremium
[InlineData(500, true, 20)]    // !(>1000) ∧ isPremium
[InlineData(500, false, 0)]    // !(>1000) ∧ !isPremium
public void CalculateDiscount_AllBranches_ReturnsCorrectDiscount(
    decimal amount, bool isPremium, decimal expected
)
{
    var service = new DiscountService();
    var result = service.CalculateDiscount(amount, isPremium);
    Assert.Equal(expected, result);
}

Эти четыре теста проверяют:

  1. Первое условие (orderAmount > 1000) — и истину (2000), и ложь (500).
  2. Второе условие (isPremium) — и истину (true), и ложь (false).
  3. Третье условие (discount > 100) — при некоторых комбинациях (например, 2000, true даёт скидку 70, что < 100, а если бы сумма была больше, мы бы проверили и эту ветвь). В текущем примере при данных значениях третье условие всегда ложно, поэтому для 100% покрытия ветвей нужно добавить тест, где скидка превышает 100.

Плюсы: Выявляет ошибки в логических условиях, более надежна, чем покрытие операторов.

Минусы: Не гарантирует проверку всех комбинаций условий и всех маршрутов выполнения.

Метод №3. Покрытие путей (Path Coverage)

Покрытие путей отвечает на вопрос: «Были ли пройдены все возможные маршруты выполнения от начала до конца метода?» Это самая строгая метрика.

Путь — это уникальная последовательность выполнения ветвлений. Для метода с N независимыми условными операторами максимальное число путей равно 2N2^N2N (в нашем случае 23=82^3 = 823=8 путей). Однако не все пути достижимы, и некоторые могут быть исключены.

Для нашего метода возможны следующие пути (символом Т обозначено истинное условие, F — ложное):

ПутьУсловие 1 (>1000)Условие 2 (isPremium)Условие 3 (>100)Итоговая скидка
1ТТТ100 (capped)
2ТТF70
3ТFТ50 (ne >100)
4ТFF50
5FТТ20 (ne >100)
6FТF20
7FFТ0 (ne >100)
8FFF0

Чтобы достичь 100% покрытия путей, нужно написать тесты для каждого достижимого пути. В нашем случае пути 3 и 4 идентичны по результату (скидка 50), но для покрытия путей они считаются разными, так как проходят через разные комбинации условий. Это требует 8 тестов.

Плюсы: Максимальная гарантия выявления дефектов, особенно в сложной бизнес-логике с множеством условий.

Минусы: Комбинаторный взрыв. Для метода с 10 условиями потребуется 1024 теста, для 20 условий — более миллиона. Покрытие путей экономически нецелесообразно для большинства реальных систем.

Индустриальный стандарт

На практике индустриальным стандартом является покрытие ветвей на уровне 70-90%. Стремление к 100% покрытию операторов часто неоправданно, а покрытие путей применяется только для самых критичных модулей. Важно не гнаться за процентами, а понимать, какие участки кода наиболее рискованны, и направлять усилия на их тщательное тестирование.

§2.5 Метрики покрытия кода

В предыдущем параграфе мы рассмотрели техники белого ящика и поняли, что покрытие кода — это не абстрактная теория, а практический инструмент оценки качества тестирования. Code Coverage (покрытие кода) — это метрика, показывающая, какая часть исходного кода была выполнена во время прогона тестов. Она измеряется в процентах и рассчитывается как отношение количества исполненных операторов, ветвей или путей к общему числу соответствующих элементов в коде.

Зачем измерять покрытие кода? Основных причин три:

  1. Выявление непроверенных участков. Отчет о покрытии подсвечивает строки кода, которые ни разу не были выполнены ни одним тестом. Это сигнал: либо тесты неполны, либо код мертвый (недостижимый) и его можно удалить.
  2. Оценка рисков. Низкое покрытие в критически важных модулях — повод для дополнительного тестирования. Высокое покрытие в простых DTO-классах — не повод для гордости.
  3. Контроль качества в CI/CD. Многие пайплайны сборки настроены так, что падают, если покрытие новых изменений ниже заданного порога (например, 80%). Это предотвращает слияние кода, который не был должным образом протестирован.

Для измерения покрытия в экосистеме .NET используются следующие инструменты:

ИнструментОписаниеОсобенности
CoverletСовременный open-source инструмент для .NETИнтегрируется с xUnit/NUnit/MSTest, работает с .NET Core/.NET 5+, генерирует отчеты в форматах Cobertura, OpenCover, JSON
OpenCoverКлассический инструмент для .NET FrameworkПоддерживает xUnit, NUnit, MSTest, требует больше настроек, чем Coverlet
dotCoverПлатный инструмент от JetBrainsИнтегрируется с Rider и Visual Studio, удобный GUI, показывает покрытие в реальном времени
JaCoCoИнструмент для JavaАналог Coverlet в мире Java, стандарт де-факто для проектов на JVM

Для практической демонстрации рассмотрим, как запустить Coverlet в .NET-проекте и прочитать отчет. Предположим, у нас есть проект с тестами на xUnit. Для измерения покрытия достаточно выполнить команду в терминале:

bash

dotnet test --collect:"xPlat Code Coverage"

После выполнения тестов в папке TestResults появится файл coverage.cobertura.xml — это и есть отчет о покрытии. Чтобы визуализировать его, можно использовать расширение для Visual Studio или VS Code. Для Visual Studio Enterprise подсветка покрытия встроена, однако у большинства студентов нет доступа к этой версии. Существует бесплатная кроссплатформенная альтернатива — утилита ReportGenerator, которая преобразует XML-отчет в удобный HTML-документ с подсветкой строк кода. Для этого выполните следующие шаги:

  1. Установите ReportGenerator (однократно):

bash

dotnet tool install -g dotnet-reportgenerator-globaltool
  1. Сгенерируйте HTML-отчет:

bash

reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html
  1. Откройте файл coveragereport/index.html в браузере.

В результате вы увидите таблицу с процентами покрытия по модулям, а кликнув на имя класса — подсветку исходного кода: выполненные строки будут выделены зеленым, непокрытые — красным.

Рассмотрим, какие данные содержит этот отчет и как их интерпретировать.

В верхней части отчета приведены три ключевые метрики, каждая из которых отвечает на свой вопрос о качестве тестирования:

  1. Line coverage (Покрытие строк) — 80%. Эта метрика показывает, какая доля исполняемых строк кода была выполнена хотя бы один раз за время прогона тестов.
  2. Branch coverage (Покрытие ветвей) — 75%. Более строгая метрика, отвечающая на вопрос: «Были ли проверены все логические ветвления как на истину, так и на ложь?».
  3. Method coverage (Покрытие методов) — 100%. Самая простая метрика, показывающая, был ли вызван хотя бы один раз каждый метод.

Цвета в отчете служат визуальным навигатором, позволяющим мгновенно оценить состояние кода:

  • Зеленый фон — строка выполнена (покрыта)
  • Красный фон — строка НЕ выполнена (не покрыта). Это зона риска
  • Желтый / Оранжевый фон — строка выполнена частично (например, условие проверено только в одном направлении)

Мифы о покрытии кода

  • Миф №1: «80% покрытия — это гарантия качества». Нет. Можно проверить 80% строк, но пропустить самую критическую бизнес-логику.
  • Миф №2: «Нужно стремиться к 100% покрытия». 100% покрытие не гарантирует отсутствие дефектов. Можно покрыть все строки, но проверять неправильные ожидания.
  • Миф №3: «Если покрытие низкое, то код плохой». Низкое покрытие в UI-слое или в классах-конфигурациях — нормально. Низкое покрытие в бизнес-логике — тревожный сигнал.

На практике индустриальный стандарт выглядит так:

Уровень покрытияИнтерпретация
< 60%Тревожный сигнал. Система тестируется недостаточно, высокий риск критических дефектов.
60% — 80%Приемлемый уровень для большинства проектов. Основные сценарии покрыты, но есть зоны для улучшения.
80% — 90%Хороший уровень для бизнес-критичных модулей. Дальнейшее увеличение требует непропорционально больших усилий.
> 90%Отлично, но требует осторожности. Высокий процент может быть достигнут за счет тестирования тривиальных методов.

Главное правило

Смотреть не на проценты, а на непокрытые строки. Если отчет показывает, что непокрытыми остались именно те участки кода, которые содержат сложную логику, обработку исключений или граничные условия — значит, именно туда и нужно направить усилия по написанию новых тестов. Code Coverage — это навигатор, а не самоцель.

§2.6 Инструменты для Unit Testing в экосистеме .NET

Модульное тестирование в .NET опирается на экосистему фреймворков и библиотек, которые стандартизируют процесс написания, запуска и анализа тестов. Выбор конкретного инструментария определяется требованиями проекта, опытом команды и корпоративными стандартами, однако все современные инструменты следуют единым принципам, что делает их взаимозаменяемыми в изучении.

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

// xUnit - современный фреймворк
using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_TwoNumbers_ReturnsSum()
    {
        var calc = new Calculator();
        var result = calc.Add(2, 3);
        Assert.Equal(5, result);
    }

    [Theory]
    [InlineData(1, 1, 2)]
    [InlineData(2, 3, 5)]
    public void Add_VariousInputs_ReturnsExpected(int a, int b, int expected)
    {
        var calc = new Calculator();
        var result = calc.Add(a, b);
        Assert.Equal(expected, result);
    }
}

Сравнение фреймворков:

ХарактеристикаxUnitNUnitMSTest
Параметризация[Theory] + [InlineData][TestCase][DataRow]
ИнициализацияКонструктор класса[SetUp][TestInitialize]
ОчисткаIDisposable[TearDown][TestCleanup]
Основной сценарийНовые проектыЛегаси-проектыКорпоративные проекты

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

При тестировании модуля в изоляции возникает необходимость заменять его зависимости (базы данных, внешние API, файловую систему) на управляемые имитации. Для этого используются библиотеки моков, которые позволяют создать «фальшивые» реализации интерфейсов и настроить их поведение.

Moq

— самая популярная библиотека моков в .NET. Она использует лямбда-выражения и fluent-синтаксис, что делает настройку моков интуитивно понятной. Рассмотрим пример. Допустим, у нас есть интерфейс репозитория, который обращается к базе данных:

csharp

public interface IOrderRepository
{
    Order GetById(int id);
    void Save(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public decimal CalculateTotal(int orderId)
    {
        var order = _repository.GetById(orderId);
        if (order == null)
            throw new ArgumentException("Order not found");
        return order.Items.Sum(item => item.Price * item.Quantity);
    }
}

Для тестирования OrderService нам не нужна реальная база данных. Мы создаем заглушку (mock) с помощью Moq:

csharp

[Fact]
public void CalculateTotal_ExistingOrder_ReturnsSumOfItems()
{
    // Arrange: создаем mock репозитория
    var mockRepo = new Mock<IOrderRepository>();

    // Настраиваем поведение:
    // при вызове GetById(1) возвращать заказ с двумя товарами
    var order = new Order
    {
        Id = 1,
        Items = new List<OrderItem>
        {
            new OrderItem { Price = 10m, Quantity = 2 },
            new OrderItem { Price = 15m, Quantity = 1 }
        }
    };

    mockRepo.Setup(repo => repo.GetById(1)).Returns(order);

    // Создаем сервис, внедряя mock
    var service = new OrderService(mockRepo.Object);

    // Act
    var total = service.CalculateTotal(1);

    // Assert
    Assert.Equal(35m, total);
}

NSubstitute

— альтернативная библиотека, которая предлагает еще более лаконичный синтаксис. Вместо Mock<> используется Substitute.For<>(), что делает код короче. Обе библиотеки равноценны, выбор — дело вкуса.

Для сравнения, тот же тест с использованием NSubstitute выглядит так:

csharp

[Fact]
public void CalculateTotal_ExistingOrder_ReturnsSumOfItems()
{
    // Arrange: создаем заглушку через NSubstitute
    var mockRepo = Substitute.For<IOrderRepository>();

    // Настраиваем поведение – синтаксис более прямой
    var order = new Order
    {
        Id = 1,
        Items = new List<OrderItem>
        {
            new OrderItem { Price = 10m, Quantity = 2 },
            new OrderItem { Price = 15m, Quantity = 1 }
        }
    };
    mockRepo.GetById(1).Returns(order);

    // Создаем сервис
    var service = new OrderService(mockRepo);

    // Act
    var total = service.CalculateTotal(1);

    // Assert
    Assert.Equal(35m, total);
}

§2.7 Границы модульного тестирования: что НЕ надо тестировать юнит-тестами

Основное правило

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

Основное правило: юнит-тест проверяет логику, а не интеграцию.

Если тест обращается к внешним ресурсам — базе данных, файловой системе, сети, внешним API — он перестает быть юнит-тестом и становится интеграционным. Почему это важно? Внешние ресурсы делают тест медленным, нестабильным и сложным в настройке. Такой тест может упасть не из-за ошибки в коде, а из-за проблем с сетью или недоступности базы данных.

Рассмотрим наглядный пример. Предположим, у нас есть метод, который сохраняет заказ в базу данных:

csharp

public class OrderService
{
    private readonly SqlConnection _connection;

    public OrderService(SqlConnection connection)
    {
        _connection = connection;
    }

    public void SaveOrder(Order order)
    {
        using var cmd = new SqlCommand("INSERT INTO Orders ...", _connection);
        cmd.ExecuteNonQuery();
    }
}

Неправильно — интеграционный тест

Пытаться тестировать этот метод как юнит-тест, создавая реальное подключение к БД:

csharp

[Fact]
public void SaveOrder_ValidOrder_SavesToDatabase()
{
    // Arrange
    var connection = new SqlConnection("RealConnectionString");
    var service = new OrderService(connection);
    var order = new Order { ... };

    // Act
    service.SaveOrder(order);

    // Assert
    // ... проверка, что запись появилась в БД
}

Такой тест медленный (сеть, дисковые операции), ненадежный (падает, если БД недоступна), и требует внешних настроек. Это интеграционный тест, и у него своя область применения.

Правильно — юнит-тест с моком

Тестировать логику, изолировав внешние зависимости. Для этого используется внедрение зависимости и моки:

csharp

public interface IOrderRepository
{
    void Save(Order order);
}

public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public void SaveOrder(Order order)
    {
        // Валидация, бизнес-логика
        if (order.TotalAmount < 0)
            throw new ArgumentException();
        // ... другие проверки

        _repository.Save(order);
    }
}

Теперь мы можем протестировать логику без базы данных:

csharp

[Fact]
public void SaveOrder_NegativeTotal_ThrowsException()
{
    // Arrange
    var mockRepo = new Mock<IOrderRepository>();
    var service = new OrderService(mockRepo.Object);
    var invalidOrder = new Order { TotalAmount = -100 };

    // Act & Assert
    Assert.Throws<ArgumentException>(
        () => service.SaveOrder(invalidOrder)
    );

    // Проверяем, что Save НЕ был вызван (логика не допустила)
    mockRepo.Verify(r => r.Save(It.IsAny<Order>()), Times.Never);
}

Этот тест быстрый (миллисекунды), детерминированный и не зависит от окружения.

Разграничение между юнит-тестами и интеграционными тестами можно представить пирамидой тестирования:

На практике проект содержит значительно больше юнит-тестов, чем интеграционных, и значительно больше интеграционных, чем Е2Е-тестов. Соотношение примерно 70/20/10.

Для того чтобы юнит-тесты приносили пользу, они должны соответствовать принципам F.I.R.S.T. , сформулированным Робертом Мартином:

  • Fast (Быстрый) — тест должен выполняться за миллисекунды. Если тест длится секунды, он замедляет разработку.
  • Independent (Независимый) — каждый тест не должен зависеть от других. Порядок выполнения не влияет на результат.
  • Repeatable (Повторяемый) — тест дает одинаковый результат при каждом запуске. Никакой внешней нестабильности.
  • Self-validating (Самопроверяемый) — тест сам определяет, прошел он или упал (assert). Никакого ручного просмотра логов.
  • Timely (Своевременный) — тест пишется до или сразу после написания кода, а не через месяц, когда логика забыта.

Нарушения принципов

Нарушение любого из этих принципов делает тест обузой, а не помощью. Самые частые нарушения — медленные тесты (из-за работы с БД/сетью) и зависимость между тестами (когда один тест изменяет общее состояние).

§2.8 Тестопригодность кода

В первой лекции мы сформулировали ключевой постулат:

Постулат

«Код, который нельзя протестировать, является кодом плохого качества по определению».

Теперь, вооружившись знаниями о модульном тестировании, мы можем раскрыть этот тезис на практическом уровне. Тестопригодность (Testability) — это характеристика кода, определяющая, насколько легко для него можно написать и выполнить юнит-тесты. Высокая тестопригодность достигается не магией, а следованием конкретным архитектурным принципам, которые мы рассмотрим на контрастных примерах.

Код становится тестопригодным, когда он следует принципу инверсии зависимостей (Dependency Inversion) — один из пяти принципов SOLID. Суть его проста: код должен зависеть от абстракций (интерфейсов), а не от конкретных реализаций. Это позволяет в тестах подставлять вместо реальных зависимостей (база данных, сеть, файловая система) их имитации (моки).

Тестопригодный код

— использует интерфейсы и внедрение зависимостей (Dependency Injection, DI):

csharp

public interface IEmailSender
{
    void Send(string to, string subject, string body);
}

public class NotificationService
{
    private readonly IEmailSender _emailSender;

    public NotificationService(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public void NotifyUser(string email, string message)
    {
        // Бизнес-логика: формирование письма, проверки
        _emailSender.Send(email, "Уведомление", message);
    }
}

Такой код тестируется легко: в тесте мы подставляем мок IEmailSender, проверяем, что метод Send был вызван с правильными параметрами, и не беспокоимся о реальной отправке писем. Тест быстр и надежен.

Теперь стоит обсудить антипаттерны, которые «убивают» тестопригодность.

Антипаттерн №1. Жесткие зависимости

Когда код сам создает свои зависимости, а не получает их извне:

csharp

public class NotificationService
{
    private readonly SmtpClient _smtpClient = new SmtpClient("smtp.gmail.com");

    public void NotifyUser(string email, string message)
    {
        _smtpClient.Send(email, "Уведомление", message);
    }
}

Такой код невозможно протестировать без реального SMTP-сервера. Метод Send вызывается внутри, и мы не можем его переопределить или проверить, был ли он вызван.

Антипаттерн №2. Статические методы и классы

Глобальное состояние, которое сложно изолировать:

csharp

public static class EmailSender
{
    public static void Send(string to, string subject, string body)
    {
        // ...
    }
}

public class NotificationService
{
    public void NotifyUser(string email, string message)
    {
        EmailSender.Send(email, "Уведомление", message);
    }
}

Статические методы нельзя замокать, а их поведение глобально влияет на все тесты. Если статический метод обращается к внешнему ресурсу (например, файлу конфигурации), каждый тест становится нестабильным.

Антипаттерн №3. Синглтоны (Singleton)

Еще один вид глобального состояния:

csharp

public sealed class ConfigManager
{
    public static ConfigManager Instance { get; } = new ConfigManager();
    public string ConnectionString => ConfigurationManager.AppSettings["DbConnection"];
}

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

Процесс повышения тестопригодности можно визуализировать:

§2.9 Место белого ящика в общей стратегии QA

Вторая лекция познакомила нас с тестированием «белого ящика» — подходом, который позволяет заглянуть внутрь программного кода и целенаправленно проверять его структуру. В отличие от черного ящика, где мы оцениваем внешнее поведение системы, белый ящик дает возможность выявить дефекты на уровне логических ветвлений, граничных условий и скрытых путей выполнения. Однако важно понимать, что эти подходы не конкурируют, а дополняют друг друга.

Когда и что использовать:

ПодходКогда применятьЧто дает
Черный ящикСистемное и приемочное тестированиеПроверка соответствия требованиям, оценка с точки зрения пользователя
Серый ящикИнтеграционное тестирование, API, БДПроверка корректности обмена данными между компонентами
Белый ящикМодульное тестирование, сложная логикаПроверка всех ветвлений, границ, путей выполнения

Комбинация подходов

Профессиональный подход заключается в разумной комбинации всех трех техник. Белый ящик обеспечивает надежный фундамент: если каждый метод покрыт юнит-тестами, мы уверены в корректности базовых алгоритмов. Серый ящик проверяет, что модули корректно взаимодействуют друг с другом. Черный ящик дает финальную оценку: «А правильно ли мы построили продукт с точки зрения пользователя?».

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

Главный вывод этой лекции: тестирование — это не про «нажать кнопки и посмотреть, упало ли». Это про инженерный подход, где каждый тест имеет обоснование, а каждый инструмент применяется осознанно.

Владение белым ящиком превращает тестировщика из «пользователя программы» в «инженера качества», способного анализировать код, проектировать тесты на основе его структуры и предлагать улучшения архитектуры.

§2.10 Введение в TDD (Test-Driven Development)

В предыдущих параграфах мы рассматривали модульное тестирование как способ проверки уже написанного кода. Однако в современной инженерии тесты выполняют не только контрольную, но и проектную функцию. Этот подход называется Test-Driven Development (TDD) — разработка через тестирование.

Ключевая идея TDD формулируется парадоксально: тесты пишутся до того, как написан сам код. Это не безумие, а системный способ управления сложностью. Разработчик сначала формулирует требования к модулю в виде теста (который заведомо не может быть выполнен, потому что модуля еще нет), а затем пишет ровно столько кода, сколько нужно для прохождения этого теста.

Процесс TDD подчиняется жесткому циклу из трех фаз, известному как Red-Green-Refactor:

  1. Red (Красный)

    — написать тест, который проверяет новое требование. Запустить все тесты и убедиться, что новый тест падает (красный индикатор). Это критически важно: если тест прошел без написания кода, значит, он либо проверяет уже существующее поведение, либо написан некорректно.

  2. Green (Зеленый)

    — написать минимальный объем кода, достаточный для прохождения теста. На этом этапе разрешены «временные хакерские» решения (например, вернуть константу), главное — сделать тест зеленым как можно быстрее.

  3. Refactor (Рефакторинг)

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

Важное различие: TDD — это не просто «написать тест первым». Это методология проектирования. Когда разработчик пишет тест до кода, он вынужден ответить на три вопроса:

  • Как я буду использовать этот метод? (проектирование API)
  • Какие входные данные я должен предусмотреть? (анализ граничных условий)
  • Какое состояние я ожидаю на выходе? (формализация требований)

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

Сравнение подходов:

ХарактеристикаТрадиционное тестированиеTDD
Когда пишутся тестыПосле кодаДо кода
ЦельПроверка корректностиПроектирование + проверка
Как пишется кодСначала логика, потом тестыСначала тест (спецификация), потом логика
ТестопригодностьКод может быть плохо тестируемКод заведомо тестируем
Покрытие кодаМожет быть низкимВысокое по определению

Предположим, мы хотим реализовать метод, который проверяет, является ли строка палиндромом.

Шаг 1 (Red): пишем тест на несуществующий метод:

csharp

[Fact]
public void IsPalindrome_ValidPalindrome_ReturnsTrue()
{
    var validator = new StringValidator(); // класса еще нет!
    bool result = validator.IsPalindrome("aba"); // метода еще нет!
    Assert.True(result);
}

Запуск теста — ошибка компиляции (красный). Мы только что поставили задачу.

Шаг 2 (Green): пишем минимальную реализацию:

csharp

public class StringValidator
{
    public bool IsPalindrome(string input)
    {
        return true; // Хак! Но тест зеленый.
    }
}

Тест проходит. Мы сделали минимальное усилие.

Шаг 3 (Red + Green): пишем второй тест, чтобы поймать «жульничество»:

csharp

[Fact]
public void IsPalindrome_NonPalindrome_ReturnsFalse()
{
    var validator = new StringValidator();
    bool result = validator.IsPalindrome("abc");
    Assert.False(result);
}

Тест падает (красный). Теперь мы вынуждены написать честную реализацию:

csharp

public bool IsPalindrome(string input)
{
    return input.SequenceEqual(input.Reverse());
}

Оба теста зеленые. Рефакторинг (например, замена на цикл для производительности) — и задача решена.

Что дает понимание TDD тестировщику

Даже если Вы не планируете стать разработчиком, понимание TDD дает Вам следующее:

  • Вы понимаете, почему разработчик просит уточнить требования перед написанием кода.
  • Вы можете участвовать в обсуждении тест-кейсов на этапе анализа, когда они еще не превратились в код.
  • Вы лучше понимаете отчеты о покрытии: если покрытие низкое, это значит, что разработчик нарушил цикл TDD и написал код без тестов.

Заключение

TDD — это прагматичный инструмент. В реальных проектах разработчики не всегда следуют ему строго (особенно в легаси-коде), но знание этого цикла позволяет выстраивать эффективную коммуникацию между разработчиком и тестировщиком.