CxxMock — принцип действия

в 21:04, , рубрики: c++, cxxmock, unit-testing, как это работает, Программирование

CxxMock — принцип действия - 1
Иногда бывает интересно изучить архитектуру какого либо изделия, и посмотреть как оно устроено. Вот бывало разберешь часы, а обратно собрать не можешь… Но в отличии от часов программные продукты при доступе к исходникам можно разобрать, и собрать. А найденные решения применять уже в своей практике.

Когда у меня возникла необходимость в создании CxxMock, о котором я писал в статье CxxMock — Mock-объекты в C++, я разобрал принцип действия похожего GoogleMock. Или еще раньше разобрал основную идею c10k сервера mathopd, что последующих проектах позволило мне лучше маневрировать в проектировании архитектуры.

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

CxxMock взгляд изнутри

Интересные решения о которых будет идти речь:

  1. Имитация поведения как будто у нас есть отражение
  2. Обеспечение регистрации фабрик объектов без оверхеда.
  3. Создание нужной реализации интерфейса.
  4. Программирование поведения метода.
  5. Контроль выполнения метода
  6. Сравнение аргументов.
  7. Выполнение пользовательского метода.

Имитация поведения как будто у нас есть отражение

В С# отражение есть, в C++ нет, но есть RTTI который может помочь идентифицировать типы но не может ни вызывать методы, ни строить классы динамически во время исполнения программы. То есть, для того чтобы что-то создать, нужно чтобы оно уже существовало во время компиляции, и чтобы ядро CxxMock знало о том ЧТО надо создать и КАК надо создать. Для достижения этого, можно применить парсер кода также, как это делает CxxTest для построения оглавления тестов и Qt для создания QMetaOBject содержащий ссылки на все сигналы и слоты. В случае с CxxMock пришлось соответствовать концепции CxxTest и написать генератор на python со всякими страшными regex и алгоритмом разбора скобок чтобы учитывать такие случаи:

namespace NS4 { namespace NS5 {
    
class Interface
{
public:
	 virtual void method(int a)=0;
	 virtual Type* method2(const Type& a)=0;
	 virtual ~Interface(){};
};
}}

на выходе, генератор создает заголовочный файл с классом (классами) который реализует интерфейс

namespace NS4 { namespace NS5 {
    
class CXXMOCK_DECL( Interface )
{
public:
	 virtual int method(int a) {
             CXXMOCK( int, a ) 
         }
	 virtual Type* method2(const Type& a){ 
             return CXXMOCK( Type*, a ) 
         }
	 virtual ~Interface(){};
};
CXXMOCK_IMPL( Interface )
}}

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

Обеспечение регистрации фабрик объектов без оверхеда

Если подходить в лоб, то пришлось бы для каждого класса имеющий специальную реализацию интерфейса в какой-то точке setUp() или сразу вначале писать код типа такого:

cxxmock::Repository::instance().registerFactory<Interface>( new MockFactory<Interface, InterfaceImpl>() );
cxxmock::Repository::instance().registerFactory<Interface2>( new MockFactory<Interface2, Interface2Impl>() );
...

Это согласитесь, не очень удобно, по десять раз повторять название нашего интерфейса. Даже если этот фрагмент кода будет автоматически создан, то куда он должен быть вставлен?

Мы имеем ограничения:

  1. В большей части случаев при применении CxxTest не требуется вручную переписывать функцию main(). То есть мы в нее не можем вставить наш код.
  2. Однако, также не удобно делать регистрацию в каждом методе setUp() каждого тестового набора, даже подключая оглавлание через директиву #include
  3. И выполнять какой-то код в том же месте где объявляется наш класс тоже вроде бы нельзя

Но когда нельзя но очень хочется – то можно. Для этой цели применяется вызов макроса

CXXMOCK_IMPL( Interface )

Макрос отвечает за создание статической переменной – контейнера типизированного интерфейсом и нашим созданным классом — заглушкой

#define CXXMOCK_IMPL( interface )  CxxMock::Container<interface, cxxmocks_impl_##interface> cxxmocks_instance_##interface;

За счет того, что в С++ так же как и в ANSI C при загрузке библиотеки в память, еще до запуска main() и _init() сначала инициализируются все статические и глобальные переменные. Так как объявленные в CXXMOCK_IMPL переменные — являются экземпляром класса, то для них будет вызван конструктор, в котором мы можем сообщить реестру что у нас есть контейнер которая умеет создавать объекты реализующие определенный интерфейс.

template<class Interface, class Impl> 
Container<Interface, Impl>::Container()
{
    Repository::instance().registerFactory( this );
}

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

template<class Interface, class Impl> 
Interface* Container<Interface, Impl>::create(){    return new Impl();  }

поэтому дальше на его ссылаются как на фабрику — Factory.

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

Создание нужной реализации интерфейса

Для того чтобы что-то создать, нужно знать ЧТО создавать и найти того кто знает КАК создавать то что нам нужно.
В .NET мы можем просто написать:

_registry[ typeof( factory ) ] = factory;

для C++ нужно применять магию с RTTI:
CxxMock — принцип действия - 2

template<class T>
void Repository::registerFactory(Factory<T>* factory)
{
    string tname = typeid( typename Factory<T>::TargetType ).name();
    _registry[ tname ] = factory;
}

_registry это обычный std::map<string, Handle*>, использование указателя на Handle (базовый класс для фабрик) обеспечивает работу dynamic_cast.

после того как фабрика есть в реестре, мы можем попросить репозиторий создать нам реализацию объекта заглушки для нашего интерфейса:

template<class T>
T* Repository::create()
{
    RepositoryTypeMap::iterator it = _registry.find( typeid(T).name() );
    ...
    return dynamic_cast< Factory<T>* >(&(*it->second))->create();
}

Здесь применяется трюк с приведением типа от Handle обратно к Factory* так, как у нас в коллекции лежат фабрики совершенно разных типов у которых общий только класс Handle.

Программирование поведения метода

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

Идеально это должно выглядеть так (Rhino.Mocks, C# ):

Expect.Call( mock->method( 5 ) ).returns( 10 );
Expect.Call( ()=> { mock->voidMethod( 5 ); } ).repeat.any;

На самом деле тут происходит происходит два вызова:

  • Сначала честно вызывается наш метод интерфейса mock->method(), затем
  • Результат, неважно какой, передается в вызов Expect.Call() который возвращает структуру CalllInfo содержащую информацию о вызове.

Также в Rhino.Mocks используется класс Expect который '''знает''' о текущем контексте и активном MockRepository.

Для С++ версии я применил похожий трюк:

 TS_EXPECT_CALL( mock->method(10) ).returns(5);

но с использованием макроса TS_EXPECT_CALL с совместимой с CxxTest сигнатурой в который спрятал вызов:

 CxxMock::Repository::instance().expectCall(  mock->method(10) )

отличие от Rhino.Mocks здесь в том, что во первых не используется дополнительный класс для упрятывая обращения к экземпляру репозитория (MocksRepository), а во вторых есть возможность замаскировать способ вызова метода method().

После того как структура CallInfo возвращается по ссылке из expectCall() происходит обычная работа по настройке объекта.

Передача аргументов

Интересный вопрос с записыванием аргументов с которым был метод вызван и обеспечение запоминания и возврата возвращаемого значения. Аргументы надо хранить и с ними нужно сравнивать.

В CxxMock применено смешанное решение:

1. Автогенератор создает класс с использованием макроса CXXMOCK, что дает возможность простого использования в ручном режиме.

int method(int a)
{
   return CXXMOCK(int, a);
}

2. Макрос CXXMOCK, в свою очередь, вызывает перегруженный метод cxxmock_object.mock(MOCK_FUNCID, args); который имеет произвольную типизацию аналогично Action<> в C# (до 10 аргументов) и сообщает ядру CxxMock строковое представление названия метода. Так как нам важно точно знать какой именно метод был вызван и какая у него сигнатура, и в С++ возможна перегрузка в том числе и чистых виртуальных методов, то используется регистрация по вызова по полной сигнатуре метода используя макрос MOCK_FUNCID реализующий __PRETTY_FUNCTION__ или __FUNCDNAME__ в зависимости о компилятора.

template <typename R, typename  A1, typename A2>
R MockObject::mock( const std::string& funcname, A1 a1, A2 a2)
{
   //Метод processCall() принимает решение: записывать действие или проверять вызов.
   return this->processCall<R>(  method(funcname).arg( a1 ).arg( a2 ) );
}
CallInfo& MockObject::method( std::string funcname )
{
   CallInfoPtr ptr = new CallInfo(funcname);
   Repository::instance().setLastCall( ptr );
   return *ptr;
}

Внутри перегруженного метода происходит фактическая регистрация всех аргументов и формирование структуры CallInfo для дальнейшей настройки

В отличии от решения googlemock, тут метод MockObject::mock() на каждый вызов формирует одинаковую структуру CallInfo в которую записывается вся информация о вызове. Аргументы метода сохраняются таким же образом как это делается для Фабрики классов.:

template<typename A>
CallInfo& CallInfo::arg(const A value )
{
	inValues[ inValues.size() ] = new Argument<A>(value);
	return *this;
}

После чего, в режиме воспроизведения выполняется простое сравнение коллекций аргументов используя унифицированный интерфейс IArgument для всех реализаций Argument.

Сравнение аргументов

Сравнение аргументов делается очень просто. Для этого нужно взять ожидаемое значение и сравнить его с тем значением которое пришло вызове пользовательского интерфейса. А так как вариантов сравнения много, то здесь я просто использую возможности CxxTest про сравнению всех типов. У него для этого есть хорошие возможности.

template<typename T>
bool Argument<T>::compare( const IArgument& other ) const
{
   const Argument<T> *arg = dynamic_cast< const Argument<T>* >( &other );
   return CxxTest::equals( Value, arg->Value);
}

Более серьезной проблемой является было получение строкового представления значения для того чтобы сформировать хорошее сообщение об ошибке типа такой:

Ожидали вызов: Interface::method( 5 )
Фактически вызван: Interace::method2( 6 )

Так как разработчик может применять свои типы данных, и если он использует какой-то фреймвок для тестирования, то он не должен писать ничего дополнительно. Поэтому тут также применен CxxTest:

template<typename TVal>
std::string convertToString(TVal arg ) const
{ 
    return CxxTest::ValueTraits<T>( Value ).asString();  
}
const std::string toString() const
{
    return convertToString( Value );
}

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

Выполнение пользовательского метода

Для того чтобы выполнить пользовательский метод необходимо несколько условий::

  1. Нужно сохранить информацию о методе, который надо вызвать, и его тип чтобы правильно его вызвать.
  2. Мы должны одинаково инициировать вызов пользоватеского метода назависимо от того сколько у него аргументов.
  3. Учитывая строгую типизацию, нужно построить вызов метода как будто у нас переменое число аргументов.

Чтобы сохранить информацию о методе, просто сделаем шаблонный класс который хранит указатель на функцию который мы получили на входе:

template< typename Sender, typename T>
CallInfo& action( Sender* sender, T method )
{
    _action = new Action<Sender, T>(sender, method );
    return *this;
}
 

Для того чтобы вызвать «то незнаю что» применим прокси метод который реализует интерфейс (IAction) но внутри себя вызывает уже шаблонный метод реализующий конкретную стратегию вызова пользовательского метода:

template< typename Sender, typename T>
class Action : public IAction
{
   Sender* _sender;
   T _method;
...
   //шаблонный метод
   template<typename R, typename A>
   void callMethod(IArgumentPtr result, const ArgList& args, R (Sender::*method)(A)){
      result->setValue(Argument<R>((_sender->*_method)( args.val<A>(0) )) );
   }
...
   void call(IArgumentPtr result, const ArgList& args)  {
      callMethod(result, args, _method );
   }
}

За счет того что того что мы в каждой шаблонной реализации явно указываем сигнатуру пользовательского метода, в точке callMethod() наш тип Т будет разложен на тип R (Sender::*method)(A). что позволяет обработать конкретную версию вызова отдельно. И построить вызов пользовательского метода таким же образом, как выполнялась регистрация вызова метода.

За счет применения решения «шаблон наследуемый от интерфейса», не требуется создавать много сервисных классов, достаточно просто сделать много версий методов с реализацией стратегии вызова. Что обеспечит симуляцию вызова метода с переменным числом аргументов.

Заключение

Главные тезисы примененных трюков:

  • Даже простой текста может сделать имитацию «отражения» когда очень хочется
  • Можно выполнять любой код до передачи управления в main() используя конструктор объекта
  • Чтобы привязать шаблонные классы к общей точке нужно наследовать интерфейс, dynamic_cast все остальное сделает как надо.
  • Можно строить цепочки последовательностей за счет неявного использования глобального контекста (Expect.Call(...)).
  • Десяток перегруженных методов и коллекции контейнеров аргумента может упростить создание своей версии RPC или задачу сравнения списка аргументов
  • Не нужно делать все самому, иногда платформа уже предоставляет для этого возможности
  • Сложный тип в шаблоне может быть разложен на более простые типы, что позволит точнее выбрать реализующий метод

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

Все лежит на SourceForge и GitHub.

Спасибо за внимание.

Ссылки

  1. Основной сайт CxxMock
  2. Зеркало на SourceForge
  3. Зеркало на GitHub
  4. CxxTest
  5. Rhino.Mocks
  6. GoogleMock

Что почитать

Для постижения ДАО программирования, также рекомендую:

  1. Майерс Скотт. Эффективное использование C++. 55 верных способов улучшить структуру и код ваших программ
  2. Джон Бентли. Жемчужины программирования

Автор: sbase

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js