Переклад З++ проекту на розроблення з юніт-тестування/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

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.