Перевод С++ проекта на разработку с юнит-тестированием/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<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

Дополнение №2


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


using time_function_t = std::function<time_t(time_t*)>;

class service {
 public:
  service(time_function_t = &time);
};

А в юнит тесте используется уже другая функция получения времени. Например вот функция времени, которая позволяет перейти на следующую минуту выполнением ++minute_passed:


    std::atomic_int minute_passed{0};
    time_t start_ts = time(nullptr);

    time_function = [&](time_t*) {
      auto current_ts = time(nullptr);
      auto diff_ts = current_ts - start_ts;
      return start_minute_ts + 60 * minute_passed + diff_ts;
    };

    service test_srv(time_function);
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 25

    +4
    Хорошие советы по написанию хорошего кода, по большому счёту. По-моему, testability и maintainability сильно коррелируют.
      0
      В чём проводилась оценка покрываемости кода? Я использую lconv, но мне кажется, что он не настолько информативный вывод даёт, как ваш
        0
        Я предполагаю, что gcov + lcov, получается тоже самое.
          0
          lcov тоже. А приведен вывод genhtml. Я добавлю команды построения отчета в статью
            0
            Огромное спасибо!
          0
          На самом деле половина советов сводится к тому, что «используйте dependency injection». Собственно, так оно и есть, потому что DI позволяет избавится от лишней связанности классов и четко контролировать создание объекто, в нужный момент заменяя все это моками. Благо, на плюсах теперь есть из чего выбирать в плане DI (я, например, использую Hypodermic C++, но есть и куча другого).
            0
            Тут еще один момент есть. Время кодинга хоть и возрастает, но, по моему ощущению, время разработки таки сокращается (это при TDD).
            Т.е. необходимость отладки практически пропадает. Но это только при TDD, при просто UnitTesting эффект существенно слабее.
              0
              ИМХО, тут все просто. Если можно применить TDD — есть ясное понимание задачи. Ну, более ясное, чем в ситуации, когда TDD применить трудно. Вы удивлены, что при лучшем понимании задачи разработка идет быстрее?
                0
                Скорее наоборот — нет ясного понимания задачи, тогда TDD еще удобнее, т.к. дает возможность легче вносить изменения и архитектура выстраивается почти сама из имеющихся требований.
                  0
                  И как же Вы собираетесь писать тесты, если не знаете, что будете тестировать?
              0
              К сожалению, за надёжность разработки приходится расплачиваться тем, что код становится менее удобным для изучения, когда хочется посмотреть не что функция делает, а как:

              1. Увеличивается количество сущностей: вместо прямого вызова new конкретного класса вызывается абстрактная фабрика, возвращающая абстрактный класс: +2 интерфейса, +1 класс.

              2. Усложняется навигация по коду: перейти по определению класса становится невозможно.

              3. Может упасть производительность из-за виртуальных вызовов в вычилистельных задачах.

              Ну и общие соображения:

              4. В C++ нет интерфейсов. Их можно пытаться эмулировать абстрактными классами и множественным наследованием, но получить тот же функционал, что в C# и Java (композиция интерфейсов), все равно не получится.
                –1
                но получить тот же функционал, что в C# и Java (композиция интерфейсов), все равно не получится

                Почему?

                  +2
                  Потому что для получения аналогичного функционала придётся познать все прелести множественного наследования (причём «интерфейсы» — ещё с виртуальным наследованием), за которое в приличном обществе бьют палкой по рукам.
                    0

                    А можно минимальный пример? A то я что-то не соображу где проблема будет. В смысле, где вылезет наследование от одного интерфейса несколько раз.

                      +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 байт.
                      0
                      Виртуальное наследование может быть опасно только если в базовых классах имеются какие-либо данные. Если интерфейсные классы не содержат данных, то никаких проблем возникнуть не должно, можно виртуальное наследование вообще не использовать.
                        0
                        Виртуальное наследование нужно использовать для возможности множественного включения одного и того же интерфейса (см. мой пример выше).
                          0
                          Можно и не использовать
                          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;
                          }
                          

                            0
                            И получаем вполне закономерную ошибку:

                            error: 'I0' is an ambiguous base of 'I12'
                              0
                              Вышеприведённый вариант работает в VS2015. Для переносимости можно сделать так:
                              struct I12 : I1, I2 {
                              ...
                              };
                              
                              int main() {
                                  I0* i0 = static_cast<I1*>(new I12);
                              ...
                              
                                0
                                Да, при миграции проекта с 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"); }
                                };
                                


                          0
                          Если интерфейсные классы не содержат данных
                          Они содержат указатели на таблицу виртуальных функций, пусть эта деталь реализации от нас компилятором и скрывается
                            +1
                            Не содержат изменяемых данных.
                      0
                      Вот не соглашусь. Когда я вижу код, который был написан без тестов, то обычно это пара экранов текста, в котором черт ногу сломит, а комментарии либо устарели, либо не родились.
                      Когда же у меня код с тестами — я просто смотрю в тесты и сразу ясно, что ожидается от кода, что он может и должен, а что нет.
                        0
                        Моя мысль находится немного в стороне.

                        Я согласен с тем, что изучать код по тестам даже проще, согласен с тем, что слабая связанность — благо для эффективного написания и поддержки кода.

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

                    Only users with full accounts can post comments. Log in, please.