Тестирование в .NET. Часть 2 — MSTest
Привет. В прошлой статье я немного рассказал о тестировании программного обеспечения в целом и вообще, сегодня мы попробуем написать несколько первых тестов при помощи фреймворка тестирования MSTest (Microsoft Test). Это простой, лёгкий и удобный вариант для быстрого тестирования личных или не очень больших проектов. Хотя, конечно, никто не запрещает покрывать все свои проекты при помощи данной технологии, но это уже больше вкусовщина и дело ньюансов, о которых я постараюсь поговорить чуть ниже.
Итак, для того, чтобы начать тестировать код — нам в первую очередь понадобится код 🙂 Да, в этой статье мы не поднимаем тему TDD и подходу написания программного обеспечения через тестирования (test first) — об этом когда-нибудь потом. Сейчас наша цель иметь возможность покрыть уже написанный код или код, который создаётся прямо сейчас, в конкретный момент.
Подготовка.
Для нашей первой, пробной реализации проверки кода я выбрал банальный и просто пример калькулятора. Буквально в несколько строк и с кучей проблемных мест, но так даже лучше:
public class Calc { public decimal Value { get; private set; } public Calc(decimal value) => this.Value = value; public void Reset() => this.Value = 0; public void Set(decimal v) => this.Value = v; public decimal Sum(decimal v) => this.Value = this.Value + v; public decimal Min(decimal v) => this.Value = this.Value - v; public decimal Div(decimal v) => this.Value = this.Value / v; public decimal Mul(decimal v) => this.Value = this.Value * v; }
На всякий случай стоит ещё раз отдельно оговорить, что это очень плохой калькулятор 🙂 во всех смыслах, единственная причина существования которого — наиболее простая демонстрация тестов.
Здесь мы видим класс Calc
и несколько методов, которые работают с локальной переменной (доступной через свойство) Value
. Здесь мы можем проводить примитивные арифметические операции, такие как сложение, вычитание, умножение и деление: Sum
, Min
, Mul
и Div
, соответственно. Каждый из данных методов принимает в аргументах одну переменную decimal v
, которую мы прибавляем, отнимаем, на которую умножаем или делим. Никаких проверок. Пока что так, будем считать это первой версией.
Теперь давайте добавим к своему Solution новый проект с типом MSTest Test Project (данный тип существует в обоих вселенных — в старом добром .NET Framework и в новом прекрасном .NET Core). Назовём его Tests:
У нас сгенерируется простой проект с одним классом, который переименуем в CalcTests
. Теперь необходимо добавить проект Calc в референсы CalcTests, чтобы наш тестовый проект получил доступ и видимость к нашему коду. По сути это все приготовления, всё очень просто — в Solution Explorer кликаем правой кнопкой мыши по меню Dependencies и выбираем Add Project Reference, как показано на рисунке:
В появившемся окошке выбираем проект с нашим калькулятором и сохраняем по кнопке OK. Теперь наш проект видит весь код, а это значит, что мы полностью готовы.
Фреймворк MSTest.
В основе тестирования лежит идея о том, что мы, как разработчики, знаем наверняка то, что нам требуется. Конкретно и по существу. Поэтому нам нужен инструмент этой самой проверки желаемого результата и суровой действительности. Для этого у нас есть статический класс Assert
с массой статических методов для проверки.
Как правило, в основном это методы с двумя аргументами — expected и actual, т.е. то, что мы ожидаем и то, что мы получили. В случае если наше поведение не соответствует нашему ожиданию — тест «валится», становится красным и мы при помощи Test Explorer можем понять что и где пошло не так. Также, в перегрузках методов можно найти строковую переменную message и задать текст ошибки — в каждом отдельном случае.
Также следует отметить, что существует также и методы, внутрь которых не нужно передавать эти самые expected и actual. Для ситуаций, когда нам нужно проверить, что мы ожидаем получить какой-либо Exception
, к примеру, существует метод ThrowsException
.
Методы
Теперь немного о каждом из методов более подробно:
AreEqual
— по сути, основной и самый главный метод. Во многом, если упускать момент удобства, только им и можно обойтись абсолютно везде. Метод проверяет эквивалентность наших аргументов и в случае их полного соответствия — тест проходит, в обратном случае — падает. В перегрузках кроме object
есть фактически все примитивы, иногда со своими какими-то точечными подстройками (к примеру, для сравнения строк можно игнорировать чувствительность к регистру или задавать дельту допустимой погрешности для сравнения float
).
AreNotEqual
— как следует из названия, функция, обратная по смыслу AreEqual
— тест проходит в случае, если аргументы НЕ являются эквивалентными и падает, если они равны, соответственно.
AreSame
— проверка эквивалентности по указателю. В случае если оба объекта имеет одинаковую ссылку и ведут на один и тот же объект — тест проходит, в противном случае — падает.
AreNotSame
— обратная проверка AreSame
на НЕ одинаковую ссылку.
IsTrue
, IsFalse
— проверка предикатов на истинность или ложь.
IsNull
, IsNotNull
— проверка на то, что объект является пустым (null) или же наоборот, что объект не является пустым.
IsInstanceOfType
, IsNotInstanceOfType
— проверка на соответствие типов объекта.
ThrowsException
— проверка на то, что ожидаемым поведением должен быть Exception
.
Fail
— метод, который «валит» тест с определённым сообщением. Таким образом вы можете абстрагироваться от предустановленных имеющихся подходов и реализовать свой собственный. Любая кастомная проверка, в которой нет ошибки или Exception
будет считать «зелёным» пройденным тестом.
Атрибуты
Для того, чтобы фреймворк знал какие из наших методов и классов связаны с тестами и как с ними работать вообще — используются специальные атрибуты, со своим жизненным циклом: сначала мы инициализируем настройки сборки, далее идём в настройки класса, после чего попадаем в тест метод, из него чистим класс и в самом конце чистим сборку: AssemblyInitialize
, TestClass
, TestInitialize
, TestMethod
, TestCleanup
, AssemblyCleanup
.
Как видно из названия, часть из атрибутов ориентированы на работу с глобальным состоянием. В частности, AssemblyInitialize
начинает работу первым и единожды на весь запуск тестов. Здесь мы можем получить какие-то глобальные настройки, проинициализировать необходимые нам данные (к примеру, базу данных). Соответственно, AssemblyCleanup
отработает в самом конце и также только один раз — специально для того, чтобы вы «почистили» то, что необходимо почистить — закрыли соединения, провели какие-то постработы. Для обоих данных атрибутов требуется статические методы и данные атрибуты можно указать в проекте лишь один раз, иначе тесты не заведутся.
TestInitialize
— работает на уровне класса. И при создания экземпляра тестового класса управление перейдет в первую очередь в метод, который отмечен данным атрибутом. Здесь мы можем провести какие-то инициирующие операции близкие к контексту класса, возможно, логер или какие-то другие необходимые для класса вещи.
TestCleanup
, соответственно, отработает после всех тестовых методов класса и здесь вы сможете подвести некоторой итог вашей работы и, опять-таки, почиститься.
TestClass
— атрибут, который мы отмечаем класс с тестовыми методами.
TestMethod
, соответственно, как видно из названия — для методов.
Также существуют специальные мета атрибуты для организации и структурирования наших тестов. Речь идёт о TestCategory
, при помощи которого мы можем указать к какой группе относится данный метод. К слову, группировка в Test Explorer доступна по файлам и классам, таким образом при грамотном распределении тестов на файловой системе — надобность задавать категории в ручном режиме может отпасть.
Тестируем калькулятор
Если я хорошо помню арифметику, то дважды два ровняется четырем. Давайте с этого и начнём. Мы ожидаем, что результат умножения 2 на 2 вернёт нам 4:
[TestMethod] [TestCategory("Arithmetic")] public void Calc_Mul_Test() { //Arrange Calc calc = new Calc(2); //Act decimal r1 = calc.Mul(2); decimal r2 = calc.Mul(2); decimal r3 = calc.Mul(2); decimal r4 = calc.Mul(2); decimal r5 = calc.Mul(2); //Assert Assert.AreEqual(4, r1); Assert.AreEqual(8, r2); Assert.AreEqual(16, r3); Assert.AreEqual(32, r4); Assert.AreEqual(64, r5); }
Чтобы далеко не ходить, давайте здесь же проверим обратную операцию — деление:
[TestMethod] [TestCategory("Arithmetic")] public void Calc_Div_Test() { //Arrange Calc calc = new Calc(60); //Act decimal r1 = calc.Div(1); decimal r2 = calc.Div(3); decimal r3 = calc.Div(5); decimal r4 = calc.Div(2); decimal r5 = calc.Div(8); //Assert Assert.AreEqual(60, r1); Assert.AreEqual(20, r2); Assert.AreEqual(4, r3); Assert.AreEqual(2, r4); Assert.AreEqual(0.25m, r5); }
И, конечно, же не забываем про сложение и вычитание:
[TestMethod] [TestCategory("Arithmetic")] public void Calc_Sum_Test() { //Arrange Calc calc = new Calc(0); //Act decimal r1 = calc.Sum(1); decimal r2 = calc.Sum(1); decimal r3 = calc.Sum(1); decimal r4 = calc.Sum(2); decimal r5 = calc.Sum(3); //Assert Assert.AreEqual(1, r1); Assert.AreEqual(2, r2); Assert.AreEqual(3, r3); Assert.AreEqual(5, r4); Assert.AreEqual(8, r5); }
[TestMethod] [TestCategory("Arithmetic")] public void Calc_Min_Test() { //Arrange Calc calc = new Calc(100); //Act decimal r1 = calc.Min(1); decimal r2 = calc.Min(3); decimal r3 = calc.Min(5); decimal r4 = calc.Min(7); decimal r5 = calc.Min(9); //Assert Assert.AreEqual(99, r1); Assert.AreEqual(96, r2); Assert.AreEqual(91, r3); Assert.AreEqual(84, r4); Assert.AreEqual(75, r5); }
С нашими примитивными операциями мы закончили. Осталось два метода, которые мы ещё не покрыли — это сброс калькулятор в ноль Reset
и установка значения Set
:
[TestMethod] [TestCategory("Common")] public void Calc_Set_Test() { //Arrange Calc calc = new Calc(100); //Act calc.Set(32768); //Assert Assert.AreEqual(32768, calc.Value); } [TestMethod] [TestCategory("Common")] public void Calc_Reset_Test() { //Arrange Calc calc = new Calc(100); //Act calc.Reset(); //Assert Assert.AreEqual(0, calc.Value); }
Давайте вернёмся к делению. Все мы знаем, что делить на ноль нельзя и что результатом данной операции будет DivideByZeroException
. Мы можем посчитать это поведение допустимым для нас и добавить его в тестирование следующим образом:
[TestMethod] [TestCategory("Exceptions")] public void Calc_Div_Exceptions_Test() { //Arrange Calc calc = new Calc(100); //Act && Assert Assert.ThrowsException<DivideByZeroException>(() => { calc.Div(0); }); }
Выводы.
Как видите, всё очень просто. Пишем код, проверяем его поведение на предмет ожидания и адекватности работы. MSTest — это лёгкий (и с точки зрения точки входа, и с точки зрения лёгковесности решения), простой способ контролировать работоспособность всей системы во время написания нового кода и изменения старого. Теперь вы сможете с лёгкостью обнаружить проблемные места и на порядок увеличить скорость разрешения ошибок.