Мощный инструмент для работы с GCOV покрытием кода C/C++
Привет, Хабр.
Скорее всего то, о чем я сейчас расскажу, уже было реализовано и не единожды.
Но пусть это все равно лежит здесь, возможно эта статья будет кому-то полезна в качестве методического материала или HOWTO. Все, сказанное ниже является продуктом моего текущего опыта разработки и не претендует на идеальное решение.
Итак, проблема
Каждый, уважающий себя проект, проводит юнит-тестирование (оно же модульное тестирование), а также покрытие кода. В данной статье я рассчитываю, что читатель знаком со словами «юнит-тестирование» и «покрытие кода» и не буду разбирать эти понятия в рамках данной статьи. Для последнего, я использую программу gcov из семейства GCC.
GCC — это GNU Compilers Collection, набор компиляторов проекта GNU. Прежде всего это компиляторы C/C++ (gcc и g++). GCC входит в тройку лидирующих C/C++ компиляторов: GCC, Clang, MSVC.
В качестве библиотеки для проведения юнит-тестирования в данной статье я буду использовать check (который -lcheck -lsubunit), потому как и сам код проекта (совершенно открытого и академического, ссылка будет внизу), на базе которого я буду приводить примеры, написан на языке C.
Но с тем же успехом вы можете использовать вашу любимую библиотеку, который вы обычно используете, например Google C++ Testing Framework (который -lgtest -lgmock) для C++.
Те, кто применяет line-based и branch-based покрытие, знает как тяжело поднять процент покрытия на этих самых branch’ах (далее — ветках). Особенно, это касается файловых дескрипторов (об этом возможно, когда-нибудь выйдет отдельная статья) и динамического выделения памяти. Бывает, уже перебрал в тестах все доступные тебе «плохие» варианты, но зеленой зоны покрытия (90%) так и не достиг. Хочу привести пример кода такой функции:
Line-based покрытие в зеленой зоне (91.3%), а branch-based — как всегда нет (53.8%). Давайте рассмотрим, что здесь происходит.
Функция динамически выделяет в памяти структуру и матрицу в ней. Код построен блоками так, что каждый последующий блок не будет выполняться, если работа предыдущего блока завершилась с ошибкой. Первый блок (в строке 22) запрашивает память размера структуры, второй (в строке 28) — массива указателей на строки матрицы и третий (в строке 36) — пытается аллоцировать сами строки матрицы. Беда в том, что у них это всегда получается.
Как видно из строки 15, ветки плохих аргументов мы проверили.
Ветки строки 42 не реализованы, потому что не наступает такой ситуации, когда результирующий указатель уже получил адрес, но при этом возникла бы ошибка в дальнейшей работе функции. Другими словами, чтобы первый блок отработал успешно, а ошибки возникли в следующих.
И даже если бы мы нашли способ (а такие способы есть) запретить выделение памяти при работе теста вообще — это не принесло бы нам сколько-нибудь значительного результата. Было бы примерно так:
Лучше. Но недостаточно лучше. Покрытие ветвей выросло до 76.9%. Что именно произошло?
Тест не смог выделить память в первом же блоке и поднял покрытие ветки в строках 23–24. Также, случилось нужное нам ветвление в строках 26, 33 и 42, где следующие блоки поняли, что то-то идет не так. В остальном все осталось так же. Блок самоочистки так и не наступил, потому как результирующий указатель все еще пуст. Что дальше? Запрашивать какой-то гигантский размер памяти через аргумент size? Возможно у нас и получится удивить систему, запрашивая матрицу размером 65535×65535 (максимальное число, которое влезет в аргумент, который uint16_t). Но давайте поищем способ, дающий более точный результат, чем «возможно».
Решение
Нам нужен способ ограничивать выделение памяти, причем:
Не во время компиляции, а «на ходу».
Точечно, в тех блоках, которые мы хотим протестировать.
Как нам это сделать? А вот как:
Мы напишем свои, кастомные функции-обертки над функциями malloc () и calloc () из стандартной библиотеки:
#include
void *malloc(size_t __size) {
void *result = NULL;
void *(*libc_malloc)(size_t) = NULL;
*(void **)(&libc_malloc) = dlsym(RTLD_NEXT, "malloc");
if (!memory_locked(__size, 0)) result = libc_malloc(__size);
return result;
}
void *calloc(size_t __nmemb, size_t __size) {
void *result = NULL;
void *(*libc_calloc)(size_t, size_t) = NULL;
*(void **)(&libc_calloc) = dlsym(RTLD_NEXT, "calloc");
if (!memory_locked(__size, 0)) result = libc_calloc(__nmemb, __size);
return result;
}
Что здесь происходит? Мы взяли библиотеку для работы с динамически подключаемыми библиотеками (простите за тавтологию) dlfcn (которая -ldl) и используем её функцию dlsym () для получения адреса оригинальной функции в стандартной библиотеке libc.
После этого мы используем некоторый хедтрик, для того чтобы превратить полученный адрес в адрес функции и можно приступать к главному — написанию функции-дистрибьютора, которая будет поставлять нам решение, можно ли пустить запрос на выделение памяти, пришедший из нашего теста дальше, к оригинальной функции стандартной библиотеки. Вот она:
int memory_locked(size_t size, int locked) {
static size_t value = 0;
int result = 0;
if (locked == 1) value = size;
if (locked == -1) value = 0;
if (locked == 0 && value == size) result = 1;
return result;
}
На всякий случай, проговорю: Функция хранит размер в байтах, который выдавать нельзя.
Функция сохранит значение size если ключ locked == 1(установка значения)
Забудет значение, если ключ == -1(удаление значения)
Вернёт сигнал 1 если запрошенное и сохраненное совпадают, а ключ не установлен (0)
Как это применить? Вот так:
START_TEST(suite_figure_create_test1) {
figure_t *figure = NULL, *check = NULL;
memory_locked(sizeof(figure_t), 1);
figure = figure_create(10);
memory_locked(0, -1);
check = figure;
figure_destroy(figure);
ck_assert_ptr_null(check);
}
END_TEST
START_TEST(suite_figure_create_test2) {
figure_t *figure = NULL, *check = NULL;
memory_locked(sizeof(int*), 1);
figure = figure_create(10);
memory_locked(0, -1);
check = figure;
figure_destroy(figure);
ck_assert_ptr_null(check);
}
END_TEST
START_TEST(suite_figure_create_test3) {
figure_t *figure = NULL, *check = NULL;
memory_locked(sizeof(int), 1);
figure = figure_create(10);
memory_locked(0, -1);
check = figure;
figure_destroy(figure);
ck_assert_ptr_null(check);
}
END_TEST
Рассмотрим: В первом тесте мы запрещаем выделение памяти размером с sizeof (figure_t), в втором тесте мы запрещаем выделение памяти размера sizeof (int*) и в третьем — размера самого int — sizeof (int). И наконец, давайте проверим, к чему это нас приведёт.
Результат
100%. И это только и исключительно благодаря работе с памятью. Все же, хочу обратить ваше внимание на несколько деталей:
При работе в таком манере с размерностью int’а будьте готовы, что у вас появится целая гора Sega Mega Drive неожиданных ошибок при работе с памятью, благо вы точно знаете где вы это включили. Вам понадобятся функции, очень качественно обращающиеся с памятью (как например мой деструктор figure_destroy ())
Под valgrind’ом эта техника работать не будет, так как он сам транслирует вашу программу тестов, а у него системные функции не переопределены и работают оригинальные. То есть, утечки на тестах отладить вы сможете, но результаты выполнения самих тестов под valgrind’ом будут отличаться.
В завершении статьи — бонус. Вот еще пара функций, которая мне также неоднократно пригождалась, для неё я рекомендую завести дистрибьютор с другим именем, чтобы не пересекались с выделением памяти (в моем случае — memory_locked_mem ()).
void *memset(void *__s, int __c, size_t __n) {
void *result = NULL;
void *(*libc_memset)(void *, int, size_t) = NULL;
*(void **)(&libc_memset) = dlsym(RTLD_NEXT, "memset");
if (!memory_locked_mem(__n, 0)) result = libc_memset(__s, __c, __n);
return result;
}
void *memcpy(void *__dest, const void *__src, size_t __n) {
void *result = NULL;
void *(*libc_memcpy)(void*, const void*, size_t) = NULL;
*(void **)(&libc_memcpy) = dlsym(RTLD_NEXT, "memcpy");
if (!memory_locked_mem(__n, 0)) result = libc_memcpy(__dest, __src, __n);
return result;
}
Ссылка на репозиторий проекта