1. Навіщо взагалі потрібен CTest, якщо в нас уже є doctest/Catch2?
Якщо ви щойно опанували тестовий фреймворк, цілком закономірно виникає запитання: «Ну все, тести ж запускаються — навіщо ще якийсь CTest?» Зайвих сутностей у житті програміста й без того вистачає, особливо коли дедлайн уже зазирає вам у душу. Тож давайте спокійно розмежуємо ролі й подивимося, що саме додає CTest.
Тестовий фреймворк (doctest/Catch2) — це бібліотека всередині вашого тестового виконуваного файла. Вона вміє знаходити тест-кейси, запускати їх, зручно показувати «очікували/отримали» та повертати код завершення процесу 0 (успіх) або != 0 (помилка).
CTest — це зовнішній інструмент. Він не «розуміє» ваші TEST_CASE і не аналізує ваші CHECK. Натомість він організовує запуск тестів як частини процесу збирання проєкту. Інакше кажучи, CTest — це диспетчер: «у проєкті зареєстровано такі-то тести, давайте запустимо їх усі, зберемо статистику, підсумуємо результати й повернемо назовні код успіху або помилки».
Ось коротка й зручна таблиця:
| Сутність | Де живе | Що робить | Чого НЕ робить |
|---|---|---|---|
| Тестовий фреймворк (doctest/Catch2) | усередині тестового .exe | запускає тест-кейси, друкує звіт | не знає, як влаштований ваш проєкт і процес збирання |
| CMake | «рецепт збирання» | компілює та лінкує цілі | сам по собі не є засобом запуску тестів |
| CTest | поруч із CMake (екосистема) | запускає зареєстровані тести й підбиває підсумок | не аналізує CHECK; для нього важливий лише код завершення |
2. Головна ідея CTest: тест — це команда з кодом 0
Коли ви вперше чуєте «CTest», інколи може здатися, що це якийсь магічний режим компілятора: мовляв, він «вбудує тести в збірку», і далі вони запускатимуться самі собою, наче погода за вікном. Насправді модель тут дуже проста: тест — це просто процес, тобто команда, яку можна запустити, а в процесу є код завершення.
Майже вся автоматизація у світі розроблення тримається на дуже простій домовленості: 0 означає успіх, а будь-яке інше число — помилку. CTest — не виняток. Він запускає тестові команди й дивиться, чи повернувся 0. Якщо ні, тест вважається таким, що завершився з помилкою, навіть якщо він надрукував «усе добре, чесно».
Щоб краще відчути це на практиці, корисно побачити мікротест без фреймворка — просто саморобний засіб запуску. Він не надто вишуканий, зате дуже чесний (як cout без форматування):
#include <iostream>
bool test_add() {
return (2 + 3) == 5;
}
int main() {
if (!test_add()) {
std::cerr << "test_add FAILED\n";
return 1; // <-- не 0: помилка
}
std::cout << "test_add OK\n"; // test_add OK
return 0; // <-- 0: успіх
}
Саме тому поєднання «фреймворк + CTest» працює так гладко: фреймворк усередині процесу вирішує, «пройшло/не пройшло», і виставляє правильний код завершення, а CTest ззовні запускає процеси й підсумовує результат.
Як CTest підключається до проєкту в CMake
CMake вміє генерувати файли для збирання, а також «реєстр тестів» — список команд, які вважаються тестами проєкту. Саме цей список потім читає CTest.
Щоб мінімально підключити CTest у CMake, достатньо двох дій. Перша — сказати: «у проєкті взагалі є тести, увімкни підтримку тестування». Друга — зареєструвати конкретні команди як тести.
У CMake це виглядає так:
enable_testing()
add_test(NAME core_tests COMMAND core_tests)
Читається це майже як звичайна людська мова — рідкісний випадок, коли програмісту не боляче: увімкнули тестування, зареєстрували тест з імʼям core_tests, який запускається командою core_tests (зазвичай це імʼя тестового виконуваного файла).
Важливо: add_test реєструє команду, а не «файл із тестами». За потреби ви можете зареєструвати тестом узагалі що завгодно: запуск вашого застосунку з аргументами, запуск скрипта, запуск перевірки форматування. Але в межах цієї теми зосередьмося на unit-тестах і тестових виконуваних файлах.
3. Практичний приклад: тестовий виконуваний файл і реєстрація в CTest
Зараз ми зберемо невеликий, але цілісний «скелет проєкту», у якому є логіка, яку ми тестуємо, є застосунок, який запускає користувач, і є тести, які запускає CTest. Це методично важливо: якщо все змішати в один main.cpp, ви начебто й «запустили тести», але автоматизувати це буде складно, та й підтримувати теж.
Уявімо, що ми розвиваємо навчальний міні-застосунок TinyCalc. Поки що він уміє робити дві речі: clamp (обмежувати число діапазоном) і safe_div (ділити, але повертати std::optional, якщо ділення неможливе). Жодної магії — лише функції, які зручно тестувати.
Код «ядра», який будемо тестувати
Файл src/core.hpp:
#pragma once
#include <optional>
int clamp(int x, int lo, int hi);
std::optional<int> safe_div(int a, int b);
Файл src/core.cpp:
#include "core.hpp"
int clamp(int x, int lo, int hi) {
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
std::optional<int> safe_div(int a, int b) {
if (b == 0) return std::nullopt;
return a / b;
}
Зверніть увагу на «тестованість»: жодного std::cin, жодної залежності від часу й жодних випадкових чисел. Подали параметри — отримали результат. Такі функції тестуються майже без опору з боку Всесвіту.
Застосунок, який використовує ядро
Файл src/main.cpp:
#include <iostream>
#include "core.hpp"
int main() {
std::cout << clamp(15, 0, 10) << '\n'; // 10
const auto r = safe_div(10, 2);
if (r) {
std::cout << *r << '\n'; // 5
}
}
Це лише демонстрація того, що бібліотека підключена й працює. Тестувати main за допомогою unit-тестів можна, але це окрема дисципліна. Для сьогоднішньої теми достатньо тестувати «ядро».
Тестовий виконуваний файл на doctest/Catch2
Файл tests/core_tests.cpp:
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
#include "core.hpp"
#include <optional>
TEST_CASE("clamp clamps into [lo, hi]") {
CHECK(clamp(5, 0, 10) == 5);
CHECK(clamp(-1, 0, 10) == 0);
CHECK(clamp(999, 0, 10) == 10);
}
TEST_CASE("safe_div returns value or nullopt") {
CHECK(safe_div(10, 2) == std::optional<int>{5});
CHECK(safe_div(10, 0) == std::nullopt);
}
Цей файл компілюється в окремий виконуваний файл, наприклад core_tests. Якщо запустити його напряму, він сам знайде тести, виконає їх і поверне коректний код завершення.
CMakeLists.txt: звʼязуємо все разом
У корені проєкту: CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(TinyCalc LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(core src/core.cpp)
target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
add_executable(tinycalc src/main.cpp)
target_link_libraries(tinycalc PRIVATE core)
add_executable(core_tests tests/core_tests.cpp)
target_link_libraries(core_tests PRIVATE core)
enable_testing()
add_test(NAME core_tests COMMAND core_tests)
Тут важливі дві ключові думки.
- Перша думка: тести — це окремий виконуваний файл (окрема ціль), як і застосунок. Ми не змішуємо тести з tinycalc, інакше потім самі заплутаємося, що саме запускаємо й навіщо.
- Друга думка: add_test реєструє команду запуску core_tests як тест для CTest. Потім CTest запускатиме цей виконуваний файл і перевірятиме код завершення.
4. Як запускати CTest на практиці
На цьому етапі зазвичай трапляється перший типовий «біль новачка»: людина пише ctest, отримує дивну помилку або бачить нуль тестів, а потім урочисто оголошує: «CTest зламаний». Насправді найчастіше проблема не в CTest, а в розумінні, з якого каталогу його запускати і що вже має бути зібрано.
CTest «живе» в каталозі збирання, бо саме туди CMake генерує файли з переліком тестів. Тому послідовність дій така: спочатку ви конфігуруєте проєкт, потім збираєте його, щоб зʼявився тестовий виконуваний файл, і лише після цього запускаєте ctest.
Типовий сценарій у вигляді команд такий:
cmake -S . -B build
cmake --build build
ctest --test-dir build
Якщо ви запускаєте ctest з папки build, часто достатньо такого:
cd build
ctest
Що ви побачите у виводі? Зазвичай CTest друкує, скільки тестів він знайшов, які запустив і які завершилися з помилкою. І ось важлива деталь: якщо тест упав, CTest завершиться з кодом != 0. Це означає, що «ззовні», тобто для автоматизації, тестовий крок провалено.
Чому інколи буває «0 tests»
Якщо CTest пише, що тестів 0, це майже завжди означає одне з двох: або ви забули enable_testing() / add_test(...), або запускаєте ctest не в тому каталозі, де CMake згенерував тестову конфігурацію.
Як побачити вивід тесту, який упав
Ще один частий сюрприз: тест упав, а ви не бачите докладного повідомлення, наприклад які значення були в секції «очікували/отримали». Це не баг, а політика виводу. Часто зручно вмикати режим «покажи вивід у разі провалу»:
ctest --test-dir build --output-on-failure
Або «дуже докладний» режим:
ctest --test-dir build -V
Сенс простий: CTest уміє бути «тихим диспетчером», а на запит — «балакучим диспетчером». Під час розроблення балакучість корисна, а в автоматичній перевірці — інколи навпаки.
Кілька тестів: імена та фільтрація
Коли в проєкті один виконуваний файл core_tests, усе виглядає просто й зрозуміло. Але щойно проєкт трохи розростається, тестів стає кілька: наприклад, тести для парсингу, тести для контейнера моделей, тести для форматування виводу. І тут CTest стає особливо корисним: він уміє запускати тести як єдиний набір, а також дозволяє вибирати підмножину.
У CMake це просто повторення add_test, тільки з різними іменами:
add_executable(parser_tests tests/parser_tests.cpp)
target_link_libraries(parser_tests PRIVATE core)
add_test(NAME parser_tests COMMAND parser_tests)
Корисна звичка: імʼя тесту (NAME ...) робіть таким, щоб воно було зрозумілим людині у звіті. test1 і test2 — це як змінні a і b у бухгалтерії: технічно можна, але потім ви самі собі влаштуєте квест.
Коли тестів багато, CTest дозволяє запускати не все підряд, а за фільтром. Наприклад, за регулярним виразом в імені тесту. Концептуально це виглядає так:
ctest --test-dir build -R core
Тобто «запусти лише ті тести, в імені яких зустрічається core». Це не те, що обовʼязково треба знати напамʼять, але корисно памʼятати: ви не зобовʼязані щоразу запускати весь набір, якщо лагодите лише одну підсистему.
5. Як тести стають частиною процесу збирання
Фраза «як частина збірки» інколи звучить так, ніби тести виконуються просто під час компіляції, як constexpr. Насправді «частина збірки» означає частину процесу перевірки проєкту. Спочатку збирання підтверджує, що код бодай компілюється й лінкується. Потім тести підтверджують, що поведінка не зламалася.
Це дуже важливий розподіл відповідальності. Компілятор перевіряє форму: типи, імена, синтаксис, лінкування. Тести перевіряють зміст: «safe_div(10, 0) справді повертає nullopt», «clamp не виходить за межі діапазону», «парсер не приймає сміття» — і так далі.
І ось тут зʼявляється CTest: він перетворює ручний запуск тестового виконуваного файла на стандартну команду тестування проєкту. Вам більше не потрібно памʼятати: «а як називається наш тестовий виконуваний файл і де він лежить?» Ви просто кажете: «CTest, запусти тести проєкту», і він робить це, бо CMake вже зареєстрував потрібні команди.
Окремо корисно знати про мультиконфігураційні генератори, наприклад деякі IDE, де є Debug/Release. У таких випадках CTest інколи треба явно вказати конфігурацію, але для загального розуміння достатньо запамʼятати: тести запускаються з тієї конфігурації, у якій їх зібрано. Якщо ви зібрали Debug — запускайте тести Debug. Якщо зібрали Release — запускайте тести Release.
6. Типові помилки під час роботи з CTest
Помилка № 1: тести зареєстрували, але виконуваний файл не збирається.
Дуже легко написати add_test(NAME core_tests COMMAND core_tests) і забути, що core_tests має існувати як виконуваний файл. Якщо add_executable(core_tests ...) немає або він не зібрався через помилку компіляції, CTest чесно намагатиметься запустити те, чого немає. Вирішення просте: спочатку виправляємо збирання, а вже потім запускаємо тести.
Помилка № 2: тести «не знаходяться», бо ctest запускають не з того каталогу.
CTest — не екстрасенс: він не буде обходити весь диск у пошуках вашого build. Якщо ви запускаєте ctest з кореня репозиторію, а тестова конфігурація лежить у build/, то CTest часто покаже «0 tests» або просто не знайде жодного тесту. Звикайте до дисципліни: запускайте ctest або з каталогу збирання, або з явним указанням каталогу тестів.
Помилка № 3: тести залежать від std::cin і чекають на введення.
CTest запускає тести як автоматичні команди. Якщо ваш тест під час виконання чекає, поки користувач введе число, CTest чекатиме разом із ним — і це виглядає так, ніби «ctest завис». На рівні unit-тестів це лікується архітектурою: логіка має тестуватися функціями з параметрами, а введення/виведення залишаємо в main.
Помилка № 4: тест падає, але ви не бачите чому.
Новачки інколи думають, що CTest зобовʼязаний завжди показувати весь вивід. Але він може бути «тихим» і повідомляти лише про сам факт провалу. Якщо потрібен докладний вивід, вмикайте режими докладності (-V) або вивід у разі провалу (--output-on-failure). І, звісно, переконайтеся, що тестовий фреймворк друкує діагностику в stderr/stdout саме так, як ви очікуєте.
Помилка № 5: тест друкує «FAILED», але повертає 0.
Це класика саморобних засобів запуску й погано написаних перевірок. CTest вірить не словам, а коду завершення процесу. Якщо ви вивели «все погано», але повернули return 0;, то для CTest тест успішний. Домовленість проста: успіх — 0, помилка — != 0. І краще не сперечатися з цим контрактом, бо на ньому тримається весь світ автоматизації.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ