Полгода назад на моем проекте было примерно около 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)


  1. VioletGiraffe
    30.06.2016 16:27
    +4

    Хорошие советы по написанию хорошего кода, по большому счёту. По-моему, testability и maintainability сильно коррелируют.


  1. royal_cookie
    30.06.2016 23:52

    В чём проводилась оценка покрываемости кода? Я использую lconv, но мне кажется, что он не настолько информативный вывод даёт, как ваш


    1. oYASo
      01.07.2016 01:05

      Я предполагаю, что gcov + lcov, получается тоже самое.


    1. sergei_kurenkov
      01.07.2016 12:15

      lcov тоже. А приведен вывод genhtml. Я добавлю команды построения отчета в статью


      1. royal_cookie
        01.07.2016 21:27

        Огромное спасибо!


  1. oYASo
    01.07.2016 01:11

    На самом деле половина советов сводится к тому, что «используйте dependency injection». Собственно, так оно и есть, потому что DI позволяет избавится от лишней связанности классов и четко контролировать создание объекто, в нужный момент заменяя все это моками. Благо, на плюсах теперь есть из чего выбирать в плане DI (я, например, использую Hypodermic C++, но есть и куча другого).


  1. dmbreaker
    01.07.2016 09:47

    Тут еще один момент есть. Время кодинга хоть и возрастает, но, по моему ощущению, время разработки таки сокращается (это при TDD).
    Т.е. необходимость отладки практически пропадает. Но это только при TDD, при просто UnitTesting эффект существенно слабее.


    1. Ryppka
      01.07.2016 12:46

      ИМХО, тут все просто. Если можно применить TDD — есть ясное понимание задачи. Ну, более ясное, чем в ситуации, когда TDD применить трудно. Вы удивлены, что при лучшем понимании задачи разработка идет быстрее?


      1. dmbreaker
        02.07.2016 17:09

        Скорее наоборот — нет ясного понимания задачи, тогда TDD еще удобнее, т.к. дает возможность легче вносить изменения и архитектура выстраивается почти сама из имеющихся требований.


        1. Ryppka
          02.07.2016 19:35

          И как же Вы собираетесь писать тесты, если не знаете, что будете тестировать?


  1. DistortNeo
    01.07.2016 11:15

    К сожалению, за надёжность разработки приходится расплачиваться тем, что код становится менее удобным для изучения, когда хочется посмотреть не что функция делает, а как:

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

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

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

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

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


    1. DarkEld3r
      01.07.2016 14:21
      -1

      но получить тот же функционал, что в C# и Java (композиция интерфейсов), все равно не получится

      Почему?


      1. DistortNeo
        01.07.2016 14:37
        +2

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


        1. DarkEld3r
          01.07.2016 14:48

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


          1. 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 байт.


        1. Unrul
          06.07.2016 13:52

          Виртуальное наследование может быть опасно только если в базовых классах имеются какие-либо данные. Если интерфейсные классы не содержат данных, то никаких проблем возникнуть не должно, можно виртуальное наследование вообще не использовать.


          1. DistortNeo
            06.07.2016 14:12

            Виртуальное наследование нужно использовать для возможности множественного включения одного и того же интерфейса (см. мой пример выше).


            1. 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;
              }
              


              1. DistortNeo
                06.07.2016 14:55

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

                error: 'I0' is an ambiguous base of 'I12'


                1. Unrul
                  06.07.2016 15:33

                  Вышеприведённый вариант работает в VS2015. Для переносимости можно сделать так:

                  struct I12 : I1, I2 {
                  ...
                  };
                  
                  int main() {
                      I0* i0 = static_cast<I1*>(new I12);
                  ...
                  


                  1. 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"); }
                    };
                    


          1. lemelisk
            09.07.2016 23:10

            Если интерфейсные классы не содержат данных
            Они содержат указатели на таблицу виртуальных функций, пусть эта деталь реализации от нас компилятором и скрывается


            1. Unrul
              09.07.2016 23:41
              +1

              Не содержат изменяемых данных.


    1. dmbreaker
      02.07.2016 17:11

      Вот не соглашусь. Когда я вижу код, который был написан без тестов, то обычно это пара экранов текста, в котором черт ногу сломит, а комментарии либо устарели, либо не родились.
      Когда же у меня код с тестами — я просто смотрю в тесты и сразу ясно, что ожидается от кода, что он может и должен, а что нет.


      1. DistortNeo
        03.07.2016 00:44

        Моя мысль находится немного в стороне.

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

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