CMake і vcpkg

Тестування з CMake: CTest, Google Test, покриття коду

Вбудована система тестування CTest, add_test, запуск і фільтрація тестів, інтеграція Google Test через FetchContent та gtest_discover_tests, пастка assert у Release, вимірювання покриття коду.

Тестування з CMake: CTest, Google Test, покриття коду

🎯 Мета лекції

  • Підключити тести до проєкту через enable_testing() та add_test().
  • Запускати тести утилітою ctest, фільтрувати їх і читати результати.
  • Інтегрувати Google Test та автоматично реєструвати його тести в CTest.
  • Розуміти, чому assert у Release-збірці не перевіряє нічого.
  • Виміряти покриття коду тестами.

🔑 Ключові терміни

  • Юніт-тест (unit test): невелика програма, яка перевіряє окрему функцію або клас.
  • CTest: утиліта запуску тестів, що входить до складу CMake.
  • Тестовий фреймворк: бібліотека з макросами перевірок і запуском тестів (Google Test, Catch2).
  • Покриття коду (code coverage): відсоток рядків програми, які виконались під час тестів.

Збирати програму недостатньо: потрібно переконатися, що вона працює правильно. Автоматичні тести виконуються після кожної зміни і швидко показують, що саме зламалось. CMake має вбудований інструмент для їх запуску.

CTest: вбудована система тестування

CTest — утиліта, що йде разом із CMake. Її задача — запускати програми, зареєстровані як тести, і збирати результат. Тест вважається успішним, якщо програма завершилась із кодом 0, і провальним в усіх інших випадках (ненульовий код, аварійне завершення, перевищення часу).

Структура проєкту

Ключові рядки:

  • enable_testing() вмикає тестову інфраструктуру. Її потрібно викликати в кореневому CMakeLists.txt до add_subdirectory(tests), інакше CTest не знайде тести з підпапок.
  • add_test(NAME ... COMMAND ...) реєструє тест: ім'я для звітів і команду запуску. Як команда можна вказати ім'я цілі test_math, і CMake сам підставить шлях до виконуваного файлу.
  • target_link_libraries(test_math MathUtils) підключає тестовану бібліотеку. Завдяки PUBLIC у MathUtils тест отримує і шлях до заголовків.

Запуск тестів

# Збираємо проєкт
cmake -S . -B build
cmake --build build

# Запускаємо тести з папки збірки
cd build
ctest

# Докладний вивід
ctest --verbose

# Показувати вивід лише для провальних тестів
ctest --output-on-failure
ctest
$ ctest
Test project /Users/student/MathTests/build
Start 1: MathTests
1/1 Test #1: MathTests ........................ Passed 0.17 sec
100% tests passed, 0 tests failed out of 1
Total Test time (real) = 0.18 sec

Корисні прапорці CTest

КомандаДія
ctest -R MathЗапустити тести, імена яких відповідають регулярному виразу Math
ctest -E SlowВиключити тести за іменем
ctest -j 4Запускати до 4 тестів паралельно
ctest -C DebugВказати конфігурацію для multi-config генераторів (Visual Studio, Xcode)
ctest --rerun-failedПовторити лише ті тести, що впали минулого разу
ctest --timeout 10Обмежити час кожного тесту десятьма секундами

Для окремого тесту можна задати властивості:

add_test(NAME MathTests COMMAND test_math)
set_tests_properties(MathTests PROPERTIES TIMEOUT 5)

Пастка: assert у Release

Спробуємо зламати перевірку: замінимо в тесті assert(add(2, 3) == 5) на assert(add(2, 3) == 6). Це свідомо хибне твердження. Зберемо проєкт у двох конфігураціях і запустимо CTest:

одна й та сама помилка у Debug і Release
$ cmake -S . -B bd -DCMAKE_BUILD_TYPE=Debug && cmake --build bd && (cd bd && ctest --output-on-failure)
Start 1: MathTests
1/1 Test #1: MathTests ........................Subprocess aborted***Exception: 0.33 sec
Assertion failed: (add(2, 3) == 6), function test_add, file test_math.cpp, line 6.
0% tests passed, 1 tests failed out of 1
$ cmake -S . -B br -DCMAKE_BUILD_TYPE=Release && cmake --build br && (cd br && ctest)
1/1 Test #1: MathTests ........................ Passed 0.17 sec
100% tests passed, 0 tests failed out of 1

У Debug CTest виявив помилку. У Release той самий хибний тест «пройшов». Причина: Release-збірка визначає макрос NDEBUG, а assert при ньому розгортається в порожнє місце, і перевірок у виконуваному файлі просто немає.

Тест, який нічого не перевіряє, гірший за відсутність тесту: він створює хибне відчуття безпеки. Не покладайтесь на assert у тестах. Використовуйте тестовий фреймворк, макроси якого не залежать від NDEBUG, або власну перевірку з поверненням ненульового коду.

Google Test

Для серйозного тестування потрібен фреймворк: він надає макроси порівняння, групує тести, виводить зрозумілі повідомлення про розбіжності та підтримує параметризовані тести. Найпоширеніший варіант — Google Test.

Підключення через FetchContent

cmake_minimum_required(VERSION 3.14)
project(GoogleTestExample)

set(CMAKE_CXX_STANDARD 17)

# Модуль для завантаження залежностей
include(FetchContent)

# Завантажуємо Google Test
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG release-1.12.1
)

# Для Windows: не змінювати налаштування CRT батьківського проєкту
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)

FetchContent_MakeAvailable(googletest)

# Бібліотека, яку тестуємо
add_library(MathUtils src/math_utils.cpp)
target_include_directories(MathUtils PUBLIC src)

# Вмикаємо тестування
enable_testing()

# Тестовий виконуваний файл
add_executable(math_tests tests/test_math.cpp)

# Лінкуємо з Google Test і з нашою бібліотекою
target_link_libraries(math_tests
    GTest::gtest_main
    MathUtils
)

# Автоматично знаходимо тести
include(GoogleTest)
gtest_discover_tests(math_tests)

Розбір нових елементів:

  • GTest::gtest_main — ціль Google Test, яка містить функцію main(), що запускає всі тести. У тестовому файлі писати main не потрібно.
  • gtest_force_shared_crt потрібна лише на Windows з MSVC: вона змушує Google Test використовувати ту саму бібліотеку часу виконання, що й проєкт, і запобігає помилкам лінкування.
  • gtest_discover_tests запускає тестовий файл із прапорцем --gtest_list_tests, отримує список тестів і реєструє кожен як окремий тест CTest. Одного add_test на весь файл вистачає лише для простих випадків.

Тести на Google Test

#include <gtest/gtest.h>
#include "math_utils.h"

// Тест функції add
TEST(MathUtilsTest, Add) {
    EXPECT_EQ(add(2, 3), 5);
    EXPECT_EQ(add(-1, 1), 0);
    EXPECT_EQ(add(0, 0), 0);
    EXPECT_EQ(add(100, -50), 50);
}

// Тест функції multiply
TEST(MathUtilsTest, Multiply) {
    EXPECT_EQ(multiply(2, 3), 6);
    EXPECT_EQ(multiply(-2, 3), -6);
    EXPECT_EQ(multiply(0, 100), 0);
    EXPECT_EQ(multiply(7, 8), 56);
}

// Тест функції isPrime
TEST(MathUtilsTest, IsPrime) {
    EXPECT_TRUE(isPrime(2));
    EXPECT_TRUE(isPrime(3));
    EXPECT_TRUE(isPrime(5));
    EXPECT_TRUE(isPrime(17));
    EXPECT_TRUE(isPrime(97));

    EXPECT_FALSE(isPrime(0));
    EXPECT_FALSE(isPrime(1));
    EXPECT_FALSE(isPrime(4));
    EXPECT_FALSE(isPrime(20));
    EXPECT_FALSE(isPrime(100));
}

// Параметризований тест: один тест, багато значень
class PrimeParameterizedTest : public testing::TestWithParam<int> {};

TEST_P(PrimeParameterizedTest, KnownPrimes) {
    EXPECT_TRUE(isPrime(GetParam()));
}

INSTANTIATE_TEST_SUITE_P(
    KnownPrimes,
    PrimeParameterizedTest,
    testing::Values(2, 3, 5, 7, 11, 13, 17, 19, 23, 29)
);

Макрос TEST(Набір, Ім'я) оголошує тест, а EXPECT_EQ, EXPECT_TRUE, EXPECT_FALSE перевіряють результати. Відмінність між EXPECT_* і ASSERT_*: після невдалого EXPECT_* тест продовжується (видно всі помилки одразу), а ASSERT_* зупиняє тест на першій же невдачі. Параметризований тест TEST_P запускає ту саму перевірку для кожного значення зі списку.

Запуск

cmake -S . -B build
cmake --build build
cd build
ctest --verbose

# Запустити виконуваний файл напряму
./math_tests

# Один конкретний тест
./math_tests --gtest_filter=MathUtilsTest.Add

# Показати список тестів без виконання
./math_tests --gtest_list_tests

Завдяки gtest_discover_tests CTest бачить кожен тест окремо (три прості плюс десять параметризованих):

ctest
$ ctest
Start 1: MathUtilsTest.Add
1/13 Test #1: MathUtilsTest.Add ................................... Passed 0.00 sec
Start 2: MathUtilsTest.Multiply
2/13 Test #2: MathUtilsTest.Multiply .............................. Passed 0.00 sec
Start 3: MathUtilsTest.IsPrime
3/13 Test #3: MathUtilsTest.IsPrime ............................... Passed 0.00 sec
Start 4: KnownPrimes/PrimeParameterizedTest.KnownPrimes/2
4/13 Test #4: KnownPrimes/PrimeParameterizedTest.KnownPrimes/2 .... Passed 0.00 sec
...
13/13 Test #13: KnownPrimes/PrimeParameterizedTest.KnownPrimes/29 ... Passed 0.00 sec
100% tests passed, 0 tests failed out of 13
Версія release-1.12.1 використана для відтворюваності прикладу. У нових проєктах зазвичай беруть найновіший стабільний реліз Google Test. Конкретний тег варто перевіряти на сторінці релізів.

Альтернатива: Google Test через vcpkg

Якщо у проєкті вже використовується vcpkg (його розібрано в наступних лекціях), Google Test не потрібно завантажувати при кожній конфігурації:

find_package(GTest CONFIG REQUIRED)

add_executable(math_tests tests/test_math.cpp)
target_link_libraries(math_tests PRIVATE GTest::gtest_main MathUtils)

include(GoogleTest)
gtest_discover_tests(math_tests)

Решта CMake-коду залишається тим самим: змінюється лише спосіб отримання залежності.

Покриття коду

Покриття показує, які рядки програми виконались під час тестів. Рядки з нульовим покриттям — це код, який жоден тест не перевіряє.

cmake_minimum_required(VERSION 3.10)
project(CoverageExample)

set(CMAKE_CXX_STANDARD 17)

# Опція для покриття
option(ENABLE_COVERAGE "Enable code coverage" OFF)

if(ENABLE_COVERAGE AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
    message(STATUS "Code coverage enabled")

    # Прапорці для збору статистики
    add_compile_options(--coverage -O0 -g)
    add_link_options(--coverage)
endif()

add_library(MathUtils src/math_utils.cpp)
target_include_directories(MathUtils PUBLIC src)

enable_testing()
add_executable(math_tests tests/test_math.cpp)
target_link_libraries(math_tests GTest::gtest_main MathUtils)

gtest_discover_tests(math_tests)

Прапорець --coverage змушує компілятор вставити в програму лічильники, що рахують, скільки разів виконано кожен рядок. Оптимізація (-O0) вимкнена, щоб рядки відповідали вихідному коду. Після запуску тестів статистика записується у файли .gcda.

# Збираємо з покриттям
cmake -S . -B build -DENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build

# Запускаємо тести
ctest --test-dir build

# Генеруємо звіт (потрібна утиліта gcovr або lcov)
gcovr -r . --html --html-details -o coverage.html

# Відкриваємо звіт у браузері

Прапорець --test-dir build дозволяє запустити CTest без cd build (CMake 3.20 і новіше). Звіт coverage.html показує відсоток покриття для кожного файлу і підсвічує рядки, які не виконувались.

Високе покриття не гарантує правильності: рядок може виконатися і при цьому не бути перевіреним. Ціль покриття в 100% часто недосяжна й недоречна. Корисніше стежити, щоб покриття не падало й щоб нова логіка мала тести.

Питання для самоперевірки

Практичне завдання

Резюме

🧪 CTest

enable_testing() у корені і add_test() реєструють тести. ctest запускає їх, фільтрує та показує результат.

✅ Google Test

Підключається через FetchContent або vcpkg. gtest_discover_tests перетворює кожен тест на окремий запис CTest.

⚠️ assert і NDEBUG

У Release assert зникає, і хибний тест проходить. Для перевірок використовуйте фреймворк.

📊 Покриття

--coverage збирає статистику виконаних рядків, gcovr будує звіт. Корисне доповнення, а не самоціль.
Далі зберемо корисні команди CMake, який набір змінних і .gitignore варто тримати під рукою, а також скрипти збірки й перелік кращих практик сучасного CMake.
Copyright © 2026