Рекомендации по модульному тестированию для .NET Core и .NET Standard

Существует множество преимуществ написания модульных тестов; они помогают с регрессией, предоставляют документацию и упрощают хорошую структуру. Но трудночитаемые и ненадежные модульные тесты могут негативно отразиться на базе кода. В этой статье приведены некоторые рекомендации, касающиеся разработки модульных тестов для проектов .NET Core и .NET Standard.

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

Автор: Джон Риз (John Reese) с особой благодарностью Рою Ошерову (Roy Osherove)

Почему именно модульные тесты?

Существует несколько причин использования модульных тестов.

Меньше времени на выполнение функциональных тестов

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

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

Защита от регрессии

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

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

Исполняемая документация

Это может быть не всегда очевидно, что конкретный метод делает или как он ведет себя с определенными входными данными. Вы можете спросить себя: как этот метод ведет себя, если я передаю пустую строку? А значение NULL?

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

Менее связанный код

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

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

Характеристики хорошего модульного теста

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

Покрытие кода

Высокий процент покрытия кода зачастую связан с более высоким качеством кода. Однако само измерение не может определить качество кода. Задание чрезмерно большого процента объема протестированного кода может снизить производительность. Представьте себе сложный проект с тысячами условных ветвей и представьте, что объем протестированного кода составляет 95 %. В настоящее время проект поддерживает объем протестированного кода в 90 %. Время, затрачиваемое на принятие всех пограничных вариантов в оставшихся 5 %, может оказаться очень значительным, а ценность предложения быстро сокращается.

Высокий процент охвата кода не является индикатором успеха, и не подразумевает высокого качества кода. Он представляет собой лишь объем кода, охваченного модульными тестами. Дополнительные сведения см. в статье Модульное тестирование объема протестированного кода.

Определимся с терминами

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

Заполнитель — это общий термин, который можно использовать для описания заглушки или макета объекта. Использование заглушки или макета зависит от контекста. Иными словами, заполнитель может быть заглушкой или макетом.

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

Заглушка — это управляемая замена существующей зависимости (или участника) в системе. С помощью заглушки можно протестировать код, не задействовав зависимость напрямую. По умолчанию заглушка выступает как заполнитель.

Рассмотрим следующий фрагмент кода:

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Предыдущий пример будет иметь заглушку, называемую макетом. В этом случае это заглушка. Вы передаете Order, чтобы создать экземпляр Purchase (тестируемая система). Имя MockOrder также вводит в заблуждение, потому что опять же, заказ не является макетом.

Лучший подход будет:

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Переименовав класс на , вы сделали класс FakeOrderгораздо более универсальным. Класс можно использовать в качестве макета или заглушки, независимо от того, что лучше для тестового случая. В предыдущем примере FakeOrder используется в качестве заглушки. Во время утверждения вы не используете FakeOrder ни одну фигуру или форму. FakeOrder передается в класс Purchase, чтобы удовлетворить требованиям конструктора.

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

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

В этом случае вы проверка свойство в Fake (утверждение против него), поэтому в предыдущем фрагменте mockOrder кода это макет.

Внимание

Очень важно правильно разобраться в терминологии. Если вы вызываете заглушки "макеты", другие разработчики будут делать ложные предположения о вашем намерении.

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

Рекомендации

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

Избегайте зависимостей инфраструктуры

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

Выбор имен для тестов

Имя теста должно состоять из трех частей:

  • имя тестируемого метода;
  • сценарий, в котором выполняется тестирование;
  • ожидаемое поведение при вызове сценария.

Почему?

Стандарты именования важны, так как они выражают намерение теста. Тесты не просто проверяют работоспособность кода — они также предоставляют документацию. Просто посмотрев на набор модульных тестов, вы должны определить поведение кода, не глядя на сам код. Кроме того, при сбое тестов можно увидеть, какие сценарии не соответствуют вашим ожиданиям.

Плохо:

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Лучше:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Упорядочивание тестов

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

  • Упорядочение объектов, создание и настройка их по мере необходимости.
  • Действуйте, работая с объектами.
  • Проверяйте, что все выполняется, как ожидалось.

Почему?

  • Объект тестирования четко отделяется от этапов упорядочивания и проверки.
  • Меньше вероятности перепутать проверочные утверждения с кодом действия.

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

Плохо:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

Лучше:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add("");

    // Assert
    Assert.Equal(0, actual);
}

Пишите минималистичные тесты

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

Почему?

  • Тесты становятся более устойчивыми к будущим изменениям в базе кода.
  • Ближе к тестированию поведения по сравнению с реализацией.

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

Плохо:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

Лучше:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Избегайте "магических" строк

Именование переменных в модульных тестах важно, если не более важно, чем именование переменных в рабочем коде. Модульные тесты не должны содержать волшебные строки.

Почему?

  • Избавит читателя теста от необходимости проверять рабочий код, чтобы выяснить, что делает значение особенным.
  • Явно показывает, что вы пытаетесь проверить, а не выполнить.

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

Совет

При написании тестов старайтесь как можно яснее выразить свое намерение. В случае "магических" строк рекомендуется присваивать эти значения константам.

Плохо:

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

Лучше:

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

Избегайте логики в тестах

При написании модульных тестов избегайте объединения строк вручную, логических условий, таких как if, whileforи switchдругие условия.

Почему?

  • Меньше шансов внедрить ошибку в тесты.
  • Направленность на конечный результат, а не на детали реализации.

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

Совет

Если логика в тесте неизбежна, рекомендуется разбить тест на несколько тестов.

Плохо:

[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
    var stringCalculator = new StringCalculator();
    var expected = 0;
    var testCases = new[]
    {
        "0,0,0",
        "0,1,2",
        "1,2,3"
    };

    foreach (var test in testCases)
    {
        Assert.Equal(expected, stringCalculator.Add(test));
        expected += 3;
    }
}

Лучше:

[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add(input);

    Assert.Equal(expected, actual);
}

Выбирайте вспомогательные методы вместо установки и удаления

Если для тестов требуется аналогичный объект или состояние, предпочтете вспомогательный метод, чем использовать Setup и Teardown атрибуты, если они существуют.

Почему?

  • Меньше путаницы при чтении тестов, так как весь код виден в каждом тесте.
  • Меньше шансов настроить слишком много или слишком мало для определенного теста.
  • Меньше шансов совместного использования состояния несколькими тестами, что приводит к нежелательным зависимостям между ними.

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

Примечание.

В xUnit удалены SetUp и TearDown в версии 2.x.

Плохо:

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
// more tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}

Лучше:

[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// more tests...
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

Избегайте нескольких действий

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

  • Создание отдельного теста для каждого действия.
  • Использование параметризованных тестов.

Почему?

  • При сбое теста ясно, какой акт завершается ошибкой.
  • Гарантирует, что тест ориентирован только на один случай.
  • Предоставляет полную картину о том, почему тесты завершаются неудачей.

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

Плохо:

[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
    // Act
    var actual1 = stringCalculator.Add("");
    var actual2 = stringCalculator.Add(",");

    // Assert
    Assert.Equal(0, actual1);
    Assert.Equal(0, actual2);
}

Лучше:

[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add(input);

    // Assert
    Assert.Equal(expected, actual);
}

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

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

Рассмотрим следующий случай:

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

Ваша первая реакция может быть начать писать тест TrimInput , потому что вы хотите убедиться, что метод работает должным образом. Тем не менее, вполне возможно, что ParseLogLine управление таким образом, что вы не ожидаете, отрисовка sanitizedInput теста против TrimInput бесполезного использования.

Настоящий тест нужно провести для открытого метода ParseLogLine, потому что в конечном итоге для вас важен именно он.

public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

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

Используйте заглушки для статических ссылок

Один из принципов модульного тестирования — его полный контроль над тестируемой системой. Этот принцип может быть проблематичным, если рабочий код включает вызовы статических ссылок (например, DateTime.Now). Рассмотрим следующий код:

public int GetDiscountedPrice(int price)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Как можно провести модульное тестирование этого кода? Вы можете попробовать такой подход, как:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(2, actual)
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(1, actual);
}

К сожалению, вы быстро поймете, что есть несколько проблем с вашими тестами.

  • Если набор тестов выполняется во вторник, второй тест будет пройден, а первый — нет.
  • Если набор тестов выполняется в другой день, первый тест будет пройден, а второй — нет.

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

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Теперь набор тестов становится следующим образом:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(2, actual);
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(1, actual);
}

Теперь набор тестов имеет полный контроль над DateTime.Now и может использовать заглушку для любого значения при вызове метода.