Найти в Дзене

CMake от начинающего до опытного. Часть 6 - Собираем отчеты о покрытии кода тестами.

В одной из предыдущих статей я начал тему тестирования. Сегодня продолжим развивать ее и посмотрим как тестирование помогает нам в процессе разработки. Как известно, написание исходного кода программы - это только пол дела. Кроме этого необходимо убедиться в том что созданный код работает правильно, а также в том, что изменения, внесенные в программу, не сломали какую-то часть ее функционала. Для этого код снабжается тестами, которые должны регулярно выполняться, например в автоматическом режиме в процессе непрерывной интеграции (continous integration). В результате получается отчет, который показывает, какая часть исходного кода покрыта тестами. Далее этот отчёт анализируется с целью выявить участки кода(их можно назвать непокрытыми), которые не выполнялись в процессе работы ни одного из тестов. После этого тестовый набор обновляется добавлением тестов для непокрытых участков кода. Цель состоит в том, чтобы получить набор тестов, проверяющих большую часть кода (в идеале весь). Дол

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

Для этого код снабжается тестами, которые должны регулярно выполняться, например в автоматическом режиме в процессе непрерывной интеграции (continous integration). В результате получается отчет, который показывает, какая часть исходного кода покрыта тестами. Далее этот отчёт анализируется с целью выявить участки кода(их можно назвать непокрытыми), которые не выполнялись в процессе работы ни одного из тестов. После этого тестовый набор обновляется добавлением тестов для непокрытых участков кода. Цель состоит в том, чтобы получить набор тестов, проверяющих большую часть кода (в идеале весь).

Доля покрытия кода тестами выражается в процентах. Существует несколько различных способов измерения покрытия, основные из них:

  • покрытие операторов — каждая ли строка исходного кода была выполнена и протестирована;
  • покрытие условий — каждая ли точка решения (вычисления истинно ли или ложно выражение) была выполнена и протестирована;
  • покрытие путей — все ли возможные пути через заданную часть кода были выполнены и протестированы;
  • покрытие функций — каждая ли функция программы была выполнена;
  • покрытие вход/выход — все ли вызовы функций и возвраты из них были выполнены;
  • покрытие значений параметров — все ли типовые и граничные значения параметров были проверены.

Некоторые из приведённых критериев связаны между собой. Например, покрытие путей включает в себя и покрытие условий, и покрытие операторов. Покрытие операторов не включает покрытие условий, как показывает этот фрагмент программы

std::cout << "this is";
if (value <= 0)
{
std::cout <<" not ";
}
std::cout << "a positive number";

Если value = −1, то покрытие операторов будет полным, а покрытие условий — нет, так как случай несоблюдения условия в операторе if не покрыт. Полное покрытие путей обычно невозможно так как фрагмент кода, имеющий n условий, содержит 2 в степени n путей.

Некоторые пути в программе могут быть не достигнуты из-за того, что в тестовых данных отсутствовали такие, которые могли привести к выполнению этих путей.

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

Источники информации о поведении программы:

  • Исходный код программы (покрытие кода). В качестве источника используется исходный код самой программы. Такое тестирование называется тестированием методом белого ящика, для создания набора тестов используется знание внутреннего устройства программы.
  • Требования (покрытие требований). Источник — требования к программе. Основанием разделения тестов на классы относительно проверки ими определенных требований к программе является предположение о том, что ошибка в реализации требования проявляется при любой проверке этого требования (метод черного ящика).

Покр́ытие тр́ебований — метрика, используемая в тестировании программного обеспечения. Покрытие требований позволяет оценить степень полноты системы тестов по отношению к функциональности системы. В сравнении с покрытием кода, покрытие требований позволяет выявить нереализованные требования, но не позволяет оценить полноту по отношению к её программной реализации. Одна и та же функция может быть реализована при помощи совершенно различных алгоритмов, требующих разного подхода к организации тестирования.

Переходя от теории к практике, рассмотрим процесс получения отчета о покрытии. Как говорилось в первой статье данной серии, CMake только управляет процессом сборки и тестирования программы, делегируя отдельные действия этого процесса соответствующим инструментам.

Первым инструментом является компилятор. Если открыть документацию к GCC, то можно увидеть что для получения информации о покрытии в процессе выполнения программы, необходимо указать опцию --coverage. Рассмотрим процесс на примере компиляции простой программы main.cpp без использования CMake.

main.cpp

#include <iostream>

#include <exception>

float division(int a, int b) {
if(!b) {
throw std::logic_error("divisor is equal to zero!");
}
return static_cast<float>(a) / b;
}

int main() {

try {

int a = 1;

int b = 2;

std::cout << a << " / " << b << "=" <<division(a, b) << std::endl;

catch(std::exception& ex)

{ std::cout << ex.what() << std::endl; }

return 0;

}

Откомпилируем программу в ОС Ubuntu, введя команду: g++ --coverage main.cpp -o main

Здесь g++ - компилятор языка С++, --coverage - опция получения покрытия, -o main - указание компилятору создать выходной исполняемый файл с названием main. В результате выполнения команды в директории с main.cpp появятся дополнительные файлы - main, содержащий собранную программу, и main.gcno, содержащий базовую информацию для покрытия кода. Теперь надо запустить программу, чтобы в процессе работы она записала информацию о фактическом покрытии кода. В Ubuntu введем команду ./main. После завершения работы программы увидим, что появился файл main.gcda, который и содержит информацию о фактическом покрытии кода.

Для получения информации о покрытии нам нужны утилиты gcov, lcov и genhtml. Gcov — свободно распространяемая утилита для исследования покрытия кода. Gcov генерирует точное количество выполнений для каждого оператора в программе и позволяет добавить аннотации к исходному коду. Gcov поставляется как стандартная утилита, которая также может работать и с Clang. Gcov предоставляет информацию о том, сколько раз исполнился во время работы программы каждый участок кода. Аннотированный исходный код сохраняется в новом файле, содержащим счетчики исполнения и текст программы.

lcov - утилита для сбора данных о покрытии. Запустим ее при помощи следующей команды:

lcov -t "main" -o main.info -c -d .

Расшифруем ключи командной строки:

  • -t "main" устанавливает название отчёта при измерении покрытия кода тестами;
  • -o main.info устанавливает имя выходного файла с промежуточной информацией;
  • -c указывает, что lcov должен использовать существующие данные о coverage;
  • -d <путь> устанавливает каталог, в котором надо искать данные о покрытии. В данном примере используется текущий каталог ( символ “.”).

В результате работы lcov создается файл main.info. Теперь можно создать отчёт о покрытии в виде HTML-документа. Для этого используется утилита genhtml, входящая в состав пакета программ lcov. Для этого введем команду:

genhtml -o report main.info

Ключ -o report задает название директории, в которую будут записаны данные о покрытии. В директории report ищем файл index.html, содержащий отчет о покрытии. Он имеет следующий вид:

-2

В шапке представлена сводка с краткой информацией о покрытии. Были выполнены 8 строк(Hit) из 12 (Total). Процент покрытия строк - 66.7. Также были вызваны 2 функции (main, division). Процент покрытия функций - 100. Для того чтобы узнать какие строки не были покрыты перейдем по гиперссылке к исходному коду файла main.cpp (колонка Filename). В результате увидим следующий отчет.

-3

Как и ожидалось, логика, связанная с исключениями, не отработала. В столбце Line data напротив соответствующих строк стоит цифра 0, а строки, для наглядности, окрашены в оранжевый цвет. Для того, чтобы добиться 100% покрытия строк, добавим в блок try вызов division с делением на 0.

b = 0;

std::cout << a << " / " << b << "=" <<division(a, b) << std::endl;

После этого удалим папку report и все созданные артефакты (иначе возможны сообщения об ошибках) и выполним построение проекта и сбор статистики заново. Теперь покрытие будет полным. Несмотря на то, что про тестирование пока не было сказано ни слова, общий алгоритм покрытия кода тестами, основанный на методе белого ящика(исходный код доступен для анализа) надеюсь, стал понятен.

Задаем целевой процент тестового покрытия (обычно от 80% до 100% ). Создаем наборы тестовых данных, которые при попадании на вход тестируемых методов выполняют логику программы, заходят в ветки if/else, обработчики исключений и т.д. После написания тестов осуществляем их прогон и анализируется отчет о покрытии. В случае если он меньше целевого добавляем новые тесты и повторяем процесс до тех пор пока не достигнем заданного процента покрытия.

Перейдем к рассмотрению вопросов получения покрытия с использованием CMake. Для этого необходимо сделать следующее:

  • Скомпилировать в конфигурации Debug с флагами компилятора, включающими покрытие кода. Сгенерировать файлы примечаний покрытия (.gcno);
  • Собрать метрики покрытия без запуска каких-либо тестов;
  • Запустить тесты для создания файлов данных покрытия (.gcda);
  • Собрать метрики в агрегированный информационный файл (.info) ;
  • Создать отчет в виде HTML файлов.

Код должен быть скомпилирован в конфигурации Debug потому что, как правило, конфигурации Debug отключают любую оптимизацию с помощью флага -O0. Это необходимо для предотвращения компиляторных оптимизаций. В противном случае будет сложно отследить, какая машинная инструкция пришла из какой строки исходного кода. Далее, как и в примере без использования CMake, нужно использовать флаг --coverage для включения инструментария сборки покрытия, встроенного в компилятор. Для получения покрытия возьмем проект с библиотекой Goolge Test, рассмотренный в одной из предыдущих статей. Чтобы не приводить здесь весь код скажу, что его можно будет посмотреть по ссылке в конце статьи. Здесь рассмотрим только основные отличия.

Файл CMakeLists.txt для библиотеки arithmetic будет иметь следующий вид:

add_library(arithmetic SHARED arithmetic.cpp run.cpp)

target_include_directories(arithmetic PUBLIC .)

if (CMAKE_BUILD_TYPE STREQUAL Debug)

target_compile_options(arithmetic PRIVATE --coverage)

target_link_options(arithmetic PUBLIC --coverage)

add_custom_command(TARGET arithmetic PRE_BUILD COMMAND

find ${CMAKE_BINARY_DIR} -type f

-name '*.gcda' -exec rm {} +)

endif()

Необходимо отметить, что в отличие от проекта, описанного в предыдущей статье, я добавил тестирование функции main, в которой происходит вызов всех функций калькулятора. Для этого весь функционал из main вынесен в функцию run, которая находится в отдельном файле run.cpp.

run.cpp

#include <iostream>

#include "arithmetic.h"

#include "../logic/logic.h"

int run() {
int a = 1;
int b = 2;
std::cout << "a and b:" << bitwise_and(a, b) << std::endl;
std::cout << "a or b:" << bitwise_or(a, b) << std::endl;
std::cout << "a xor b:" << bitwise_xor(a, b) << std::endl;
std::cout << "not a:" << bitwise_not(a) << std::endl;

a = 10;
b = 20;
std::cout << "a + b:" << sum(a, b) << std::endl;
std::cout << "a - b:" << sub(a, b) << std::endl;
std::cout << "a * b:" << mult(a, b) << std::endl;
std::cout << "a / b:" << divide(a, b) << std::endl;
return 0;

}

Этот файл линкуется к библиотеке arithmetic. Аналогично написан и CMakeLists.txt для библиотеке logic с той лишь разницей, что run.cpp к ней не подключается. Вместо файла main.cpp теперь используется bootstrap.cpp, в котором просто вызывается функция run.

src/bootstrap.cpp

int run();

int main() {
run();

}

В файле arithmetiс/CMakeLists.txt логика сбора покрытия выполняется только при установленном флаге CMAKE_BUILD_TYPE. Его будем задавать в командной строке при построении проекта. Команды target_compile_options и target_link_options используются для включения функционала сборки (--coverage) покрытия при компиляции и сборке соответственно.

Строка add_custom_command(TARGET arithmetic PRE_BUILD COMMAND find ${CMAKE_BINARY_DIR} -type f -name '*.gcda' -exec rm {} +) позволяет в процессе обработки CMakeLists.txt выполнить команды операционной системы. В данном случае - это поиск в папке, название которой задано в переменной CMAKE_BINARY_DIR файлов с расширением gcda и их последующее удаление командой rm. Удаление необходимо делать потому, что редактирование исходников в проекте, построенном для сбора покрытия, может служить источником проблем. Это связано с тем, что информация о покрытии разделена на две части:

• файлы с расширением gcno (в литературе их называют заметками о покрытии), генерируемые во время компиляции тестируемой программы;

• файлы gcda (данные о покрытии), генерируемые и обновляемые во время запуска программы.

Функциональность обновления gcda файлов является потенциальным источником ошибок сегментации. После того, как тесты запущены, останется множество файлов gcda, которые не будут удалены. Если мы внесем изменения в исходный код и вновь создадим объектные файлы, будут созданы новые файлы gcno. Однако файлы gcda, оставшиеся от предыдущих запусков, хранят устаревший исходный код. Когда тестовая программа запускается на выполнение, файлы информации о покрытии не будут совпадать, что приведет к SEGFAULT (ошибка сегментации). Чтобы избежать этой проблемы, нам следует удалить все устаревшие файлы gcda. Поскольку экземпляр тестируемой программы является статической библиотекой, мы можем привязать команду add_custom_command(TARGET), которая будет выполнять удаление устаревших файлов, к событиям сборки. Удаление будет выполнена до начала пере сборки. Для сборки покрытия добавляется цель, вынесенная в отдельный файл coverage.cmake, хранящийся в папке coverage.

coverage/coverage.cmake

function(AddCoverage target)

find_program(LCOV_PATH lcov REQUIRED)

find_program(GENHTML_PATH genhtml REQUIRED)

add_custom_target(coverage

COMMENT "Running coverage for ${target}..."

COMMAND ${LCOV_PATH} -d . --zerocounters

COMMAND $<TARGET_FILE:${target}>

COMMAND ${LCOV_PATH} -d . --capture -o coverage.info

COMMAND ${LCOV_PATH} -r coverage.info '/usr/include/*'

-o filtered.info

COMMAND ${GENHTML_PATH} -o coverage filtered.info --legend

COMMAND rm -rf coverage.info filtered.info

WORKING_DIRECTORY ${CMAKE_BINARY_DIR}

)

endfunction()

В функции AddCoverage сначала определяются пути для lcov и genhtml (два инструмента командной строки из пакета LCOV). Ключевое слово REQUIRED указывает CMake выдавать ошибку, если они не найдены. Затем добавляется пользовательская цель покрытия, в которой выполняются следующие шаги:

  • Очищаются счетчики статистики от всех предыдущих запусков;
  • Запускается целевой исполняемый файл (используя выражения генератора для получения его пути). $<TARGET_FILE:target> — это выражение генератора, оно неявно добавляет зависимость от цели, заставив ее собраться перед выполнением всех команд. Цель подставляется в качестве аргумента этой функции AddCoverage;
  • Собирается метрика для программы из текущего каталога (-d .) и выводится в файл (-o coverage.info);
  • Удаляются (-r) нежелательные данные покрытия в заголовочных системных файлах ('/usr/include/*') и осуществляется вывод в другой файл (-o filtered.info);
  • Генерируется HTML-отчет в каталоге покрытия и добавляется цвет (--legend);
  • Удаляются временные файлы (.info);
  • Указанием ключевого слова WORKING_DIRECTORY устанавливается рабочий каталог для всех команд.

Данная цель вызывается из test/CMakeLists.txt

test/CMakeLists.txt

include(FetchContent)

FetchContent_Declare(

googletest

GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0

)

# For Windows: Prevent overriding the parent project's compiler/linker settings

set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)

FetchContent_MakeAvailable(googletest)

add_executable(unit_tests

arithmetic_test.cpp logic_test.cpp run_test.cpp

)

target_link_libraries(unit_tests PRIVATE arithmetic logic gtest_main)

include(coverage)

AddCoverage(unit_tests)

include(GoogleTest)

gtest_discover_tests(unit_tests)

Как видно из текста скрипта, вначале подключается директория coverage, в которой хранится coverage.cmake, а после этого вызывается функция AddCoverage в которую передается название цели unit_tests.

Для сборки с покрытием вводим команду cmake -B ./build -DCMAKE_BUILD_TYPE=Debug , находясь в корневой директории проекта.

Далее переходим в созданную директорию build и вводим команду cmake --build . -t coverage

-4

Вначале строятся цели для скачанной на предыдущем шаге библиотеки GoogleTest. Далее - цели arithmetic, logic и unit_test. После этого стартуют тесты и ,наконец, запускаются процесс сбора покрытия.

-5

В конце работы CMake выводит краткий отчет о покрытии. Отчеты в формате HTML можно найти в директории build/coverage. Как и ожидалось достигнуто 100% покрытие строк.

Исходный код проекта как всегда можно посмотреть в репозитории. Продолжение следует...