Полгода назад на моем проекте было примерно около 0% покрытие кода юнит-тестами. Простых классов было достаточно мало, создавать для них юнит-тесты было легко, но это было относительно бесполезно, так как на самом деле важные алгоритмы находились в сложных классах. А сложные, с точки зрения поведения, классы было трудно юнит-тестировать так как такие классы были завязаны на другие сложные классы и классы конфигурации. Создать объект сложного класса и тем более его протестировать юнит-тестами было невозможно.
Некоторое время назад я прочёл "Writing Testable Code" в Google Testing Blog .
Ключевая идея в статье заключается в том, что C++ код, пригодный для юнит-тестирования, пишется совсем не так, как привычный C++ код.
До этого у меня было впечатление, что для написания юнит-тестов наиболее важен фреймворк для юнит-тестирования. Но все оказалось не так. Роль фреймворка — второстепенна, прежде всего требуется писать именно код, пригодный для юнит-тестирования. Автор для этого использует термин "testable code". Или, как мне кажется более точным, "unit-testable code". Затем все достаточно просто. Для testable code можно сразу писать ЮТ и тогда будет Test Driven Development (TDD), можно и позже, код все равно это позволяет. Я пишу тесты сразу с кодом, а потом смотрю по coverage report забытые и пропущенные места в коде и дополняю тесты.
В своей статье автор приводит несколько принципов. Я отмечу и прокомментирую самые важные, с моей точки зрения.
#1. Mixing object graph construction with application logic:
Абсолютно важный принцип. Фактически любой сложный класс обычно создает несколько классов других объектов внутри себя. Например в конструкторе или в ходе обработки конфигурации.
Обычный подход — использовать new прямо в коде класса. Это совершенно неправильно для юнит-тестирования. Если так создавать класс, то в итоге получится именно куча слипшихся объектов классов, которые невозможно протестировать.
Правильный подход с точки зрения ЮТ — если классу требуется создавать объекты, то класс должен получать на вход указатель или ссылку на интерфейс класса-фабрики.
Пример:
// заголовочный файл с интерфесами
class input_handler_factory_i {
virtual ~input_handler_factory_i() {}
// чистые виртуальные функции для создания объектов
};
// файл с классами программы
class input_handler_factory : input_handler_factory_i {
// реализованные функции для создания объектов
};
class input_handler {
public:
input_handler(std::shared_ptr<input_handler_factory_i>)
};
// файл с юнит-тестами
class test_input_handler_factory : input_handler_factory_i {
// реализованные функции для создания тестовых объектов
};
Я обычно возвращаю именно std::shared_ptr из методов класса-фабрики. Таким образом непосредственно в юнит-тестах можно сохранять
созданные тестовые объекты и проверять их состояние. Еще. В фабрике я не только создаю объекты, но и могу делать отложенную инициализицию объектов.
#2. Ask for things, Don't look for things (aka Dependency Injection / Law of Demeter):
Объекты с которыми взаимодействует класс должны ему предоставляться непосредственно.
Например вместо того, чтобы передавать классу ссылку на объект класса application, у которого конструктор класса получит ссылку на объект meta::class_repository, стоит передавать в конструктор класса ссылку на meta::class_repository.
При таком подходе в юнит-тестах достаточно создать объект meta::class_repository, а не создавать объект класса application.
#6. Static methods: (or living in a procedural world):
Тут важная мысль у автора:
The key to testing is the presence of seams (places where you can divert the normal execution flow).
Интерфейсы важны. Нет интефейсов — нет возможности тестировать.
Пример.
Мне требовалось написать юнит-тесты для failover сервиса. Он завязан на библиотечный класс zookeeper::config_service в своей работе. "Швов" не было у zookeeper::config_service. Попросил разработчика zookeeper::config_service добавить интерфейс zookeeper::config_service_i и добавить наследование zookeeper::config_service от zookeeper::config_service_i.
Если бы не было возможности добавить интерфейс так просто, то использовал бы прокси объект и интерфейс для прокси-объекта.
#7. Favor composition over inheritance
Наследование склеивает классы и делает сложным юнит-тестирование отдельного класса. Так что лучше без наследования.
Однако иногда без наследования не обойтись. Например:
class amqp_service : public AMQP::service_interface {
public:
uint32_t on_message(AMQP::session::ptr, const AMQP::basic_deliver&,
const AMQP::content_header&, dtl::buffer&,
AMQP::async_ack::ptr) override;
};
Это пример, когда метод on_message требуется определять в дочернем классе и без наследования от класса AMQP::service_interface не обойтись. В таком случае я не добавляю сложные алгоритмы в amqp_service::on_message(). В вызове amqp_service::on_message() я делаю сразу вызов input_handlers::add_message(). Таким образом логика работы по обработке AMQP сообщения переносится в input_handlers, который уже написан правильно с точки зрения юнит-тестирования и который я могу полностью протестировать.
#9. Mixing Service Objects with Value Objects
Важная идея. Классы сервисных объектов сложны и их объекты создаются в фабриках.
С точки зрения трудозатрат одновременная разработка кода и юнит-тестов заметно увеличивает время разработки. Вот примерно такие есть варианты:
1) Если просто покрывать основные сценарии.
2) Если дополнительно покрывать "dark corners", которые видны только по coverage отчету и которые обычно тестировщик просто может не проверять и, как следствие, не тратить на это время.
3) Если добавлять юнит-тесты для негативных, редких или сложных сценариев. Например, ЮТ для проверки изменения количества воркеров в конфигурации на ходу при пустой и непустой очереди на обработку.
4) Если код был не testable, а задача доработать с добавление фичи и юнит-тестов, что потребует рефакторинг.
Не буду давать точных оценок, но мое впечатление, что если юнит-тестирование выполнять не только для основного сценария, а с учетом пунктов 2 и 3, то время разработки вырастает на 100% по сравнению просто с разработкой без юнит-тестов. Если же код не testable, а в него добавляется фича с юнит-тестами, то рефакторинг такого кода для того, чтобы превратить его в testable увеличивает трудозатраты на 200%.
Дополнительный нюанс по трудозатратам. Если разработчик подходит к написанию ЮТ тщательно и делает все из пунктов 1, 2 и 3, а тимлид считает, что юнит-тесты — это в основном пункт 1, то возможны вопросы,
почему так долго ведется разработка.
Еще есть вопрос по производительности такого testable кода. Один раз я слышал такое мнение, что наследование от интерфейсов и использование виртуальных функций влияет на производительность и поэтому так писать код не стоит. И как раз удачно одна из задач у меня была увеличить производительность обработки AMQP сообщений в 5 раз до 25000 записей в секунду. После выполнения этой задачи я сделал профилирование на Linux работы программы. В топе были pthread_mutex_lock и pthread_mutex_unlock, которые шли из аллокаторов классов. Накладные расходы на вызовы виртуальных функций просто не оказали какого-то заметного влияния. Вывод по производительности у меня получился такой, что использование интерфейсов не оказало влияния на производительность.
В заключение, вот оценки покрытия тестами для некоторых файлов на моем проекте после перехода на разработку с юнит-тестами. Файлы failover_service.cpp, input_handlers.cpp и input_handler.cpp были разработаны именно с использованием "Writing Testable Code" и имеют высокую степень покрытия кода юнит-тестами.
Test: data_provider_coverage
Lines: 1410 10010 14.1 %
Date: 2016-06-28 16:38:35
Functions: 371 1654 22.4 %
Filename / Line Coverage / Functions coverage
amqp_service.cpp 8.0 % 28 / 350 25.6 % 10 / 39
config_service.cpp 1.5 % 7 / 460 6.3 % 4 / 63
event_controller.cpp 0.3 % 1 / 380 3.6 % 2 / 55
failover_service.cpp 81.8 % 323 / 395 66.7 % 34 / 51
file_service.cpp 31.5 % 40 / 127 52.6 % 10 / 19
http_service.cpp 0.7 % 1 / 152 10.5 % 2 / 19
input_handler.cpp 73.0 % 292 / 400 95.7 % 22 / 23
input_handler_common.cpp 16.4 % 12 / 73 20.8 % 5 / 24
input_handler_worker.cpp 0.3 % 1 / 391 5.9 % 2 / 34
input_handlers.cpp 98.6 % 217 / 220 100.0 % 26 / 26
input_message.cpp 86.6 % 110 / 127 90.3 % 28 / 31
schedule_service.cpp 0.2 % 3 / 1473 1.6 % 2 / 125
telnet_service.cpp 0.4 % 1 / 280 7.7 % 2 / 26
Дополнение
Построение отчета я делаю так:
# делаю в каталоге coverage сборки
COV_DIR=./tmp.coverage
mkdir -p $COV_DIR
mkdir -p ./coverage.report
find $COV_DIR -mindepth 1 -maxdepth 1 -exec rm -fr {} \;
find . -name "*.gcda" -exec cp "{}" $COV_DIR/ \;
find . -name "*.gcno" -exec cp "{}" $COV_DIR/ \;
lcov --directory $COV_DIR --base-directory ./ --capture --output-file $COV_DIR/coverage.info
lcov --remove $COV_DIR/coverage.info "/usr*" -o $COV_DIR/coverage.info
lcov --remove $COV_DIR/coverage.info "*gtest*" -o $COV_DIR/coverage.info
lcov --remove $COV_DIR/coverage.info "**unittest*" -o $COV_DIR/coverage.info
genhtml -o coverage.report -t "my_project_coverage" --num-spaces 4 $COV_DIR/coverage.info
gnome-open coverage.report/src/index.html
Комментарии (25)
royal_cookie
30.06.2016 23:52В чём проводилась оценка покрываемости кода? Я использую lconv, но мне кажется, что он не настолько информативный вывод даёт, как ваш
sergei_kurenkov
01.07.2016 12:15lcov тоже. А приведен вывод genhtml. Я добавлю команды построения отчета в статью
oYASo
01.07.2016 01:11На самом деле половина советов сводится к тому, что «используйте dependency injection». Собственно, так оно и есть, потому что DI позволяет избавится от лишней связанности классов и четко контролировать создание объекто, в нужный момент заменяя все это моками. Благо, на плюсах теперь есть из чего выбирать в плане DI (я, например, использую Hypodermic C++, но есть и куча другого).
dmbreaker
01.07.2016 09:47Тут еще один момент есть. Время кодинга хоть и возрастает, но, по моему ощущению, время разработки таки сокращается (это при TDD).
Т.е. необходимость отладки практически пропадает. Но это только при TDD, при просто UnitTesting эффект существенно слабее.Ryppka
01.07.2016 12:46ИМХО, тут все просто. Если можно применить TDD — есть ясное понимание задачи. Ну, более ясное, чем в ситуации, когда TDD применить трудно. Вы удивлены, что при лучшем понимании задачи разработка идет быстрее?
dmbreaker
02.07.2016 17:09Скорее наоборот — нет ясного понимания задачи, тогда TDD еще удобнее, т.к. дает возможность легче вносить изменения и архитектура выстраивается почти сама из имеющихся требований.
DistortNeo
01.07.2016 11:15К сожалению, за надёжность разработки приходится расплачиваться тем, что код становится менее удобным для изучения, когда хочется посмотреть не что функция делает, а как:
1. Увеличивается количество сущностей: вместо прямого вызова new конкретного класса вызывается абстрактная фабрика, возвращающая абстрактный класс: +2 интерфейса, +1 класс.
2. Усложняется навигация по коду: перейти по определению класса становится невозможно.
3. Может упасть производительность из-за виртуальных вызовов в вычилистельных задачах.
Ну и общие соображения:
4. В C++ нет интерфейсов. Их можно пытаться эмулировать абстрактными классами и множественным наследованием, но получить тот же функционал, что в C# и Java (композиция интерфейсов), все равно не получится.DarkEld3r
01.07.2016 14:21-1но получить тот же функционал, что в C# и Java (композиция интерфейсов), все равно не получится
Почему?
DistortNeo
01.07.2016 14:37+2Потому что для получения аналогичного функционала придётся познать все прелести множественного наследования (причём «интерфейсы» — ещё с виртуальным наследованием), за которое в приличном обществе бьют палкой по рукам.
DarkEld3r
01.07.2016 14:48А можно минимальный пример? A то я что-то не соображу где проблема будет. В смысле, где вылезет наследование от одного интерфейса несколько раз.
DistortNeo
01.07.2016 15:15+3Оно вылезет, если один интерфейс наследуется от другого или нескольких.
Например, IList, который наследуется от ICollection и IEnumerable.
Пример C#:
interface IA {}
interface IB {}
interface IC {}
interface IAB: IA, IB {}
class CA: IA {}
class CB: CA, IAB, IC {}
Аналогичный код на C++ будет выглядеть так:
class IA { public: virtual ~IA() {} };
class IB { public: virtual ~IB() {} };
class IC { public: virtual ~IC() {} };
class IAB: virtual public IA, virtual public IB { public: virtual ~IAB() {} };
class CA: virtual public IA {};
class CB: public CA, virtual public IAB, virtual public IC {};
При этом на 64-битной аритектуре объект C# будет занимать 24 байта вне зависимости от числа интерфейсов, тогда как C++ — 40 байт, и каждый последующий интерфейс будет добавлять ещё по 8 байт.
Unrul
06.07.2016 13:52Виртуальное наследование может быть опасно только если в базовых классах имеются какие-либо данные. Если интерфейсные классы не содержат данных, то никаких проблем возникнуть не должно, можно виртуальное наследование вообще не использовать.
DistortNeo
06.07.2016 14:12Виртуальное наследование нужно использовать для возможности множественного включения одного и того же интерфейса (см. мой пример выше).
Unrul
06.07.2016 14:30Можно и не использоватьstruct I0 { virtual ~I0() = default; virtual void base() = 0; }; struct I1 : I0 { virtual ~I1() = default; virtual void foo() = 0; }; struct I2 : I0 { virtual ~I2() = default; virtual void bar() = 0; }; struct I12 : I1, I2, I0 { ~I12() override { printf("~I12\n"); } void base() override { printf("base\n"); } void foo() override { printf("foo\n"); } void bar() override { printf("bar\n"); } }; int main() { I0* i0 = new I12; i0->base(); delete i0; printf("\n"); I1* i1 = new I12; i1->base(); i1->foo(); delete i1; printf("\n"); I2* i2 = new I12; i2->base(); i2->bar(); delete i2; printf("\n"); printf("Size0: %llu\n", sizeof(I0)); printf("Size1: %llu\n", sizeof(I1)); printf("Size2: %llu\n", sizeof(I2)); printf("Size12: %llu\n", sizeof(I12)); return 0; }
DistortNeo
06.07.2016 14:55И получаем вполне закономерную ошибку:
error: 'I0' is an ambiguous base of 'I12'
Unrul
06.07.2016 15:33Вышеприведённый вариант работает в VS2015. Для переносимости можно сделать так:
struct I12 : I1, I2 { ... }; int main() { I0* i0 = static_cast<I1*>(new I12); ...
DistortNeo
06.07.2016 16:02Да, при миграции проекта с VS2015 на g++ я с кучей проблем, связанных с нестрогим пониманием стандарта, связывался.
В любом случае, пример не будет работать, если убрать I0 из базовых классов для I12 (т.е. сделать как цитируемом сообщении). Ну а static_cast — немного некрасивое решение.
А вот на такое и VS2015 ругнётсяstruct I0 { virtual ~I0() {}; virtual void base() = 0; }; struct I1 : I0 { virtual ~I1() {}; virtual void foo() = 0; }; struct I2 : I1, I0 { virtual ~I2() {}; virtual void bar() = 0; }; struct I12 : I1, I2, I0 { ~I12() override { printf("~I12\n"); } void base() override { printf("base\n"); } void foo() override { printf("foo\n"); } void bar() override { printf("bar\n"); } };
dmbreaker
02.07.2016 17:11Вот не соглашусь. Когда я вижу код, который был написан без тестов, то обычно это пара экранов текста, в котором черт ногу сломит, а комментарии либо устарели, либо не родились.
Когда же у меня код с тестами — я просто смотрю в тесты и сразу ясно, что ожидается от кода, что он может и должен, а что нет.DistortNeo
03.07.2016 00:44Моя мысль находится немного в стороне.
Я согласен с тем, что изучать код по тестам даже проще, согласен с тем, что слабая связанность — благо для эффективного написания и поддержки кода.
Я лишь хотел указать на то, что слабая связанность в виде использования интерфейсов замедляет навигацию по коду, иногда существенно.
VioletGiraffe
Хорошие советы по написанию хорошего кода, по большому счёту. По-моему, testability и maintainability сильно коррелируют.