Thursday, September 25, 2008

Введение в mock-объекты. Классификация

Еще один длинный, но, надеюсь, полезный пост :) Пригодится тем, кто хочет начать писать 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-тестирования!

53 comments:

  1. >>interaction testing, в котором мы тестируем
    первый раз такое слышу, обычно это называеться behaviral testing...

    ReplyDelete
  2. Я слышал оба названия, они используются взаимозаменяемо. Добавлю в текст для избежания недоразумений.

    ReplyDelete
  3. спасибо! многое стало понятным.

    ReplyDelete
  4. Огромное спасибо за этот исчерпывающий пост.

    Думаю что это один из самых удачных постов в рунете, который посвящен теме mocking.

    Когда первый раз его прочитал, то как-то не отблагодарил.

    А вот уже второй раз на него натыкаюсь, причем в очень необычном контексте.

    Сейчас пишу диплом. И хотел в дипломе выделить небольшой раздел, который посвящен мокингу. Ну и как любой настоящий студент искал материалы, которые с минимальными правками можно откопипастить в пояснительную записку :)

    Надеюсь что ты не против того, чтобы я этот пост, с некоторой шлифовкой использовал повторно так сказать. Обязуюсь упомянуть твой блог в списке литературы ;)

    ReplyDelete
  5. Конечно, не против :) И тебе спасибо на добром слове.

    ReplyDelete
  6. Огромное спасибо, теперь всё стало на свои места :)

    ReplyDelete
  7. Большое спасибо за понятное объяснение, некоторые детали до сих пор не понимал.

    ReplyDelete
  8. >> Дело в том, что очень часто тестируемый метод может вызывать методы других классов, которые в данном случае тестировать не нужно.

    А якщо "тестируемый метод" викливає методи цього ж класу, які не потрібно тестувати (або вже простестовані)?

    Наприклад, метод A викликає B, а B викливає методи C і D:
    A -> B -> C D

    Нам потрібно написати тести для A і B. Як створити заглушки спочатку C і D (в тестах для B) і тоді заглушку B (в тестах A)?

    ReplyDelete
  9. По первому вопросу: В идеальном случае в этом случае вы можете "замочить" и методы своего класса, которые вызываются из тестируемого. Это нормально, т.к. те методы будут другими тестами. Однако иногда имеет смысл не делать этого, а оставить вызовы внутренних методов класса (например, в случае приватных методов). Так что это не догма, решать вам.

    По второму вопросу (A -> B -> C D): Это тоже обычная ситуация. Мок-фреймворки позволяют спокойно мокать методы C и D для тестирования метода B, а потом точно так же метод B для тестирования метода A (при этом A вообще не знает, что в B вызываются еще какие-то методы C и D.

    ReplyDelete
  10. Дякую за відповіді і за наводку: пропертя CallBase класу MockRepository - те, що шукав (NUnit).

    ReplyDelete
  11. Да, присоединюсь к Дияну, поблагодарю тебя за этот пост! Хотя в общем то, я искал документацию по NMocks, но пост все равно оказался полезным.
    Все равно уже решил использовать Rhino.
    Ксати, насчет контроля поведения и состояния.
    Если использовать ожидание изменения свойства - то это тоже в какой-то степени контроль состояний.
    Просто у нас есть некий удобный фреймвёрк для того, чтобы каждый раз не мучаться с рефлексией.

    ReplyDelete
  12. Спасибо большое за пост! Очень понятно всё описано! Побольше бы таких постов в интернете...

    ReplyDelete
  13. Спасибо Александру за отличные статьи!

    ReplyDelete
  14. А если у меня, например, объект Warehouse создается внутри метода Fill (абстрактный случай, конечно), то как тогда тестировать этот метод? в данном примере мы просто передаем в order.Fill() наш стаб.

    ReplyDelete
  15. Здесь нужно применять один из подходов Dependency Injection (Constructor Injection, Method Injection, Property Injection). То есть прокидывать готовый объект Warehouse (как правило, через интерфейсную ссылку) либо в конструктор (предпочтительнее), либо в метод, либо в свойство (редко используется). Выбор конструктор-или-метод зависит от ситуации, если объект нужен лишь внутри этого конкретного метода и объект будет создаваться вручную - лучше в метод, если это какие-то репозитории или сервисы, которые проще создавать автоматически - лучше в конструктор. Для автоматического прокидывания берем любой IoC контейнер: Autofac, Ninject, Unity, etc. А потом конфигурируем IoC, либо используем Mock фреймворки так, что в режиме тестирования в метод идут стабы/моки, а в режиме выполнения - реальные объекты.

    То есть основная идея - делегировать создание реального объекта и принятие решения о том, что именно создавать кому-нибудь наверх, кто знает, в каком режиме мы сейчас запущены (тест или выполнение).

    Еще можно использовать паттерн Service Locator, но сейчас этот подход используется намного реже.

    ReplyDelete
  16. Всем спасибо за спасибо :) Рад, что материал актуален до сих пор, через 4 года.

    ReplyDelete
  17. Спасибо. а как в данном случае (в вашем примере) можно это сделать? Method Injection. Если использовать Unity. просто для меня это еще "темные материи" :)

    ReplyDelete
  18. В IoC контейнерах обычно используется Constructor и Property Injection. Если вам нужен Method injection через IoC, возможно, вы что-то делаете не так. Вызовы методов обычно контролируются на уровне кода, поэтому автоматические иньекции там не нужно. Но тут вроде написано, как сделать Method injection в Unity: http://stackoverflow.com/questions/515028/setter-property-injection-in-unity-without-attributes. Как говорится, если очень хочется, то можно :)

    ReplyDelete
  19. Замечательная статья! Спасибо вам большое!

    ReplyDelete
  20. Вот уже наступил 2013-й год, а статья до сих пор поддерживает статус "зашибись"!
    Спасибо большое за статью, вот бы где еще найти зачетный код-конвеншн для юнит-тестирования...

    ReplyDelete
  21. This comment has been removed by the author.

    ReplyDelete
  22. Хорошая статья, единственный вопрос остался c вызовом конструкторов в тестируемых методов, как создать моки этих новых сущностей?.

    ReplyDelete
  23. Если я правильно понял вопрос, то в идеале вы должны передавать сущности, которые "должны быть созданы в тестируемых методах", извне (например, в тот же метод). Таким образом, вы создаете мок снаружи в тесте (либо реальный объект в коде), а потом передаете внутрь.

    ReplyDelete
  24. Например:
    JDBCHelper helper = new JDBCHelper();
    ReferenceModel refModel = new ReferenceModel();
    DataObject object = (DataObject) value.getValue();
    ...
    ReferenceSelector refSelector = new ReferenceSelector();
    refSelector.setURLPattern(ReferenceSelector.DEFAULT_PATTERN);
    refSelector.setModel(model);
    if (object == null) {
    refSelector.setStartObjectId(trfContextHelper.getCustomerIdFromContext(retrieveContext()));
    refSelector.setObjectTypeId(helper.getObjectTypeIdFromReferenceAttribute(value.getId()));

    ....
    Проблема в том, что метод getObjectTypeIdFromReferenceAttribute вызывается, у объекта, который создан внутри метода, но т.к. половина других объектов заменены моками и параметр на вход также приходит тестовый то метод падает с NullPointer. Замочить этот метод тоже нельзя, т.к. вызывается он у вновь созданного объекта refSelector. первый раз я наверное неправильно сформулировал вопрос.

    ReplyDelete
  25. Насколько я понял, ReferenceSelector - это созданный вами класс, верно? В таком случае его объект нужно создавать снаружи данного метода и передавать внутрь. Тогда вы сможете его замочить без особых проблем. При необходимости объект можно передавать и через конструктор, используя возможности вашего IoC-контейнера. Та же история и с JDBCHelper.

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

    ReplyDelete
  26. По этому треду комментариев можно проследить эволюцию моих аватаров за 5 лет :D

    ReplyDelete
  27. Спасибо, очень хорошая статья. Добавить бы еще сравнение mock фреймворков, и моему счастью не было б предела)))

    ReplyDelete
  28. Отличная статья, даже через 5 лет:)

    SerP, Изучаю эту тему сейчас, и по собранной статистике можно сказать, что Rhino Mocks самый популярный

    ReplyDelete
  29. Замечательная статья, я столько перерыл инфы, но везде все описано как-то сложно и непонятно. У тебя я сразу все понял)))) Спасибо огромное)))))

    ReplyDelete
  30. Лучшая статья на эту тему)

    ReplyDelete
  31. Спасибо автору за прекрасную и понятную статью, а также за ответы на вопросы. И спасибо всем за прекрасные вопросы в комментариях

    ReplyDelete
  32. Повторюсь, отличная статья! Автору respect!

    ReplyDelete
  33. Спасибо за статью, в 2016м помогаете новичкам как и в далёком 2008м.

    ReplyDelete
  34. Хорошая статья, теперь все разложилось по полочкам. Спасибо!

    ReplyDelete
  35. Превосходно разъеснены моменты, в других источниках подразумевающиеся как само-собой очевидные. Спасибо!

    ReplyDelete
  36. купить семена адениума

    Экзотические растения свежиесемена адениума и другие комнатные цветы.

    еще

    ReplyDelete
  37. Спасибо, текст до сих пор актуален, было полезно.

    ReplyDelete
  38. I know this if off topic but I'm looking into starting my own weblog and was wondering what all is required to get setup? I'm assuming having a blog like yours would cost a pretty penny? I'm not very web savvy so I'm not 100% certain. Any suggestions or advice would be greatly appreciated. Thank you

    ReplyDelete