Еще один длинный, но, надеюсь, полезный пост :) Пригодится тем, кто хочет начать писать unit-тесты, уже пишет их и хочет увеличить свои познания, и даже тем, кто тесты не пишет, но хочет быть в курсе того, что же это за зверь такой страшный - unit-тестирование.
Как я уже писал в предыдущем посте об unit-тестировании, иногда, для того, чтобы протестировать какой-нибудь кусок кода (например, метод), нужно довольно сильно постараться. Причем, это еще не тот вид извращений, когда вы тестируете методы UI, проблемы могут начаться с тестирования бизнес-логики. Дело в том, что очень часто тестируемый метод может вызывать методы других классов, которые в данном случае тестировать не нужно. Unit-тест потому и называется модульным, что тестирует отдельные модули, а не их взаимодействие. Причем, чем меньше тестируемый модуль – тем лучше с точки зрения будущей поддержки тестов. Для тестирования взаимодействия используются интеграционные тесты, где вы уже тестируете скорее полные use cases, а не отдельную функциональность.
Однако наши классы очень часто используют другие классы в своей работе. Например, слой бизнес логики (Business Logic layer) часто работает с другими объектами бизнес логики или обращается к слою доступа к данным (Data Access layer). В трехслойной архитектуре веб-приложений это вообще постоянный процесс: Presentation layer обращается к Business Logic layer, тот, в свою очередь, к Data Access layer, а Data Access layer – к базе данных. Как же тестировать подобный код, если вызов одного метода влечет за собой цепочку вплоть до базы данных?
В таких случаях на помощь приходят так называемые mock-объекты, предназначенные для симуляции поведения реальных объектов во время тестирования. Вообще, понятие mock-объект достаточно широко: оно может, с одной стороны, обозначать любые тест-дублеры (Test Doubles) или конкретный вид этих дублеров – mock-объекты. Я постараюсь использовать этот термин исключительно во втором случае, чтобы никого не путать, и не запутаться самому :)
Понятие тест-дублеров введено неким Gerard Meszaros в своей книге «XUnit Test Patterns» и теперь с подачи небезызвестного Мартина Фаулера эта терминология набирает популярность. Джерард и Мартин делят все тест-дублеры на 4 группы:
- Dummy – пустые объекты, которые передаются в вызываемые внутренние методы, но не используются. Предназначены лишь для заполнения параметров методов.
- Fake – объекты, имеющие работающие реализации, но в таком виде, который делает их неподходящими для production-кода (например, In Memory Database).
- Stub – объекты, которые предоставляют заранее заготовленные ответы на вызовы во время выполнения теста и обычно не отвечающие ни на какие другие вызовы, которые не требуются в тесте. Также могут запоминать какую-то дополнительную информацию о количестве вызовов, параметрах и возвращать их потом тесту для проверки.
- Mock – объекты, которые заменяют реальный объект в условиях теста и позволяют проверять вызовы своих членов как часть системы или unit-теста. Содержат заранее запрограммированные ожидания вызовов, которые они ожидают получить. Применяются в основном для т.н. interaction (behavioral) testing.
Поначалу эта классификация выглядит очень непонятной. Но если вдуматься, то можно разобраться, в чем заключается отличие между теми и иными типами объектов. Предположим, что вам нужно протестировать метод Foo() класса TestFoo, который делает вызов другого метода Bar() класса TestBar. Предположим, что метод Bar() принимает какой-нибудь объект класса Bla в качестве параметра и потом ничего особого с ним не делает. В таком случае имеет смысл создать пустой объект Bla, передать его в класс TestFoo (сделать это можно при помощи широко применяемого паттерна Dependency Injection или каким-либо другим приемлемым способом), а затем уже Foo() при тестировании сам вызовет метод TestBar.Bar() с переданным пустым объектом. Это и есть иллюстрация использования dummy-объекта в unit-тестировании.
К сожалению, редко можно обойтись простыми dummy-объектами. Иногда метод Bar() выполняет какие-то действия с ним (допустим, Bar() сохраняет данные в базу или вызывает веб-сервис, а мы этого не хотим). В таких случаях наш объект класса TestBar должен быть уже не таким глупым. Мы должны научить его в ответ на запрос сохранения данных просто выполнить какой-то простой код (допустим, сохранение во внутреннюю коллекцию). В таких случаях можно выделить интерфейс ITestBar, который будет реализовывать класс TestBar и наш дополнительный класс FakeBar. При unit-тестировании мы просто будем создавать объект класса FakeBar и передавать его в класс с методом Foo() через интерфейс. Естественно, при этом класс Bar будет по-прежнему создаваться в реальном приложении, а FakeBar будет использован лишь в тестировании. Это иллюстрация fake-объекта.
Со stub- и mock-объектами все немного сложнее, хотя и здесь есть от чего отталкиваться. Stub-объекты (стабы) – это типичные заглушки. Они ничего полезного не делают и умеют лишь возвращать определенные данные в ответ на вызовы своих методов. В нашем примере стаб бы подменял класс TestBar и в ответ на вызов Bar() просто бы возвращал какие-то левые данные. При этом внутренняя реализация реального метода Bar() бы просто не вызывалась. Реализуется этот подход через интерфейс и создание дополнительного класса StubBar, либо просто через создание StubBar, который является унаследованным от TestBar. В принципе, реализация очень похожа на fake-объект с тем лишь исключением, что стаб ничего полезного, кроме постоянного возвращения каких-то константных данных не требует. Типичная заглушка. Стабам позволяется лишь сохранять у себя внутри какие-нибудь данные, удостоверяющие, что вызовы были произведены или содержащие копии переданных параметров, которые затем может проверить тест.
Mock-объект (мок), в свою очередь, является, грубо говоря, более умной реализацией заглушки, которая уже не просто возвращает предустановленные данные, но еще и записывает все вызовы, которые проходят через нее, чтобы вы могли дальше в unit-тесте проверить, что именно эти методы вот этих вот классов были вызваны тестируемым методом и именно в такой последовательности (хотя учет последовательности и строгость проверки, в принципе, настраиваемая вещь). То есть мы можем сделать мок MockFoo, который будет каким-то образом вызывать реальный метод Foo() класса TestFoo и затем смотреть, какие вызовы тот сделал. Или сделать мок MockBar и затем проверить, что при вызове метода Foo() реально произошел вызов метода Bar() с нужными нам параметрами. Не совсем понятно? :) Вам нужно знать еще кое-что о unit-тестировании, чтобы понять разницу.
Unit-тестирование условно делится на два подхода:
- state-based testing, в котором мы тестируем состояние объекта после прохождения unit-теста
- interaction (behavioral) testing, в котором мы тестируем взаимодействие между объектами, поведение тестируемого метода, последовательность вызовов методов и их параметры и т.д.
То есть в state-based testing нас интересует в основном, в какое состояние перешел объект после вызова тестируемого метода, или, что более часто встречается, что в реальности вернул наш метод и правилен ли этот результат. Подобные проверки проводятся при помощи вызова методов класса Assert различных unit-тест фреймворков: Assert.AreEqual(), Assert.That(), Assert.IsNull() и т.д.
В interaction testing нас интересует прежде всего не статическое состояние объекта, а те динамические вызовы методов, которые происходят у него внутри. То есть для нашего примера с классами TestFoo и TestBar мы будем проверять, что тестируемый метод Foo() действительно вызвал метод Bar() класса TestBar, а не то, что он при этом вернул и в какое состояние перешел. Как правило, в случае подобного тестирования программисты используют специальные mock-фреймворки (TypeMock.Net, EasyMock.Net, MoQ, Rhino Mocks, NMock2), которые содержат определенные конструкции для записи ожиданий и их последующей проверки через методы Verify(), VerifyAll(), VerifyAllExpectations() или других (в зависимости от конкретного фреймворка).
То есть во многом это отличие можно назвать аналогичным отличию state machine diagram и activity diagram в UML: описывают они, в принципе, одно и то же, но разными способами. Иногда удобнее один, иногда второй.
Фаулер вот называет эти два подхода классическим (classical) и мокистским (mockist, ну и слово выдумал) unit-тестированием и делит программистов на тех, кто предпочитают первый и кто предпочитает второй подходы. Я бы этого не делал. Мне кажется, что иногда просто удобнее проверить состояние объекта, а иногда – его взаимодействие с другими объектами. Поэтому эти два подхода прекрасно уживаются вместе, когда вы понимаете, о чем идет речь, и что именно вы хотите сейчас проверить. Так же, как уживаются в одном тесте моки и стабы.
Вот такие пирожки. Теперь несколько примеров, которые иллюстрируют различия между этими двумя подходами и использования стабов и моков. Я также покажу, зачем в реальности может понадобиться interaction-тестирование, чтобы вам было проще выбирать тот или иной вид в будущем. Примеры используют NUnit и Rhino Mocks, хотя на их месте с небольшим изменением синтаксиса может оказаться почти любая другая пара фреймворков.
Допустим, у нас есть несколько простых классов:
public class Order
{
public string ProductName { get; private set; }
public int Quantity { get; private set; }
public bool IsFilled { get; private set; }
public Order(string productName, int quantity)
{
ProductName = productName;
Quantity = quantity;
}
public void Fill(IWarehouse warehouse)
{
if (warehouse.HasInventory(ProductName, Quantity))
{
warehouse.Remove(ProductName, Quantity);
IsFilled = true;
}
}
}
public class Warehouse
{
private DataAccess db;
public Warehouse()
{
db = new DataAccess();
}
public virtual bool HasInventory(string productName, int quantity)
{
return db.HasInventory(productName, quantity);
}
public virtual void Remove(string productName, int quantity)
{
db.Remove(productName, quantity);
}
}
Вот такой набор классов. Класс Order обращается к классу Warehouse, а тот обращается к базе данных. Предположим, что мы тестируем метод Fill() класса Order. Вот пример тестирования с использованием стаба для state-based тестирования:
[Test]
public void TestFillingOrderWithRhinoStub()
{
Order order = new Order(Talisker, 50);
var stubUserRepository = MockRepository.GenerateStub<Warehouse>();
stubUserRepository.Stub(x => x.HasInventory(Talisker, 50)).Return(true);
stubUserRepository.Stub(x => x.Remove(Talisker, 50));
order.Fill(stubUserRepository);
Assert.IsTrue(order.IsFilled);
}
Пара пояснений по коду. Сначала мы создаем объект типа Order, затем – стаб для класса Warehouse. После этого мы при помощи mock-фреймворка говорим, что при вызове метода HasInventory с определенными параметрами этот метод должен нам вернуть true. Аналогичным образом переопределяем поведение метода Remove (а то еще вызовет реальный и будет бяка). Далее идет вызов метода Fill() с переданным стабом, после чего проверяется, что свойство IsFilled установлено в true. Как видите, ничего сложного. Однако данный тест обладает некоторыми недостатками. Во-первых, непонятно, что делать, если в тестируемом объекте нет свойства, аналогичного IsFilled. Как проверять правильность выполнения кода? Во-вторых, непонятно, что случится, если программист удалит или закомментирует вызов следующей строчки в коде метода Fill():
warehouse.Remove(ProductName, Quantity);
IsFilled устанавливается в true, тест проходит, но код-то уже не работает!
Обе эти проблемы легко разрешаются, если мы воспользуемся interaction тестированием с использованием мока. Для этого напишем другой тест:
[Test]
public void TestFillingOrderWithRhino()
{
Order order = new Order(Talisker, 50);
var mockUserRepository = MockRepository.GenerateMock<Warehouse>();
mockUserRepository.Expect(x => x.HasInventory(Talisker, 50)).Return(true);
mockUserRepository.Expect(x => x.Remove(Talisker, 50));
mockUserRepository.Replay();
order.Fill(mockUserRepository);
Assert.IsTrue(order.IsFilled);
mockUserRepository.VerifyAllExpectations();
}
Начало теста аналогичное, затем идет создание мока Warehouse, после чего идет несколько вызовов метода Expect с теми же параметрами, что и в предыдущем тесте. При помощи этого метода мы говорим моку, что мы ожидаем вызова этих методов с такими параметрами и нам в ответ на их вызовы нужно вернуть такие-то значения. Затем идет вызов метода Replay(), который переводит мок из режима записи ожиданий в режим их проверки, то есть запуска тестового метода. Все моки имеют несколько режимов работы (Record, Replay, Verify), это распространенный подход. Далее непосредственно запуск, проверка IsFilled и вызов нового для нас метода VerifyAllExpectations(). Последний как раз и делает всю работу по проверке вызовов методов, параметров и т.д. Теперь, если метод Remove оказался закомментированным, тест не пройдет. Кроме того, нам уже не так важна проверка состояния объекта Order. Если бы свойства IsFilled не было, ничего бы не изменилось, а так мы лишь проверяем, что оно было установлено в соответствии с алгоритмом. Теперь немного поэкспериментируем с кодом. Что, если мы уберем второй Expect или поменяем их местами? Есть несколько режимов строгости проверки, которые задаются через конструктор класса Mock, который также можно использовать для создания мока. В Rhino Mocks есть три уровня строгости: Loose, Strict и Default (Loose). В Loose-режиме мок проверяет лишь то, что все ожидаемые методы были вызваны из тестируемого метода, в то время как в Strict-режиме проверяется также, что не было любых других вызовов и что порядок вызову соответствует порядку ожиданий. В других фреймворках иногда есть и другие режимы. Таким образом, в нашем случае при изменении порядка тест бы прошел, но в Strict-режиме – уже нет. Еще один момент, который показывает отличие методов Expect от методов Stub (в моке они также доступны): методы, зарегистрированные в моке при помощи метода Stub невидимы для метода VerifyAllExpectations. То есть, если нужна проверка вызовов – используйте Expect. Также стоит отметить, что при помощи дополнительных методов типа Return вы можете не только указывать возвращаемые значения, но еще генерировать exception'ы (Throw), вызывать настоящий метод (CallOriginalMethod), задавать ограничения на параметры (Constraints), вызывать дополнительные методы (Callback, Do), работать со свойствами и событиями. В общем, список потрясающий.
Как видите, иногда удобно проверить состояние, иногда – вызовы и взаимодействие. Так что это не вопрос «или-или», это вопрос – «когда» :) Удачного вам unit-тестирования!