[Из песочницы] Перевод С++ проекта на разработку с юнит-тестированием/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 сильно коррелируют.

© Habrahabr.ru