Skip to content

Instantly share code, notes, and snippets.

@vertigra
Last active March 18, 2024 14:08
Show Gist options
  • Save vertigra/696e9d92dc72070584e556e2169e850d to your computer and use it in GitHub Desktop.
Save vertigra/696e9d92dc72070584e556e2169e850d to your computer and use it in GitHub Desktop.
Введение в mock-объекты. Классификация

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

Конспект статьи

Часто тестируемый метод может вызывать методы других классов, которые в данном случае тестировать не нужно. 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.

Dummy

Предположим, что вам нужно протестировать метод Foo() класса TestFoo, который делает вызов другого метода Bar() класса TestBar. Предположим, что метод Bar() принимает какой-нибудь объект класса Bla в качестве параметра и потом ничего особого с ним не делает. В таком случае имеет смысл создать пустой объект Bla, передать его в класс TestFoo (сделать это можно при помощи широко применяемого паттерна Dependency Injection или каким-либо другим приемлемым способом), а затем уже Foo() при тестировании сам вызовет метод TestBar.Bar() с переданным пустым объектом. Это и есть иллюстрация использования dummy-объекта в unit-тестировании.

Fake

Метод Bar() выполняет какие-то действия с ним (допустим, Bar() сохраняет данные в базу или вызывает веб-сервис, а мы этого не хотим). В таких случаях наш объект класса TestBar должен быть уже не таким глупым. Мы должны научить его в ответ на запрос сохранения данных просто выполнить какой-то простой код (допустим, сохранение во внутреннюю коллекцию). В таких случаях можно выделить интерфейс ITestBar, который будет реализовывать класс TestBar и наш дополнительный класс FakeBar. При unit-тестировании мы просто будем создавать объект класса FakeBar и передавать его в класс с методом Foo() через интерфейс. Естественно, при этом класс Bar будет по-прежнему создаваться в реальном приложении, а FakeBar будет использован лишь в тестировании. Это иллюстрация fake-объекта

Stub

Stub-объекты (стабы) – это типичные заглушки. Они ничего полезного не делают и умеют лишь возвращать определенные данные в ответ на вызовы своих методов. В нашем примере стаб бы подменял класс TestBar и в ответ на вызов Bar() просто бы возвращал какие-то левые данные. При этом внутренняя реализация реального метода Bar() бы просто не вызывалась. Реализуется этот подход через интерфейс и создание дополнительного класса StubBar, либо просто через создание StubBar, который является унаследованным от TestBar. В принципе, реализация очень похожа на fake-объект с тем лишь исключением, что стаб ничего полезного, кроме постоянного возвращения каких-то константных данных не требует. Типичная заглушка. Стабам позволяется лишь сохранять у себя внутри какие-нибудь данные, удостоверяющие, что вызовы были произведены или содержащие копии переданных параметров, которые затем может проверить тест.

Mock

Mock-объект (мок), в свою очередь, является, грубо говоря, более умной реализацией заглушки, которая уже не просто возвращает предустановленные данные, но еще и записывает все вызовы, которые проходят через нее, чтобы вы могли дальше в unit-тесте проверить, что именно эти методы вот этих вот классов были вызваны тестируемым методом и именно в такой последовательности (хотя учет последовательности и строгость проверки, в принципе, настраиваемая вещь). То есть мы можем сделать мок MockFoo, который будет каким-то образом вызывать реальный метод Foo() класса TestFoo и затем смотреть, какие вызовы тот сделал. Или сделать мок MockBar и затем проверить, что при вызове метода Foo() реально произошел вызов метода Bar() с нужными нам параметрами.

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() или других (в зависимости от конкретного фреймворка).

Пример

Примеры используют 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);
    }
}

Пример тестирования с использованием стаба для 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), работать со свойствами и событиями. В общем, список потрясающий.

@Siskiz
Copy link

Siskiz commented Mar 7, 2022

Спасибо за Эпик. Теперь стал хоть немного разбираться в данном вопросе

@vertigra
Copy link
Author

vertigra commented Mar 8, 2022 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment