Рекомендации по модульному тестированию для .NET
Существует множество преимуществ написания модульных тестов. Они помогают с регрессионным тестированием, предоставляют документацию и обеспечивают хороший дизайн. Но когда модульные тесты трудно читать и они нестабильны, это может нанести ущерб вашей кодовой базе. В этой статье описаны некоторые рекомендации по проектированию модульных тестов для поддержки проектов .NET Core и .NET Standard. Вы узнаете, как обеспечить устойчивость тестов и легко понять их.
По Джон Риз с особой благодарностью Рой Ошерове
Преимущества модульного тестирования
В следующих разделах описано несколько причин написания модульных тестов для проектов .NET Core и .NET Standard.
Меньше времени выполнения функциональных тестов
Функциональные тесты являются дорогостоящими. Обычно они включают открытие приложения и выполнение ряда шагов, которые вы (или кто-то другой) должны выполнять, чтобы проверить ожидаемое поведение. Эти действия могут не всегда быть известны тестировщику. Они должны обратиться к кому-то более знающему в области, чтобы провести тест. Тестирование может занять несколько секунд для тривиальных изменений или минут для более крупных изменений. Наконец, этот процесс должен повторяться для каждого изменения, которое вы вносите в систему. Модульные тесты, с другой стороны, выполняются за миллисекунды, могут запускаться нажатием кнопки и не требуют каких-либо знаний о системе в целом. Средство выполнения теста определяет, проходит тест или завершается сбоем, а не человек.
Защита от регрессии
Дефекты регрессии — это ошибки, которые возникают при изменении приложения. Обычно тестировщики проверяют не только новые функции, но и тестируют функции, которые существуют заранее, чтобы убедиться, что существующие функции по-прежнему работают должным образом. При модульном тестировании можно повторно запустить весь набор тестов после каждой сборки или даже после изменения строки кода. Этот подход помогает повысить уверенность в том, что новый код не нарушает существующую функциональность.
Исполняемая документация
Это может быть не всегда очевидно, что конкретный метод делает или как он ведет себя с определенными входными данными. Вы можете спросить себя: Как этот метод ведет себя, если передать пустую строку или null? Если у вас есть набор хорошо именованных модульных тестов, каждый тест должен четко объяснить ожидаемые выходные данные для заданных входных данных. Кроме того, тест должен иметь возможность проверить, работает ли он на самом деле.
Менее сопряженный код
При тесном сочетании кода может быть трудно выполнить модульное тестирование. Без создания модульных тестов для кода, который вы пишете, связь может быть менее очевидной. Написание тестов для вашего кода естественным образом разделяет его, потому что в противном случае его сложнее тестировать.
Характеристики хороших модульных тестов
Существует несколько важных характеристик, определяющих хороший модульный тест:
- Быстрый: для зрелых проектов не редкость иметь тысячи модульных тестов. Модульные тесты должны занять мало времени для выполнения. Миллисекунд.
- изолированные: модульные тесты являются автономными, могут выполняться в изоляции и не зависят от каких-либо внешних факторов, таких как файловая система или база данных.
- повторяющийся. Выполнение модульного теста должно соответствовать его результатам. Тест всегда возвращает один и тот же результат, если вы ничего не измените между выполнением.
- самопроверки: тест должен автоматически определять, прошел он или провалился без какого-либо взаимодействия с человеком.
- Своевременное: написание модульного теста не должно занимать непропорционально много времени по сравнению с написанием тестируемого кода. Если вы обнаружите, что тестирование кода занимает большое количество времени по сравнению с написанием кода, рассмотрите более тестируемый дизайн.
Покрытие кода и качество кода
Высокий процент покрытия кода часто связан с более высоким качеством кода. Однако само измерение не может определить качество кода. Установка чрезмерно амбициозных целей в процентах охвата кода может быть контрпродуктивной. Рассмотрим сложный проект с тысячами условных ветвей и предположим, что вы устанавливаете цель 95% покрытия кода. В настоящее время проект поддерживает 90% покрытия кода. Время, необходимое для учета всех пограничных случаев в оставшихся 5%, может быть трудоемкой задачей, и выгода от этого быстро уменьшается.
Высокий процент покрытия кода не является индикатором успеха, и он не подразумевает высокого качества кода. Это просто представляет объем кода, охватываемого модульными тестами. Для получения дополнительной информации о покрытии кода модульного тестирования см. .
Терминология модульного тестирования
Несколько терминов часто используются в контексте модульного тестирования: fake, mockи stub. К сожалению, эти термины могут быть неправильно применяться, поэтому важно понимать правильное использование.
Поддельные: подделка является универсальным термином, который можно использовать для описания заглушки или макета объекта. Независимо от того, является ли объект заглушкой или макетом, зависит от контекста, в котором используется объект. Другими словами, подделка может быть заглушкой или имитацией.
Mock: объект макета является поддельным объектом в системе, который решает, проходит ли модульный тест или завершается сбоем. Макет начинается как подделка и остается поддельным, пока он не войдет в операцию
Assert
.заглушка: заглушка является управляемой заменой существующей депенденции (или сотрудника) в системе. Используя заглушку, вы можете протестировать код без непосредственного взаимодействия с зависимостью. По умолчанию заглушка начинается как подделка.
Рассмотрим следующий код:
var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);
Этот код показывает заглушку, называемую макетом. Но в этом сценарии заглушка действительно является заглушкой. Цель кода — передать аргумент для инициализации объекта Purchase
(системы под тестом). Имя класса MockOrder
вводит в заблуждение, потому что порядок является заглушкой, а не макетом.
В следующем коде показан более точный дизайн:
var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);
purchase.ValidateOrders();
Assert.True(purchase.CanBeShipped);
При переименовании класса в FakeOrder
класс является более универсальным. Класс можно использовать в качестве макета или заглушки в соответствии с требованиями тестового случая. В первом примере класс FakeOrder
используется в качестве заглушки, но не задействован во время операции Assert
. Код передает класс FakeOrder
в класс Purchase
только для удовлетворения требований конструктора.
Чтобы использовать класс в качестве макета, можно обновить код:
var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);
purchase.ValidateOrders();
Assert.True(mockOrder.Validated);
В этом дизайне код проверяет свойство у заместителя (проверяя его), и, следовательно, класс mockOrder
является моком.
Это важно
Важно правильно реализовать терминологию. Если вы называете свои заглушки "моками," другие разработчики будут делать ложные предположения о вашем намерении.
Главное, что нужно помнить о mock-объектах и заглушках, это то, что mock-объекты работают так же, как заглушки, за исключением процесса Assert
. Вы выполняете Assert
операций над макетным объектом, но не над заглушкой.
Лучшие практики
При написании модульных тестов существует несколько важных рекомендаций. В следующих разделах приведены примеры применения рекомендаций к коду.
Избегайте зависимостей инфраструктуры
Старайтесь не вводить зависимости от инфраструктуры при написании модульных тестов. Зависимости делают тесты медленными и хрупкими и должны быть зарезервированы для тестов интеграции. Эти зависимости можно избежать в приложении, следуя принципу явных зависимостей и используя внедрениезависимостей .NET. Вы также можете сохранить модульные тесты в отдельном проекте от тестов интеграции. Этот подход гарантирует, что в проекте модульного теста нет ссылок на пакеты инфраструктуры или зависимости.
Следуйте стандартам именования тестов
Имя теста должно состоять из трех частей:
- Имя проверяемого метода
- Сценарий, в котором тестируется метод
- Ожидаемое поведение при вызове сценария
Стандарты именования важны, так как они помогают выразить назначение теста и приложение. Тесты — это не только проверка того, что ваш код работает. Они также предоставляют документацию. Просто взглянув на набор модульных тестов, вы должны иметь возможность определить поведение кода и не нужно смотреть на сам код. Кроме того, при сбое тестов вы можете увидеть, какие сценарии не соответствуют вашим ожиданиям.
исходный код
[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);
}
Упорядочьте ваши тесты
Шаблон "Подготовка, Действие, Проверка" является распространенным подходом для написания модульных тестов. Как подразумевает имя, шаблон состоит из трех основных задач:
- Упорядочьте ваши объекты, создайте и настройте их по мере необходимости
- Действие на объекте
- Assert что нечто соответствует ожиданиям
При следовании шаблону можно четко отделить то, что тестируется, от задач "Подготовка" и "Проверка". Шаблон также помогает уменьшить возможность перемешивать утверждения с кодом в задаче Act.
Удобочитаемость является одним из наиболее важных аспектов при написании модульного теста. Разделение каждого действия шаблона в тесте ясно подчеркивает зависимости, необходимые для вызова вашего кода, способ его вызова и то, что вы пытаетесь проверить. Хотя можно объединить некоторые шаги и уменьшить размер теста, общая цель состоит в том, чтобы сделать тест максимально читаемым.
исходный код
[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
, while
, for
и 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
заставляет использовать одинаковые требования для каждого теста.
Примечание.
Атрибуты SetUp
и TearDown
удаляются в xUnit версии 2.x и более поздних версий.
исходный код
Применение передового опыта
private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
stringCalculator = new StringCalculator();
}
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
var stringCalculator = CreateDefaultStringCalculator();
var actual = stringCalculator.Add("0,1");
Assert.Equal(1, actual);
}
// More tests...
// More tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
var result = stringCalculator.Add("0,1");
Assert.Equal(1, result);
}
private StringCalculator CreateDefaultStringCalculator()
{
return new StringCalculator();
}
Избегайте выполнения нескольких задач Act
При написании тестов попробуйте включить только одну задачу Act на тест. Некоторые распространенные подходы к реализации одной задачи Закона включают создание отдельного теста для каждого Закона или использование параметризованных тестов. Существует несколько преимуществ использования одной задачи Act для каждого теста:
- Вы можете легко определить, какая задача Act не выполняется, если тест завершился неудачно.
- Вы можете убедиться, что тест ориентирован только на один случай.
- Вы получите четкое представление о том, почему ваши тесты не проходят.
Выполнение нескольких задач Act нужно утверждать по отдельности, но вы не можете гарантировать выполнение всех задач Assert. В большинстве платформ модульного тестирования после сбоя задачи Assert в модульном тесте все последующие тесты автоматически считаются неудачными. Процесс может быть запутан, так как некоторые рабочие функции могут интерпретироваться как неудачные.
исходный код
[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;
}
}
Можно ли написать модульный тест для этого кода? Вы можете попробовать запустить задачу Assert в 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
и может заглушить любое значение при вызове метода.