Translation of a C ++ project to development with unit testing / TDD
Six months ago, on my project, there was about 0% code coverage with unit tests. There were not enough simple classes, creating unit tests for them was easy, but it was relatively useless, since in fact the important algorithms were in complex classes. And complex, from the point of view of behavior, classes were difficult to unit test since such classes were tied to other complex classes and configuration classes. It was impossible to create an object of a complex class and even more so to test it with unit tests.
Some time ago I read the "Writing Testable Code" on the Google Testing Blog .
The key idea in the article is that C ++ code suitable for unit testing is not written at all like the usual C ++ code.
Before that, I had the impression that the unit testing framework is most important for writing unit tests. But everything turned out to be wrong. The role of the framework is secondary; first of all, it is required to write code that is suitable for unit testing. The author uses the term "testable code" for this. Or, as it seems to me more accurate, "unit-testable code". Then everything is quite simple. For testable code, you can immediately write UT and then there will be Test Driven Development (TDD), you can later, the code still allows it. I write tests right away with the code, and then I look at the coverage report for forgotten and missing places in the code and supplement the tests.
In his article, the author gives several principles. I will note and comment on the most important, from my point of view.
#1. Mixing object graph construction with application logic:
Absolutely important principle. In fact, any complex class usually creates several classes of other objects within itself. For example, in the constructor or during configuration processing.
The usual approach is to use new directly in the class code. This is completely wrong for unit testing. If you create a class this way, you end up with exactly a bunch of stuck class objects that cannot be tested.
The correct approach from the point of view of UT is that if a class needs to create objects, then the class should receive a pointer or a link to the factory class interface.
Example:
// заголовочный файл с интерфесами
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 {
// реализованные функции для создания тестовых объектов
};
I usually return std :: shared_ptr from factory class methods. Thus, directly in unit tests, you can save the
created test objects and check their status. Yet. In a factory, I not only create objects, but also can do delayed initialization of objects.
# 2 Ask for things, Don't look for things (aka Dependency Injection / Law of Demeter):
Objects with which the class interacts must be provided directly to it.
For example, instead of passing the class a reference to the object of the application class, from which the class constructor will receive a reference to the meta :: class_repository object, it is worth passing the link to meta :: class_repository to the class constructor.
With this approach, in unit tests it’s enough to create a meta :: class_repository object, rather than creating an application class object.
# 6 Static methods: (or living in a procedural world):
Here the author has an important idea:
The key to testing is the presence of seams (places where you can divert the normal execution flow).
Interfaces are important. No interfaces - no way to test.
Example.
I needed to write unit tests for the failover service. It is tied to the zookeeper :: config_service library class in its work. Zookeeper :: config_service didn’t have any seams. I asked the developer zookeeper :: config_service to add the interface zookeeper :: config_service_i and add the inheritance zookeeper :: config_service from zookeeper :: config_service_i.
If it were not possible to add an interface so simply, then I would use a proxy object and an interface for a proxy object.
# 7 Favor composition over inheritance
Inheritance glues classes and makes unit testing of a particular class difficult. So it’s better without inheritance.
However, sometimes inheritance is indispensable. For instance:
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;
};
This is an example where the on_message method needs to be defined in a child class and you can’t do without inheriting from the AMQP :: service_interface class. In this case, I do not add complex algorithms to amqp_service :: on_message (). In the call to amqp_service :: on_message (), I immediately make a call to input_handlers :: add_message (). Thus, the logic of processing AMQP messages is transferred to input_handlers, which is already written correctly in terms of unit testing and which I can fully test.
#9. Mixing Service Objects with Value Objects
An important idea. Service object classes are complex and their objects are created in factories.
From the point of view of labor costs, the simultaneous development of code and unit tests significantly increases the development time. Here are some of the options:
1) If you just cover the main scenarios.
2) If you additionally cover "dark corners", which are visible only by the coverage report and which usually the tester may just not check and, as a result, not waste time on this.
3) If you add unit tests for negative, rare or complex scenarios. For example, UT for checking changes in the number of workers in the configuration on the go with an empty and non-empty queue for processing.
4) If the code was not testable, but a task to modify with the addition of features and unit tests, which will require refactoring.
I will not give accurate estimates, but my impression is that if unit testing is performed not only for the main scenario, but taking into account paragraphs 2 and 3, then the development time grows by 100% compared to simply developing without unit tests. If the code is not testable, and a feature with unit tests is added to it, then refactoring such a code in order to turn it into testable increases the labor costs by 200%.
An additional caveat for labor. If a developer approaches writing UT carefully and does all of items 1, 2, and 3, and the team leader thinks that unit tests are basically point 1, then there may be questions about
why development takes so long.
There is still a question about the performance of such testable code. Once I heard the opinion that inheritance from interfaces and the use of virtual functions affect performance and therefore it is not worth writing code like this. And just rightly, one of the tasks I had was to increase the performance of processing AMQP messages by 5 times to 25,000 records per second. After completing this task, I did a profiling on Linux of the program. In the top were pthread_mutex_lock and pthread_mutex_unlock, which came from class allocators. The overhead of virtual function calls simply didn't have any noticeable effect. The conclusion on performance was such that the use of interfaces did not affect the performance.
In conclusion, here are the test coverage estimates for some files on my project after switching to development with unit tests. The files failover_service.cpp, input_handlers.cpp and input_handler.cpp were developed using the "Writing Testable Code" and have a high degree of coverage of the code with unit tests.
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
Addition
I build the report like this:
# делаю в каталоге 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
Supplement No. 2
For unit testing of algorithms that must perform a certain action, for example, every minute or every hour, I pass one of the parameters to these algorithms the function of obtaining time:
using time_function_t = std::function;
class service {
public:
service(time_function_t = &time);
};
And in the unit test, another function for obtaining time is already used. For example, here is a time function that allows you to go to the next minute by running ++ 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);