Модульное тестирование .NET: потратьте авансом, чтобы сэкономить позже
Опубликовано: 2022-03-11Часто возникает путаница и сомнения относительно модульного тестирования при обсуждении его с заинтересованными сторонами и клиентами. Юнит-тестирование иногда звучит так же, как зубная нить для ребенка: «Я уже чищу зубы, зачем мне это делать?»
Предложение модульного тестирования часто кажется ненужным расходом для людей, которые считают свои методы тестирования и приемочное тестирование достаточно сильными.
Но модульные тесты — очень мощный инструмент, и они проще, чем вы думаете. В этой статье мы рассмотрим модульное тестирование и какие инструменты доступны в DotNet, такие как Microsoft.VisualStudio.TestTools и Moq .
Мы попробуем создать простую библиотеку классов, которая будет вычислять n-й член последовательности Фибоначчи. Для этого нам нужно создать класс для вычисления последовательностей Фибоначчи, который зависит от пользовательского математического класса, который складывает числа вместе. Затем мы можем использовать .NET Testing Framework, чтобы убедиться, что наша программа работает должным образом.
Что такое модульное тестирование?
Модульное тестирование разбивает программу на мельчайшие фрагменты кода, обычно на уровне функций, и гарантирует, что функция возвращает ожидаемое значение. Используя структуру модульного тестирования, модульные тесты становятся отдельным объектом, который затем может запускать автоматические тесты в программе по мере ее создания.
[TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }
Простой модульный тест с использованием методологии Arrange, Act, Assert, проверяющий, что наша математическая библиотека может правильно сложить 2 + 2.
После настройки модульных тестов, если в код вносятся изменения для учета дополнительного условия, которое не было известно, например, при первой разработке программы, модульные тесты покажут, соответствуют ли все случаи ожидаемым значениям. вывод функцией.
Модульное тестирование — это не интеграционное тестирование. Это не сквозное тестирование. Хотя обе эти методологии являются мощными, они должны работать в сочетании с модульным тестированием, а не в качестве замены.
Преимущества и цель модульного тестирования
Самым трудным для понимания преимуществом модульного тестирования, но самым важным, является возможность повторного тестирования измененного кода на лету. Причина, по которой это может быть так трудно понять, заключается в том, что многие разработчики думают про себя: «Я никогда больше не буду касаться этой функции» или «Я просто протестирую ее повторно, когда закончу». А заинтересованные стороны думают так: «Если эта часть уже написана, зачем мне ее повторно тестировать?»
Как человек, который был на обеих сторонах спектра развития, я сказал обе эти вещи. Разработчик внутри меня знает, почему мы должны тестировать его повторно.
Изменения, которые мы делаем изо дня в день, могут иметь огромное влияние. Например:
- Правильно ли ваш коммутатор учитывает введенное вами новое значение?
- Вы знаете, сколько раз вы использовали этот переключатель?
- Правильно ли вы учитывали сравнение строк без учета регистра?
- Правильно ли вы проверяете наличие нулей?
- Обрабатывается ли исключение throw так, как вы ожидали?
Модульное тестирование принимает эти вопросы и запоминает их в коде, а также в процессе , гарантирующем, что на эти вопросы всегда будут даны ответы. Модульные тесты можно запускать перед сборкой, чтобы убедиться, что вы не добавили новых ошибок. Поскольку модульные тесты спроектированы как атомарные, они выполняются очень быстро, обычно менее 10 миллисекунд на тест. Даже в очень большом приложении полный набор тестов можно выполнить менее чем за час. Может ли ваш процесс UAT соответствовать этому?
Fibonacci_GetNthTerm_Input2_AssertResult1
, который является первым запуском и включает время установки, все модульные тесты выполняются менее чем за 5 мс. Мое соглашение об именах здесь настроено так, чтобы легко искать класс или метод внутри класса, который я хочу протестировать.
Однако, как разработчику, возможно, это звучит как дополнительная работа для вас. Да, вы получаете душевное спокойствие, что код, который вы выпускаете, хорош. Но модульное тестирование также дает вам возможность увидеть слабые стороны вашего дизайна. Вы пишете одни и те же модульные тесты для двух частей кода? Должны ли они быть в одном фрагменте кода?
Сделать ваш код пригодным для модульного тестирования — это способ улучшить ваш дизайн. А для большинства разработчиков, которые никогда не проводили модульное тестирование или не тратят столько времени на обдумывание дизайна перед кодированием, вы можете понять, насколько ваш дизайн улучшится, подготовив его к модульному тестированию.
Можно ли тестировать ваш код?
Помимо DRY, у нас есть и другие соображения.
Ваши методы или функции пытаются сделать слишком много?
Если вам нужно написать слишком сложные модульные тесты, которые выполняются дольше, чем вы ожидаете, ваш метод может быть слишком сложным и лучше подходит для использования в качестве нескольких методов.
Правильно ли вы используете внедрение зависимостей?
Если для вашего тестируемого метода требуется другой класс или функция, мы называем это зависимостью. В модульном тестировании нам все равно, что делает зависимость под капотом; для целей тестируемого метода это черный ящик. Зависимость имеет собственный набор модульных тестов, которые определят, правильно ли работает ее поведение.
Как тестер, вы хотите смоделировать эту зависимость и указать, какие значения возвращать в конкретных случаях. Это даст вам больший контроль над вашими тест-кейсами. Для этого вам нужно внедрить фиктивную (или, как мы увидим позже, фиктивную) версию этой зависимости.
Взаимодействуют ли ваши компоненты друг с другом так, как вы ожидаете?
После того, как вы разобрались со своими зависимостями и инъекцией зависимостей, вы можете обнаружить, что ввели циклические зависимости в свой код. Если класс A зависит от класса B, который, в свою очередь, зависит от класса A, вам следует пересмотреть свой проект.
Красота внедрения зависимостей
Давайте рассмотрим наш пример Фибоначчи. Ваш босс говорит вам, что у них есть новый класс, который более эффективен и точен, чем текущий оператор добавления, доступный в C#.
Хотя этот конкретный пример маловероятен в реальном мире, мы видим аналогичные примеры в других компонентах, таких как аутентификация, сопоставление объектов и почти любой алгоритмический процесс. Для целей этой статьи давайте просто представим, что новая функция добавления вашего клиента является самой последней и лучшей с тех пор, как были изобретены компьютеры.
Таким образом, ваш босс вручает вам библиотеку черного ящика с одним классом Math
и в этом классе с одной функцией Add
. Ваша работа по внедрению калькулятора Фибоначчи, скорее всего, будет выглядеть примерно так:
public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }
Это не ужасно. Вы создаете экземпляр нового класса Math
и используете его для добавления двух предыдущих терминов, чтобы получить следующий. Вы запускаете этот метод через свою обычную батарею тестов, вычисляя до 100 терминов, вычисляя 1000-й термин, 10000-й термин и так далее, пока не почувствуете, что ваша методология работает нормально. Затем когда-нибудь в будущем пользователь жалуется, что 501-й термин работает не так, как ожидалось. Вы проводите вечер, просматривая свой код и пытаясь понять, почему этот крайний случай не работает. Вы начинаете подозревать, что последний и лучший урок Math
не так хорош, как думает ваш начальник. Но это черный ящик, и вы не можете этого доказать — вы заходите в тупик внутренне.
Проблема здесь в том, что зависимость Math
не внедряется в ваш калькулятор Фибоначчи. Поэтому в своих тестах вы всегда полагаетесь на существующие, непроверенные и неизвестные результаты Math
, чтобы проверить Фибоначчи. Если есть проблема с Math
, то Фибоначчи всегда будет неправильным (без кодирования особого случая для 501-го термина).
Идея исправить эту проблему состоит в том, чтобы внедрить класс Math
в ваш калькулятор Фибоначчи. Но еще лучше создать интерфейс для класса Math
, который определяет общедоступные методы (в нашем случае Add
) и реализовать интерфейс в нашем классе Math
.
public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }
Вместо внедрения класса Math
в Fibonacci мы можем внедрить интерфейс IMath
в Fibonacci. Преимущество здесь в том, что мы можем определить наш собственный класс OurMath
, точность которого мы знаем, и протестировать наш калькулятор на его основе. Более того, используя Moq, мы можем просто определить, что возвращает Math.Add
. Мы можем определить количество сумм или просто указать Math.Add
вернуть x + y.
private IMath _math; public Fibonacci(IMath math) { _math = math; }
Внедрите интерфейс IMath в класс Fibonacci.
//setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);
Использование Moq для определения того, что возвращает Math.Add
.

Теперь у нас есть проверенный и верный (ну, если этот оператор + неверен в C#, у нас есть более серьезные проблемы) метод сложения двух чисел. Используя наш новый IMath
, мы можем закодировать модульный тест для нашего 501-го термина и посмотреть, не ошиблись ли мы в нашей реализации или нужно немного больше работы над пользовательским классом Math
.
Не позволяйте методу пытаться сделать слишком много
Этот пример также указывает на то, что метод делает слишком много. Конечно, сложение — довольно простая операция, не требующая особого абстрагирования функциональности от нашего метода GetNthTerm
. А что, если операция была немного сложнее? Вместо добавления, возможно, это была проверка модели, вызов фабрики для получения объекта для работы или сбор дополнительных необходимых данных из репозитория.
Большинство разработчиков будут стараться придерживаться идеи, что у одного метода есть одна цель. При модульном тестировании мы пытаемся придерживаться принципа, что модульные тесты должны применяться к атомарным методам, и вводя слишком много операций в метод, мы делаем его непроверяемым. Мы часто можем создать проблему, когда нам нужно написать так много тестов, чтобы правильно протестировать нашу функцию.
Каждый параметр, который мы добавляем к методу, экспоненциально увеличивает количество тестов, которые мы должны написать, в соответствии со сложностью параметра. Если вы добавите логическое значение в свою логику, вам нужно будет удвоить количество тестов для написания, поскольку теперь вам нужно проверять истинные и ложные случаи вместе с вашими текущими тестами. В случае проверки модели сложность наших модульных тестов может очень быстро возрастать.
Мы все виновны в том, что добавляем немного больше к методу. Но эти более крупные и сложные методы требуют слишком большого количества модульных тестов. И быстро становится очевидным, когда вы пишете модульные тесты, что метод пытается сделать слишком много. Если вы чувствуете, что пытаетесь проверить слишком много возможных результатов ваших входных параметров, учтите тот факт, что ваш метод необходимо разбить на серию более мелких.
Не повторяйтесь
Один из наших любимых арендаторов программирования. Это должно быть довольно прямолинейно. Если вы обнаружите, что пишете одни и те же тесты более одного раза, вы вводили код более одного раза. Вам может быть полезно реорганизовать эту работу в общий класс, доступный для обоих экземпляров, которые вы пытаетесь использовать.
Какие инструменты модульного тестирования доступны?
DotNet предлагает нам очень мощную платформу модульного тестирования из коробки. Используя это, вы можете реализовать то, что известно как методология «Упорядочить, действовать, утверждать». Вы устраиваете свои первоначальные соображения, действуете на этих условиях с тестируемым методом, а затем утверждаете, что что-то произошло. Вы можете утверждать что угодно, что делает этот инструмент еще более мощным. Вы можете утверждать, что метод был вызван определенное количество раз, что метод вернул определенное значение, что был выдан определенный тип исключения или что-то еще, что вы можете придумать. Для тех, кто ищет более продвинутую среду, NUnit и его Java-аналог JUnit являются жизнеспособными вариантами.
[TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }
Проверка того, что наш метод Фибоначчи обрабатывает отрицательные числа, путем создания исключения. Модульные тесты могут проверить, было ли выдано исключение.
Для обработки внедрения зависимостей на платформе DotNet существуют как Ninject, так и Unity. Между ними очень небольшая разница, и все зависит от того, хотите ли вы управлять конфигурациями с помощью Fluent Syntax или XML Configuration.
Для моделирования зависимостей я рекомендую Moq. С Moq может быть сложно справиться, но суть в том, что вы создаете фиктивную версию своих зависимостей. Затем вы указываете зависимости, что возвращать при определенных условиях. Например, если у вас есть метод с именем Square(int x)
, возводящий целое число в квадрат, вы можете сказать ему, что когда x = 2, вернуть 4. Вы также можете указать ему возвращать x^2 для любого целого числа. Или вы могли бы сказать, чтобы он возвращал 5, когда x = 2. Зачем вам выполнять последний случай? В случае, если метод под ролью теста должен проверить ответ из зависимости, вы можете принудительно вернуть неверные ответы, чтобы убедиться, что вы правильно поймали ошибку.
[TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }
Использование Moq, чтобы сообщить смоделированному интерфейсу IMath
, как обрабатывать тестируемое Add
. Вы можете установить явные случаи с помощью It.Is
или диапазон с помощью It.IsInRange
.
Среды модульного тестирования для DotNet
Платформа модульного тестирования Майкрософт
Microsoft Unit Testing Framework — это готовое решение для модульного тестирования от Microsoft, входящее в состав Visual Studio. Поскольку он поставляется с VS, он прекрасно с ним интегрируется. Когда вы начинаете проект, Visual Studio спросит вас, хотите ли вы создать библиотеку модульных тестов вместе с вашим приложением.
Microsoft Unit Testing Framework также поставляется с рядом инструментов, помогающих лучше анализировать процедуры тестирования. Кроме того, поскольку он принадлежит и написан Microsoft, в его существовании есть некоторое ощущение стабильности в будущем.
Но при работе с инструментами Microsoft вы получаете то, что они вам дают. Microsoft Unit Testing Framework может быть громоздкой для интеграции.
NUnit
Самым большим преимуществом использования NUnit для меня являются параметризованные тесты. В приведенном выше примере с Фибоначчи мы можем ввести несколько тестов и убедиться, что эти результаты верны. А в случае нашей 501-й проблемы мы всегда можем добавить новый набор параметров, чтобы гарантировать, что тест всегда будет выполняться без необходимости в новом методе тестирования.
Основным недостатком NUnit является его интеграция в Visual Studio. В нем отсутствуют навороты, которые поставляются с версией Microsoft, и это означает, что вам нужно будет загрузить собственный набор инструментов.
xUnit.Net
xUnit очень популярен в C#, потому что прекрасно интегрируется с существующей экосистемой .NET. Nuget имеет множество доступных расширений xUnit. Он также хорошо интегрируется с Team Foundation Server, хотя я не уверен, сколько разработчиков .NET все еще используют TFS вместо различных реализаций Git.
С другой стороны, многие пользователи жалуются на недостаток документации xUnit. Для новых пользователей модульного тестирования это может вызвать сильную головную боль. Кроме того, расширяемость и адаптируемость xUnit также делают кривую обучения немного более крутой, чем NUnit или Microsoft Unit Testing Framework.
Проектирование/разработка через тестирование
Проектирование/разработка через тестирование (TDD) — немного более продвинутая тема, которая заслуживает отдельного поста. Тем не менее, я хотел предоставить введение.
Идея состоит в том, чтобы начать с ваших модульных тестов и сообщить вашим модульным тестам, что правильно. Затем вы можете написать свой код вокруг этих тестов. В теории концепция звучит просто, но на практике очень сложно научить свой мозг думать о приложении задним числом. Но у этого подхода есть встроенное преимущество, заключающееся в том, что вам не нужно писать модульные тесты постфактум. Это приводит к меньшему рефакторингу, переписыванию и путанице классов.
В последние годы TDD был чем-то вроде модного слова, но внедрение было медленным. Его концептуальная природа сбивает с толку заинтересованные стороны, что затрудняет его утверждение. Но как разработчик, я рекомендую вам написать даже небольшое приложение, используя подход TDD, чтобы привыкнуть к процессу.
Почему у вас не может быть слишком много модульных тестов
Модульное тестирование — один из самых мощных инструментов тестирования, которые есть в распоряжении разработчиков. Этого никоим образом недостаточно для полного тестирования вашего приложения, но его преимущества в регрессионном тестировании, разработке кода и документировании цели не имеют себе равных.
Невозможно написать слишком много модульных тестов. Каждый пограничный случай может привести к большим проблемам в вашем программном обеспечении. Увековечивание найденных ошибок в виде модульных тестов может гарантировать, что эти ошибки не найдут способов вернуться в ваше программное обеспечение во время последующих изменений кода. Хотя вы можете добавить 10-20% к первоначальному бюджету вашего проекта, вы можете сэкономить гораздо больше на обучении, исправлении ошибок и документации.
Вы можете найти репозиторий Bitbucket, использованный в этой статье, здесь.