Mock-объекты с использованием библиотеки cppmock

Автор: Stanislav Kobylansky
Quest Software

Источник: RSDN Magazine #3-2004
Опубликовано: 14.11.2004
Версия текста: 1.0
Введение
Концепция
Пример использования
Подготовка тестируемого класса для использования с Mock-объектами
Написание mock-класса
Использование mock-класса в тестах
Описание API mockpp
Controller
VisitableMockObject
Ограничения и проблемы в использовании
Макросы
Ошибки в реализации библиотеки
Не-ООП API
Параметры, возвращающие значения
Строки
Ошибки в mock-объектах и тестах
Ссылки

Введение

Mockpp – платформо-независимая библиотека для создания mock-объектов на С++. Она написана с использованием идеологии, заимствованной из “Mock Objects for Javа”(http://www.mockobjects.com/) и “EasyMock”(http://www.easymock.org/). Ее задача – облегчить модульное тестирование.

Основная проблема при проведении unit-тестов заключается в трудности воспроизведения окружения, необходимого для теста. Предположим, что вы написали тест для класса, который работает с базой данных и должен определенным образом реагировать на неверную версию БД, например, сгенерировать определенное исключение. Воспроизвести такую ситуацию довольно сложно – надо иметь БД, которая содержит определенный номер версии. Для другого теста вполне может понадобиться еще какая-нибудь конфигурация базы данных, и т.д. К тому же, скорость выполнения подобных тестов оставляет желать лучшего, а для TDD (Test Driven Development) это существенно, т.к. тесты запускаются очень часто. В конечном итоге, сложность создания настоящего окружения для тестов вынудит разработчика отказаться от тестирования.

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

Концепция

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

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

Использование mock-объектов накладывает свой отпечаток на дизайн системы. Становится больше интерфейсов, т.к. окружение должно быть вынесено в интерфейсы. Больше инициализации классов выходит за пределы самих классов, т.к. теперь окружение задается извне, чтобы была возможность его подмены, что несколько затрудняет, например, использование паттерна синглетон. Думаю, что это не полный список, но пока мне бросилось в глаза только это.

Пример использования

Подготовка тестируемого класса для использования с Mock-объектами

В качестве примера использования mock-объектов давайте рассмотрим написание unit-тестов для класса, который оборачивает работу с реестром. Чтобы получить возможность подменить реальное API на mock-объект, я вынес обращения к API в простой интерфейс, а также сделал простенькую реализацию (которая будет использоваться в реальном коде), обращающуюся к реальным Win32-функциям.

        class IRegNativeAPI
{
public:
  IRegNativeAPI(void){};
  virtual ~IRegNativeAPI(void){};

  virtual LONG RegCloseKeyImpl(IN HKEY hKey) = 0;
  ...
};

Я не стал приводить тут весь интерфейс. Думаю, что по одной функции уже понятно, как выглядит описание всего остального, а также как выглядит стандартная реализация интерфейса, использующая API.

Естественно, пришлось внести небольшие изменения в сам класс для работы с реестром. Для начала я добавил в него переменную, которая является smart-указателем на IRegNativeAPI:

        class ACMUTIL_API CRegistryKey
{
  friendclass ACMUTIL_API CRegistryKeyInfo;
private:
  HKEY      m_hKey;
  IRegNativeAPIPtr  m_apiImplPtr;
  ...
};

Далее я написал код для инициализации указателя на IRegNativeAPI:

CRegistryKey::CRegistryKey(IRegNativeAPIPtr apiImplPtr 
  /*= new CRegNativeAPIImpl()*/) : m_hKey(NULL)
{
  m_apiImplPtr = apiImplPtr;
}

Затем я заменяю все вызовы API в коде на вызовы методов интерфейсов. В целом весь этот процесс занял совсем немного времени, так, что подобная замена почти безболезненна.

Кроме приведенного выше варианта, позволяющего заменять реализацию окружения, используя полиморфизм, есть вариант с использованием шаблонов. Такой вариант подразумевает использование реализации интерфейса как параметра шаблона класса. У каждого из этих методов есть свои преимущества и недостатки.

Недостаток использования указателя на реализацию - потенциально есть вероятность, что указатель не будет инициализирован, кроме того, тратится некоторое время на выделение памяти и на виртуальный вызов, но зато этот способ более нагляден и типобезопасен. Он также позволяет использовать подсказки VisualAssist-а, который достаточно умен, чтобы показывать функции, доступные через оператор “->”.

При использовании шаблонов типобезопасности нет. В данном случае шаблон, по сути, является просто макросом, т.к. туда можно передать любой тип, который реализует набор методов с соответствующими сигнатурами, хотя эту проблему можно решить, используя проверку на наследование на этапе компиляции, реализованную Андреем Александреску. Вторая проблема состоит в отсутствии наглядности – глядя на переменную, которая предоставляет интерфейс, я не могу сказать, какой именно интерфейс она предоставляет. Третья – полное отсутствие подсказок VisualAssist-а. Лично для меня это важно. :) Опять же, это мое мнение – шаблоны усложняют код. Но, тем не менее, у способа с шаблонами есть одно очень существенное преимущество – он проще. Создавать для каждого класса интерфейс - занятие муторное и вносящее дополнительную сложность в систему. В общем случае стоит выбрать решение с шаблонами.

Для данной статьи я выбрал решение с указателем. Но это, естественно, вопрос спорный. Это решение мне показалось более академичным, подходящим для изучения и демонстрации концепции.

Написание mock-класса

Итак, после того, как завершена вся подготовка класса к замене его окружения mock-объектами, надо написать класс, который будет как раз являться mock-объектом.

      class CRegNativeAPIMock: public IRegNativeAPI,
  public mockpp::VisitableMockObject
{
public:
  CRegNativeAPIMock(const wstringex& sName) :
    mockpp::VisitableMockObject(sName),
    MOCKPP_CONSTRUCT_MEMBERS_FOR_METHOD1(RegCloseKeyImpl),
    MOCKPP_CONSTRUCT_MEMBERS_FOR_METHOD5(_RegOpenKeyExImpl),
    MOCKPP_CONSTRUCT_MEMBERS_FOR_METHOD3(_RegConnectRegistryImpl),
    ...
  {
  }
  ~CRegNativeAPIMock(void);

  MOCKPP_VISITABLE1(CRegNativeAPIMock, LONG, RegCloseKeyImpl, HKEY);
  MOCKPP_VISITABLE5(CRegNativeAPIMock, LONG, _RegOpenKeyExImpl, HKEY, LPCTSTR, DWORD, REGSAM, PHKEY);
  LONG RegOpenKeyExImpl(IN HKEY hKey, IN LPCTSTR lpSubKey, IN DWORD ulOptions, IN REGSAM samDesired, OUT PHKEY phkResult)
  {
    *phkResult = HKEY_LOCAL_MACHINE;
    return _RegOpenKeyExImpl(hKey, lpSubKey, ulOptions, samDesired, NULL);
  }
  MOCKPP_VISITABLE3(CRegNativeAPIMock, LONG, _RegConnectRegistryImpl, LPCTSTR, HKEY, PHKEY);
  LONG RegConnectRegistryImpl(IN LPCTSTR lpMachineName, IN HKEY hKey, OUT PHKEY phkResult)
  {
    *phkResult = HKEY_LOCAL_MACHINE;
    return _RegConnectRegistryImpl(lpMachineName, hKey, NULL);
  }
  ...
};

Для того, чтобы класс был mock-классом, его надо унаследовать от класса VisitableMockObject.

В этой реализации mock-класса реализованы три метода из интерфейса. Для каждого из методов надо объявить два макроса. Первый – MOCKPP_CONSTRUCT_MEMBERS_FOR_METHODx, как видно по названию, инициализирует члены класса, относящиеся к методу, который передается макросу в качестве параметра. Объявляет эти члены класса второй макрос – MOCPP_VISITABLEx, который также генерирует тело метода. Ему передается имя метода, а также, последовательно, типы параметров метода. Число в конце имени этих макросов указывает на число параметров в методе, который надо реализовать. На данный момент доступны макросы только для методов, у которых есть не более пяти аргументов.

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

Использование mock-класса в тестах

При тестировании, настоящее окружение объекта заменяется mock-объектом, т.е. вместо настоящей реализации интерфейса, с которым работает объект, ему подсовывают mock-реализацию.

В случае с классом, оборачивающим работу с реестром, это будет выглядеть так:

      void CRegistryKeyTest::OpenInvalidRegParamsTest()
{
  CRegNativeAPIMockPtr mockAPIPtr = new CRegNativeAPIMock(L"mock_api");
  CRegistryKey regKey(utils::ptr_implicit_cast<IRegNativeAPIPtr>(mockAPIPtr));
  MOCKPP_CONTROLLER_FOR(CRegNativeAPIMock, _RegOpenKeyExImpl) mockContrl(mockAPIPtr);

  mockContrl.setDefaultReturnValue(ERROR_NO_SYSTEM_RESOURCES);
  mockAPIPtr->activate();

  regKey.Open(HKEY_LOCAL_MACHINE, L"some_name");
}

Это тест на то, как класс CRegistryKey обрабатывает ошибки, вернувшиеся из окружения. Тест будет считаться успешным, если в нем вызовется исключение CRegException при вызове метода Open. Сначала создается mock-объект, потом он передается тестируемому классу. Далее создается контроллер. Контроллер представляет объект для управления методом определенного mock-класса. Он создается макросом, которому в качестве параметров передаются имя mock-класса и имя метода, которым он будет управлять. В конструктор контроллера передается указатель на mock-объект. Вызов тестируемым классом метода, для которого не создан контроллер, считается ошибкой и вызовет исключение. Т.е. для всех методов, которые должен вызвать в текущем сценарии тестируемый метод, надо создать контроллер. С помощью метода setDefaultReturnValue контроллера задается значение, которое должен возвращать метод. Далее mock-объект активизируется вызовом activate и метод Open запускается на выполнение.

Предыдущий пример иллюстрирует наиболее простой способ использования mock-объекта. Более сложный сценарий будет выглядеть, например, так:

      void CRegistryKeyTest::OpenSubKeyTest()
{
  staticconst wstringex sKeyName = L"some_name";
  staticconst wstringex sSubKeyName = L"some_name";

  CRegNativeAPIMockPtr mockAPIPtr = new CRegNativeAPIMock(L"mock_api");
  MOCKPP_CONTROLLER_FOR(CRegNativeAPIMock, _RegOpenKeyExImpl) 
    mockOpenContrl(mockAPIPtr);
  MOCKPP_CONTROLLER_FOR(CRegNativeAPIMock, RegCloseKeyImpl) 
    mockCloseContrl(mockAPIPtr);

  mockCloseContrl.addReturnValue(ERROR_SUCCESS, 3);
  mockOpenContrl.addResponseValue(ERROR_SUCCESS, 
    HKEY_LOCAL_MACHINE, sKeyName.c_str(), 0, KEY_ALL_ACCESS, NULL);
  mockOpenContrl.addResponseValue(ERROR_SUCCESS, 
    HKEY_LOCAL_MACHINE, sSubKeyName.c_str(), 0, KEY_ALL_ACCESS, NULL);
  mockAPIPtr->activate();

  {
    CRegistryKey regKey(
      utils::ptr_implicit_cast<IRegNativeAPIPtr>(mockAPIPtr));
    regKey.Open(HKEY_LOCAL_MACHINE, sKeyName, KEY_ALL_ACCESS);
    regKey.OpenSubKey(sKeyName, KEY_ALL_ACCESS);
  }

  mockAPIPtr->verify();
}

Тут, в отличие от предыдущего примера, используются два контроллера и более сложные настройки методов. Метод addResponseValue() принимает в качестве параметров возвращаемое значение, а также набор значений. Если будет вызван метод, за который отвечает данный контроллер, со значениями, совпадающими с теми, что были переданы в addResponseValue(), то он вернет соответствующее им возвращаемое значение. Если же этому методу будет передан другой набор параметров, то он вернет значение, установленное методом setDefaultValue(). А если setDefaultValue() не был вызван, как в данном случае, будет вызвана ошибка, и тест не пройдет. Далее идет активация объекта, работа тестируемого кода и вызов функции verify(), которая проверяет заданные контроллеру условия и, если что-то было не так, например, функция RegCloseKeyImpl была вызвана меньше трех раз, вызывает ошибку.

Описание API mockpp

Тут описаны не все функции, т.к. не со всеми мне пришлось столкнуться, а просто переписывать то, что написано в документации mockpp – бесполезно, т.к. она более чем лаконична. Надеюсь, мое описание будет несколько информативнее родной документации.

Controller

        void AddResponseValue(const your_type& rv, …, 
  unsigned count = std::numeric_limits< unsigned >::max())

Добавляет возвращаемое значение, которое вернется при условии, что mock-реализация функции будет вызвана с соответствующими значениями параметров. Это значение для данных параметров будет возвращено count раз.

        void AddRetrunValue(const your_type& rv, insigned count = 1)

Добавляет возвращаемое значение. Оно будет возвращено mock-реализацией функции count раз.

        void setDefaultReturnValue(const your_type& rv)

Устанавливает значение, которое будет возвращать mock-реализация функции по умолчанию, т.е. если не сработало ни одно из условий, установленных с помощью addResponceValue или addReturnValue.

        void reset()

Очищает все условия и параметры, заданные функциями setDefaultReturnValue, addReturnValue, AddResponseValue, addThrowable и т.д.

        void addThrowable (Throwable *t)

Добавляет исключение, которое вызовется mock-реализацией функции. В эту функцию можно передать экземпляр любого типа.

ПРИМЕЧАНИЕ

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

        void setDefaultThrowable(Throwable* t)

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

ctrl.setDefaultThrowable(mockpp::make_throwable(CRegException(ERROR_NO_SYSTEM_RESOURCES)))

setDefaultThrowable имеет приоритет над setDefaultReturnValue и addReturnValue. Т.е. если задано исключение, генерируемое по умолчанию, оно будет выдаваться всегда, если не подходит другое, заданное через addResponceThrowable.

        void addResponseThrowable(Throwable* t, …, 
  unsigned count = std::numeric_limits< unsigned >::max())

Добавляет исключение, которое вызовется при передаче в mock-функцию определенных параметров. Действует на count число вызовов.

VisitableMockObject

        void activate()

Активизирует mock-объект. После этого его можно использовать. Если не вызвать эту функцию, то все, что задавалось в контроллере, работать не будет.

        void verify()

Проверяет, что все заданные условия в адаптере сработали. Например, если с помощью вызова addReturnValue добавлено возвращаемое значение, и указано, что оно должно вернуться 3 раза, то verify проверит это условие и выдаст ошибку, если оно не выполнено.

Ограничения и проблемы в использовании

Макросы

Много проблем при использовании mock-объектов возникает в C++ из-за специфики этого языка. В нем практически отсутствует runtime-информация о типе, что не позволяет mock-библиотеке гибко реализовывать интерфейсы. В C++ это проблема обходится с использованием макросов, что само по себе причиняет много неудобств, например, посмотреть как работает библиотека mockpp проблематично, т.к. по макросам отладчиком не пройдешься.

Ошибки в реализации библиотеки

Подключая библиотеку mockpp, я натолкнулся на несколько ошибок в ее реализации. Основная проблема была в том, что макросы, реализующие mock-функции, предполагали, что все параметры, которые будут передаваться функциям - это ссылка на константу:

        #define MOCKPP_VISITABLE1(classname, ret_type, name, type1) \
  public: \
    ret_type name(const type1& param1) \

Соответственно, если метод интерфейса принимает параметр не как ссылку на константу, то возникала проблема. Для решения это проблемы я просто убрал “const” и “&” из нескольких макросов и функций. Вроде работает. :) Хочу заметить, что эта проблема вас не коснется, если не наследовать mock-класс от интерфейса и передавать его в тестируемый класс как параметр шаблона.

Код библиотеки изобилует warning-ами из-за урезания size_t в unsigned int и некоторыми проблемами с Unicode, которые решились довольно просто.

Не-ООП API

Следующая проблема опять связана с C++. Заключается она в том, что многие API для этого языка поставляются в виде набора функций, а не в виде интерфейсов. Так как mock-объеты работают с использованием интерфейсов, то приходится делать интерфейсную обертку для этих API. К счастью, в большинстве случаев достаточно банальной обертки, вызывающей соответствующие функции, как сделано у меня при тестировании класса для работы с реестром.

Параметры, возвращающие значения

Mock-объекты не дружат с возвращаемыми параметрами. То есть если вам надо что-то вернуть из mock-метода объекта по ссылке или указателю, то mockpp не предоставит средств для реализации этого. Чаще всего реализация подобных вещей проста, но бывают и нетривиальные случаи. Решать подобные проблемы разработчику приходится самостоятельно.

Строки

Проблемы по сути заключается не в строках, а в объектах с конструкторами по умолчанию. Проблема довольно специфична и более всего заметна на классах строк. Допустим, функция, для которой есть mock-реализация, принимает параметр в виде wchar_t*. Я формирую при помощи метода addResponseValue набор параметров и соответствующее возвращаемое значение:

MOCKPP_CONTROLLER_FOR(CRegNativeAPIMock, RegConnectRegistryImpl) mockConnContrl(mockAPIPtr);
mockConnContrl.addResponseValue(ERROR_SUCCESS, L"some_machine", HKEY_LOCAL_MACHINE, NULL);

Потом вызываю метод, который в качестве параметра принимает wstring&:

regKey.Connect(L"some_machine", HKEY_LOCAL_MACHINE)

В этом методе вызывается mock-метод, и ему передается временная строка, создаваемая при вызове метода Connet из указателя на wchar_t:

        void CRegistryKey::Connect(const wstringex& sCompName, HKEY hRoot) throw(CRegException, except::CInvalidArgumentException)
{
  ...
  HKEY hKey = NULL;
  LONG lRes = m_apiImplPtr->RegConnectRegistryImpl(sCompName.c_str(), 
    hRoot, &hKey);
  if ( lRes != ERROR_SUCCESS ) 
  {
    FIRE_ERROR_EVENT(wstringex().format(
      L"RegConnectRegistry failed for computer '%s'.", 
      sCompName.c_str()), szCurrentContext, lRes, 
      utils::events::EVENT_LEVEL_2);
  throw CRegException(lRes);
  }
  m_hKey = hKey;
}

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

Ошибки в mock-объектах и тестах

Естественно, как и любой код, mock-объекты и тесты могут содержать ошибки, что может привести к неправильным результатам тестов.

Ссылки


Эта статья опубликована в журнале RSDN Magazine #3-2004. Информацию о журнале можно найти здесь