JavaRush /Курсы /C++ SELF /CTest — запуск тестов как часть сборки

CTest — запуск тестов как часть сборки

C++ SELF
37 уровень , 3 лекция
Открыта

1. Зачем вообще нужен CTest, если у нас уже есть doctest/Catch2?

Если вы только что освоили тест‑фреймворк, закономерно возникает вопрос: «Ну всё, тесты же запускаются — зачем ещё какой-то CTest?» Лишних сущностей в жизни программиста и так хватает, особенно когда дедлайн уже смотрит вам в душу. Давайте аккуратно разведём роли и посмотрим, что именно добавляет CTest.

Тест‑фреймворк (doctest/Catch2) — это библиотека внутри вашего тестового бинарника. Она умеет находить тест‑кейсы, запускать их, красиво печатать «ожидали/получили» и возвращать код завершения процесса 0 (успех) или != 0 (провал).

CTest — это инструмент снаружи. Он не «понимает» ваши TEST_CASE и не анализирует ваши CHECK. Он делает другое: организует запуск тестов как части процесса сборки проекта. Проще говоря, CTest — это диспетчер: «в проекте зарегистрированы такие-то тесты, давай их все запустим, соберём статистику, выведем итог и вернём код успеха/провала наружу».

Полезно держать в голове такую табличку:

Сущность Где живёт Что делает Что НЕ делает
Тест‑фреймворк (doctest/Catch2) внутри тестового .exe запускает тест‑кейсы, печатает отчёт не знает, как устроен ваш проект/сборка
CMake «рецепт сборки» компилирует/линкует таргеты сам по себе не является «раннером тестов»
CTest рядом с CMake (экосистема) запускает зарегистрированные тесты и агрегирует итог не анализирует CHECK, ему важен только exit‑code

2. Главная идея CTest: тест — это команда с кодом 0

Когда слышишь «CTest», новичку иногда кажется, что это какой-то магический режим компилятора: мол, он «встроит тесты в сборку» и дальше они будут происходить сами по себе, как погода за окном. На самом деле модель максимально приземлённая: тест — это просто процесс (команда), которую можно запустить, и у процесса есть код завершения.

Почти вся автоматизация в мире разработки стоит на очень простой договорённости: 0 означает успех, любое другое число означает ошибку. CTest не исключение. Он запускает тестовые команды и смотрит: вернулся 0 или нет. Если не 0 — тест считается упавшим, даже если он напечатал «всё хорошо, честно-честно».

Чтобы почувствовать это руками, полезно увидеть микротест без фреймворка — просто «самодельный runner». Он не красивый, но очень честный (как 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» работает так гладко: фреймворк внутри процесса решает «прошло/не прошло» и ставит правильный exit‑code, а 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 потом будет запускать этот бинарник и анализировать exit‑code.

4. Как запускать CTest на практике

На этом месте обычно случается первая типичная «боль новичка»: человек пишет ctest, получает странную ошибку или видит ноль тестов, а затем торжественно объявляет: «CTest сломан». На самом деле чаще всего сломан не CTest, а понимание, из какой папки его запускать и что уже должно быть собрано.

CTest «живёт» в папке сборки (build directory), потому что именно туда 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: он превращает «ручной запуск тестового exe» в стандартную команду тестирования проекта. Вам больше не нужно помнить «а как назывался наш тестовый бинарник, и где он лежит». Вы говорите: «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, и лучше не пытаться спорить с этим контрактом, потому что с ним спорит весь мир автоматизации.

1
Задача
C++ SELF, 37 уровень, 3 лекция
Недоступна
Раннер проверок
Раннер проверок
1
Задача
C++ SELF, 37 уровень, 3 лекция
Недоступна
Два набора
Два набора
1
Задача
C++ SELF, 37 уровень, 3 лекция
Недоступна
Режимы инструмента
Режимы инструмента
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ