Использование Moq

53

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

Один из удобных подходов предусматривает использование имитированных (или пробных) объектов, которые эмулируют функциональность реальных объектов из проекта, но специфическим и управляемым образом. Имитированные объекты позволяют сузить фокус тестов, проверяя только ту функциональность, которая интересует.

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

Сущность проблемы

Перед тем, как приступить к использованию Moq, мы продемонстрируем проблему, которую пытаемся исправить. В этом разделе мы собираемся провести модульное тестирование класса LinqValueCalculator, который был определен в папке Models примера проекта, который мы начали создавать в статье Внедрение зависимостей Ninject. Для справки в примере ниже приведено определение класса LinqValueCalculator:

using System.Collections.Generic;
using System.Linq;

namespace EssentialTools.Models
{
    public class LinqValueCalculator : IValueCalculator
    {
        private IDiscountHelper discounter;
        private static int counter = 0;

        public LinqValueCalculator(IDiscountHelper discountParam)
        {
            discounter = discountParam;
            System.Diagnostics.Debug.WriteLine(
                string.Format("Экземпляр класса LinqValueCalculator №{0} создан", ++counter));
        }

        public decimal ValueProducts(IEnumerable<Product> products)
        {
            return discounter.ApplyDiscount(products.Sum(p => p.Price));
        }
    }
}

Чтобы протестировать этот класс, в тестовый проект необходимо добавить новый класс модульного тестирования. Для этого щелкните правой кнопкой мыши на тестовом проекте в окне Solution Explorer и выберите в контекстном меню пункт Add --> Unit Test (Добавить --> Модульный тест). Если в меню Add не содержится пункта Unit Test, выберите вместо него пункт New Item (Новый элемент) и укажите шаблон Basic Unit Test (Базовый модульный тест). Код, помещенный в новый файл, которому среда Visual Studio назначила стандартное имя UnitTest2.cs, показан в примере ниже:

using EssentialTools.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;

namespace EssentialTools.Tests
{
    [TestClass]
    public class UnitTest2
    {
        private Product[] products = {
            new Product {Name = "Каяк", Category = "Водные виды спорта", Price = 275M},
            new Product {Name = "Спасательный жилет", Category = "Водные виды спорта", Price = 48.95M},
            new Product {Name = "Мяч", Category = "Футбол", Price = 19.50M},
            new Product {Name = "Угловой флажок", Category = "Футбол", Price = 34.95M}
        };

        [TestMethod]
        public void Sum_Products_Correctly()
        {
            // Arrange
            var discounter = new MinimumDiscountHelper();
            var target = new LinqValueCalculator(discounter);
            var goalTotal = products.Sum(e => e.Price);

            // Act
            var result = target.ValueProducts(products);

            // Assert
            Assert.AreEqual(goalTotal, result);
        }
    }
}

И здесь мы сталкиваемся с трудностью - работа класса LinqValueCalculator зависит от реализации интерфейса IDiscountHelper. В этом примере используется класс MinimumDiscountHelper, который порождает две разных проблемы:

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

Добавление Moq в проект Visual Studio

Как и в случае Ninject, простейший способ добавления Moq в проект модульного тестирования предусматривает применение интегрированной поддержки Visual Studio для NuGet. Откройте консоль NuGet (Tools --> Library Package Manager --> Package Manager Console) и введите следующую команду:

Install-Package Moq -version 4.1.1309.1617 -projectname EssentialTools.Tests

Аргумент projectname позволяет сообщить NuGet о том, что пакет Moq необходимо установить внутри проекта модульного тестирования, а не в главном приложении.

Добавление имитированных объектов в модульный тест

Добавление имитированного объекта в модульный тест означает сообщение библиотеке Moq о том, с какой разновидностью объекта нужно работать, конфигурирование его поведения и затем применение этого объекта к тестируемому компоненту. В примере ниже демонстрируется добавление имитированного объекта в модульный тест для LinqValueCalculator:

using EssentialTools.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;
using Moq;

namespace EssentialTools.Tests
{
    [TestClass]
    public class UnitTest2
    {
        private Product[] products = {
            new Product {Name = "Каяк", Category = "Водные виды спорта", Price = 275M},
            new Product {Name = "Спасательный жилет", Category = "Водные виды спорта", Price = 48.95M},
            new Product {Name = "Мяч", Category = "Футбол", Price = 19.50M},
            new Product {Name = "Угловой флажок", Category = "Футбол", Price = 34.95M}
        };

        [TestMethod]
        public void Sum_Products_Correctly()
        {
            // Arrange (добавляем имитированный объект)
            Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>();
            mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>()))
                .Returns<decimal>(total => total);
            var target = new LinqValueCalculator(mock.Object);

            // Act
            var result = target.ValueProducts(products);

            // Assert
            Assert.AreEqual(products.Sum(e => e.Price), result);
        }
    }
}

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

Создание имитированного объекта

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

Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>();

Мы создаем строго типизированный объект Mock<IDiscountHelper>, указывающий библиотеке Moq тип, который она будет обрабатывать - разумеется, для нашего модульного теста это интерфейс IDiscountHelper, но можно применять любой тип, который нужно изолировать для улучшения направленности модульных тестов.

Выбор метода

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

mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>()))
     .Returns<decimal>(total => total);

Метод Setup() позволяет добавить метод в имитированный объект. Библиотека Moq работает с применением LINQ и лямбда-выражений. Когда вызывается метод Setup() библиотека Moq передает интерфейс, реализация которого была запрошена. Это изящно скрыто с помощью "магии" LINQ, в которую мы не будем углубляться. В результате появляется возможность выбора метода для конфигурирования с помощью лямбда-выражения.

В нашем модульном тесте мы хотим определить поведение метода ApplyDiscount() который является единственным методом в интерфейсе IDiscountHelper, а также методом, подлежащим тестированию в классе LinqValueCalculator.

Мы также должны сообщить Moq интересующие значения параметров, что делается с использованием класса It. В классе It определен набор методов, которые применяются с обобщенными параметрами типов. В данном случае мы вызвали метод IsAny(), указав decimal в качестве обобщенного типа. Это указывает Moq на то, что определяемое поведение должно использоваться всякий раз, когда метод ApplyDiscount() вызывается с любым десятичным значением.

В таблице ниже перечислены методы, предоставляемые классом It: все они являются статическими.

Методы класса It
Метод Описание
Is<T>(predicate)

Указывает значения типа T, для которых предикат (predicate) возвратит значение true

IsAny<T>()

Указывает любое значение типа T

IsInRange<T>(min, max, kind)

Срабатывает, если параметр находится между определенными значениями типа T. Последний параметр - это значение перечисления Range, которым может быть Inclusive или Exclusive

IsRegex(regex)

Срабатывает, если строковый параметр дает соответствие с указанным регулярным выражением

Позже будет приведен более сложный пример, в котором используются другие методы класса It, а пока что мы будем применять метод IsAny<decimal>(), позволяющий реагировать на любое десятичное значение.

Определение результата

Метод Returns() позволяет указать результат, который Moq будет возвращать при вызове имитированного метода. Тип результата задается с помощью параметра типа, а сам результат - посредством лямбда-выражения. Вот как это было сделано в примере:

.Returns<decimal>(total => total);

За счет вызова метода Returns() с параметром типа decimal мы указываем Moq, что собираемся возвратить значение decimal. Для лямбда-выражения Moq передает значение типа, полученного в методе ApplyDiscount(). В примере мы создаем сквозной метод, в котором возвращается значение, переданное имитированному методу ApplyDiscount(), без выполнения над ним каких-либо операций. Это простейшая разновидность имитированного метода, но вскоре будут приведены более сложные примеры.

Использование имитированного объекта

Последний шаг связан с использованием имитированного объекта в модульном тесте, для чего производится чтение значения свойства Object объекта Mock<IDiscountHelper>:

var target = new LinqValueCalculator(mock.Object);

Подводя итоги примера, свойство Object возвращает реализацию интерфейса IDiscountHelper, в которой метод ApplyDiscount() возвращает значение переданного параметра decimal.

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

// ...
Assert.AreEqual(products.Sum(e => e.Price), result);
// ...

Преимущество применения библиотеки Moq подобным образом заключается в том, что наш модульный тест только проверяет поведение объекта LinqValueCalculator и не зависит от каких-либо реальных реализаций интерфейса IDiscountHelper в папке Models. Это означает, что когда тест не прошел, проблема кроется либо в реализации LinqValueCalculator, либо в способе установки имитированного объекта; решить проблему, возникшую в любом из указанных источников, намного проще, чем иметь дело с цепочкой реальных объектов и взаимодействиями между ними.

Создание более сложного имитированного объекта

В предыдущем разделе был продемонстрирован очень простой имитированный объект, однако удобство библиотеки Moq связано с возможностью быстрого построения сложных видов поведения для тестирования разнообразных ситуаций. В примере ниже в файл UnitTest2.cs добавлен новый модульный тест, который имитирует более сложную реализацию интерфейса IDiscountHelper. В действительности библиотека Moq используется для моделирования поведения класса MinimumDiscountHelper:

using EssentialTools.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;
using Moq;

namespace EssentialTools.Tests
{
    [TestClass]
    public class UnitTest2
    {
        // ...

        private Product[] createProduct(decimal value)
        {
            return new[] { new Product { Price = value } };
        }

        [TestMethod]
        [ExpectedException(typeof(System.ArgumentOutOfRangeException))]
        public void Pass_Through_Variable_Discounts()
        {
            // Arrange
            Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>();
            mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>()))
                .Returns<decimal>(total => total);
            mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v == 0)))
                .Throws<System.ArgumentOutOfRangeException>();
            mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v > 100)))
                .Returns<decimal>(total => (total * 0.9M));
            mock.Setup(m => m.ApplyDiscount(It.IsInRange<decimal>(10, 100,
                Range.Inclusive))).Returns<decimal>(total => total - 5);
            var target = new LinqValueCalculator(mock.Object);

            // Act
            decimal FiveDollarDiscount = target.ValueProducts(createProduct(5));
            decimal TenDollarDiscount = target.ValueProducts(createProduct(10));
            decimal FiftyDollarDiscount = target.ValueProducts(createProduct(50));
            decimal HundredDollarDiscount = target.ValueProducts(createProduct(100));
            decimal FiveHundredDollarDiscount = target.ValueProducts(createProduct(500));

            // Assert
            Assert.AreEqual(5, FiveDollarDiscount, "$5 потеряем");
            Assert.AreEqual(5, TenDollarDiscount, "$10 потеряем");
            Assert.AreEqual(45, FiftyDollarDiscount, "$50 потеряем");
            Assert.AreEqual(95, HundredDollarDiscount, "$100 потеряем");
            Assert.AreEqual(450, FiveHundredDollarDiscount, "$500 Fail");
            target.ValueProducts(createProduct(0));
        }
    }
}

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

Как видите, мы определили четыре разных вида поведения для метода Apply Discount на основе значения полученного параметра. Простейшим является сквозное поведение, при котором возвращается значение для любого значения decimal:

mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>()))
    .Returns<decimal>(total => total);

Точно такое же поведение использовалось в предыдущем примере, и оно включено здесь из-за того, что порядок, в котором вызывается метод Setup(), влияет на поведение имитированного объекта. Библиотека Moq оценивает виды поведения в обратном порядке, поэтому самые последние вызовы метода Setup() учитываются первыми. Это означает, что вы должны позаботиться о создании имитированных видов поведения в порядке от наиболее общего до самого специфичного.

Условие It.IsAny<decimal> является наиболее общим из числа определенных в этом примере, так что оно применяется первым. Если изменить порядок вызовов Setup(), это поведение захватит все обращения к методу ApplyDiscount() и сгенерирует некорректные имитированные результаты.

Имитация для специфичных значений (и генерация исключения)

Во втором вызове метода Setup используется метод It.Is():

mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v == 0)))
    .Throws<System.ArgumentOutOfRangeException>();

Предикат, переданный методу Is(), возвращает true, если значение, которое передавалось методу ApplyDiscount(), равно 0. Вместо возвращения результата мы используем метод Throws(), который заставляет библиотеку Moq сгенерировать новый экземпляр исключения, указанного с помощью параметра типа.

Мы также применяем метод Is() для захвата значений, превышающих 100:

mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v > 100)))
    .Returns<decimal>(total => (total * 0.9M));

Метод It.Is() представляет собой наиболее гибкий способ настройки специфических видов поведения для различных значений параметров, поскольку можно использовать любой предикат, который возвращает true или false. Чаще всего этот метод применяется при создании сложных имитированных объектов.

Имитация для диапазона значений

Финальное использование объекта It связано с его методом IsInRange(), который позволяет захватывать диапазон значений параметра:

mock.Setup(m => m.ApplyDiscount(It.IsInRange<decimal>(10, 100,
    Range.Inclusive))).Returns<decimal>(total => total - 5);

Данный способ включен ради полноты, но в реальных проектах обычно применяется метод Is() и предикат, которые делают одно и то же.

Библиотека Moq обладает рядом исключительно полезных функциональных средств, использование которых демонстрируются в кратком руководстве Moq Quickstart.

Пройди тесты
Лучший чат для C# программистов