[Из песочницы] Перевод С++ проекта на разработку с юнит-тестированием/TDD
Полгода назад на моем проекте было примерно около 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)
};
// файл с юнит-тестами
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
Комментарии (1)
30 июня 2016 в 16:27
+2↑
↓
Хорошие советы по написанию хорошего кода, по большому счёту. По-моему, testability и maintainability сильно коррелируют.