Еще раз здравствуйте! До старта занятий в группе по курсу «Разработчик С++» остается меньше недели. В связи с этим мы продолжаем делиться полезным материалом переведенным специально для студентов данного курса.
Юнит-тестирование вашего кода с шаблонами время от времени напоминает о себе. (Вы ведь тестируете свои шаблоны, верно?) Некоторые шаблоны легко тестировать. Некоторые — не очень. Иногда не хватает конечной ясности насчет внедрения mock-кода (заглушки) в тестируемый шаблон. Я наблюдал несколько причин, по которым внедрение кода становится сложным.
Ниже я привел несколько примеров с примерно возрастающей сложностью внедрения кода.
- Шаблон принимает аргумент типа и объект того же типа по ссылке в конструкторе.
- Шаблон принимает аргумент типа. Делает копию аргумента конструктора или просто не принимает его.
- Шаблон принимает аргумент типа и создает несколько взаимосвязанных шаблонов без виртуальных функций.
Начнем с простого.
Шаблон принимает аргумент типа и объект того же типа по ссылке в конструкторе
Этот случай кажется простым, потому что юнит-тест просто создает экземпляр тестируемого шаблона с типом заглушки. Некоторое утверждение может быть проверено для mock-класса. И на этом все.
Естественно, тестирование только с одним аргументом типа ничего не говорит об остальном бесконечном количестве типов, которые можно передать шаблону. Элегантный способ сказать то же самое: шаблоны связаны квантором общности, поэтому нам, возможно, придется стать немного проницательнее для более научного тестирования. Подробнее об этом позже.
Например:
template <class T>
class TemplateUnderTest {
T *t_;
public:
TemplateUnderTest(T *t) : t_(t) {}
void SomeMethod() {
t->DoSomething();
t->DoSomeOtherThing();
}
};
struct MockT {
void DoSomething() {
// Some assertions here.
}
void DoSomeOtherThing() {
// Some more assertions here.
}
};
class UnitTest {
void Test1() {
MockT mock;
TemplateUnderTest<MockT> test(&mock);
test.SomeMethod();
assert(DoSomethingWasCalled(mock));
assert(DoSomeOtherThingWasCalled(mock));
}
};
Шаблон принимает аргумент типа. Делает копию аргумента конструктора или просто не принимает его
В этом случае доступ к объекту внутри шаблона может быть неосуществим из-за прав доступа. Можно использовать friend
-классы.
template <class T>
class TemplateUnderTest {
T t_;
friend class UnitTest;
public:
void SomeMethod() {
t.DoSomething();
t.DoSomeOtherThing();
}
};
class UnitTest {
void Test2() {
TemplateUnderTest<MockT> test;
test.SomeMethod();
assert(DoSomethingWasCalled(test.t_)); // access guts
assert(DoSomeOtherThingWasCalled(test.t_)); // access guts
}
};
UnitTest :: Test2
имеет доступ к телу TemplateUnderTest и может проверить утверждения на внутренней копии MockT.
Шаблон принимает аргумент типа и создает несколько взаимосвязанных шаблонов без виртуальных функций
Для этого случая я рассмотрю реальный пример: Asynchronous Google RPC.
В C++ async gRPC есть нечто под названием CallData, которая, как следует из названия, хранит данные, относящиеся к вызову RPC. Шаблон CallData может обрабатывать несколько RPC разных типов. Так что это закономерно, что она реализована именно шаблоном.
Универсальная CallData принимает два аргумента типов: Request и Response. Выглядеть она может вот так:
template <class Request, class Response>
class CallData {
grpc::ServerCompletionQueue *cq_;
grpc::ServerContext context_;
grpc::ServerAsyncResponseWriter<Response> responder_;
// ... some more state
public:
using RequestType = Request;
using ResponseType = Response;
CallData(grpc::ServerCompletionQueue *q)
: cq_(q),
responder_(&context_)
{}
void HandleRequest(Request *req); // application-specific code
Response *GetResponse(); // application-specific code
};
Юнит-тест для шаблона CallData должен проверить поведение HandleRequest и HandleResponse. Эти функции вызывают ряд функций членов. Поэтому проверка исправности их вызова имеет первостепенное значение для исправности CallData. Тем не менее, есть подвохи.
- Некоторые типы из пространства имен grpc создаются внутри и не передаются через конструктор.
ServerAsyncResponseWriter
иServerContext
, например. grpc :: ServerCompletionQueue
передается конструктору в качестве аргумента, но не имеет виртуальных функций. Только виртуальный деструктор.grpc :: ServerContext
создается внутри и не имеет виртуальных функций.
Вопрос в том, как протестировать CallData без использования полноценного gRPC в тестах? Как сымитировать ServerCompletionQueue? Как сымитировать ServerAsyncResponseWriter, который сам является шаблоном? и так далее…
Без виртуальных функций подстановка пользовательского поведения становится сложной задачей. Захардкоженные типы, такие как grpc::ServerAsyncResponseWriter, невозможно смоделировать, поскольку они, хм, захардкоженны и не внедрены.
В передаче их в качестве аргументов конструктора толку немного. Даже если это сделать, это может оказаться бессмысленно, поскольку они могут быть final-классами или просто не иметь виртуальных функций.
Итак, что же нам делать?
Решение: трейты (Traits)
Вместо того, чтобы внедрять пользовательское поведение путем наследования от общего типа (как это делается в объектно-ориентированном программировании), ВНЕДРИТЕ САМ ТИП. Мы используем для этого трейты (traits). Мы специализируем трейты по-разному в зависимости от того, что это за код: продакшн-код или код юнит-тестирования.
Рассмотрим CallDataTraits
template <class CallData>
class CallDataTraits {
using ServerCompletionQueue = grpc::ServerCompletionQueue;
using ServerContext = grpc::ServerContext;
using ServerAsyncResponseWriter = grpc::ServerAsyncResponseWrite<typename CallData::ResponseType>;
};
Это основной шаблон для трейта, использующийся для продакшн-кода. Давайте использовать его в CallDatatemplate.
/// Unit testable CallData
template <class Request, class Response>
class CallData {
typename CallDataTraits<CallData>::ServerCompletionQueue *cq_;
typename CallDataTraits<CallData>::ServerContext context_;
typename CallDataTraits<CallData>::ServerAsyncResponseWriter responder_;
// ... some more state
public:
using RequestType = Request;
using ResponseType = Response;
CallData(typename CallDataTraits::ServerCompletionQueue *q)
: cq_(q),
responder_(&context_)
{}
void HandleRequest(Request *req); // application-specific code
Response *GetResponse(); // application-specific code
};
Глядя на приведенный выше код, ясно, что код приложения все еще использует типы из пространства имен grpc. Тем не менее, мы можем легко заменить типы grpc на фиктивные типы. Смотрите ниже.
/// In unit test code
struct TestRequest{};
struct TestResponse{};
struct MockServerCompletionQueue{};
struct MockServerContext{};
struct MockServerAsyncResponseWriter{};
/// We want to unit test this type.
using CallDataUnderTest = CallData<TestRequest, TestResponse>;
/// A specialization of CallDataTraits for unit testing purposes only.
template <>
class CallDataTraits<CallDataUnderTest> {
using ServerCompletionQueue = MockServerCompletionQueue;
using ServerContext = MockServerContext;
using ServerAsyncResponseWriter = MockServerAsyncResponseWrite;
};
MockServerCompletionQueue mock_queue;
CallDataUnderTest cdut(&mock_queue); // Now injected with mock types.
Трейты позволили нам выбирать типы, внедренные в CallData, в зависимости от ситуации. Этот метод не требует дополнительной производительности, так как не было создано ненужных виртуальных функций для добавления функциональности. Эта техника может быть использована также и в final-классах.
Как вам материал? Пишите комментарии. И до встречи на дне открытых дверей ;-)
Автор: MaxRokatansky