JavaRush /Курсы /C++ SELF /Catch2 / doctest — структура тестов

Catch2 / doctest — структура тестов

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

1. Введение

Когда мы только начинаем тестировать, assert кажется магией: написал условие — и если оно ложное, программа “ругается”. Но довольно быстро выясняется, что магия уходит в отпуск, а вы остаётесь один на один с вопросами: “какой именно тест упал?”, “сколько тестов было всего?”, “а можно мне отчёт по всем проверкам, а не падение на первой же ошибке?”. Именно здесь и появляется тест‑фреймворк: он превращает набор проверок в полноценную систему, где тесты именованные, запускаются автоматически и выдают понятный отчёт.

С assert есть и более коварный момент: в некоторых конфигурациях сборки (например, Release) assert может быть отключён. И тогда ваш “охранник качества” превращается в “охранника, который сегодня не вышел на смену”. В учебных примерах это не смертельно, но как привычка — опасно.

Что добавляет тест‑фреймворк поверх ваших функций

Тест‑фреймворк — это библиотека, которая даёт вам удобный язык описания тестов и механизм их запуска. Он решает две большие задачи: во‑первых, помогает писать тесты декларативно (например, “вот тест‑кейс с таким названием”), во‑вторых, обеспечивает test runner — то есть “главный запускатель”, который находит все тесты, выполняет их и формирует отчёт.

Важно понять философию: фреймворк не заменяет вашу логику и не делает программу “правильной” сам по себе. Он просто создаёт дисциплину вокруг проверки контрактов. Ваш код по‑прежнему должен быть тестируемым: логика в функциях, минимум интерактива, детерминированный результат. Фреймворк лишь делает проверку удобной, масштабируемой и пригодной для автоматизации.

2. Test runner и точка входа тестов

Почему у тестов “свой main” — отдельная история

В обычной программе у вас есть int main(), и это единственная точка входа. В тестах тоже есть точка входа, но её часто пишет не студент и даже не преподаватель, а… фреймворк. Это и есть test runner: специальный main, который запускает все зарегистрированные тест‑кейсы, считает количество провалов и завершает процесс правильным кодом возврата.

Почти все инструменты автоматизации (и локально, и в CI) смотрят на простое правило: если процесс завершился с кодом 0, значит всё хорошо; если код не 0, значит тесты провалены. Поэтому runner — не “деталь реализации”, а буквально мост между вашим кодом и автоматической проверкой.

Можно представить это так:

flowchart TD
    A[Вы запускаете tests.exe] --> B["Test runner (main)"]
    B --> C[Находит все TEST_CASE]
    C --> D[Выполняет проверки CHECK/REQUIRE]
    D --> E[Формирует отчёт]
    E --> F{Есть провалы?}
    F -->|нет| G[return 0]
    F -->|да| H[return != 0]

Один main — один раз: как не получить “два main” или “ноль main”

Когда вы начинаете писать тесты, есть соблазн: “а давайте я и свой main оставлю, и фреймворк пусть тоже сделает main”. В итоге компоновщик говорит: “ребята, вы уж определитесь, кто тут главный”.

Правило простое: если вы используете режим “фреймворк генерирует main”, то ваш файл тестов не должен содержать собственного main. В doctest это именно строка:

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN

Она должна встретиться ровно один раз во всём тестовом бинарнике. Если вы положите её в два файла — получите две точки входа и конфликт. Если положите её в ноль файлов — получите undefined reference to main. Это один из тех моментов, где C++ честно напоминает: “я не телепат, я компилятор”.

Минимальная структура тестов в проекте

Сейчас нам не нужно уходить в детали CMake/CTest (это будет в следующих лекциях дня), но полезно иметь картинку в голове: тесты — это отдельный исполняемый файл. Внутри него есть runner и набор тест‑кейсов. Приложение (например, ваш app.exe) — это другой исполняемый файл со своим main.

Можно держать у себя примерно такую мысленную схему:

Сущность Что это Что внутри
app
программа для пользователя main, ввод/вывод, сценарий работы
tests
программа для проверки test runner + TEST_CASE + CHECK/REQUIRE
core
библиотека/набор .cpp функции, модели, правила

Эта схема помогает не смешивать “пользовательский сценарий” и “сценарий проверки”. Пользователь не должен запускать тесты вместо приложения, а тесты не должны требовать ввода с клавиатуры.

3. Catch2 и doctest: общая идея и старт

Два популярных фреймворка и что в них общего

Если вы спросите C++‑разработчиков “какой тест‑фреймворк взять”, то очень часто услышите Catch2 или doctest. Оба популярны, оба дружелюбны к новичкам, оба дают похожий стиль тестов: TEST_CASE, CHECK, REQUIRE, понятные сообщения об ошибках. Разница чаще всего не в философии, а в деталях: размере, скорости компиляции, некоторых возможностях и стиле интеграции.

В учебной среде важно другое: мы не хотим тратить день на инфраструктуру подключения библиотек. Поэтому обычно выбирают вариант, который легко “подкинуть” в проект (часто это один заголовок) и сразу писать тесты. doctest исторически позиционируется как очень лёгкий и быстрый по компиляции, Catch2 — как более “богатый” и очень распространённый. Но для нашей сегодняшней цели годятся оба: понять структуру тестов и научиться читать отчёт.

Дальше я буду показывать примеры на doctest, потому что он часто выглядит максимально коротко. Если у вас будет Catch2 — не пугайтесь: названия макросов и общая геометрия почти те же.

Минимальный тестовый файл: runner и тесты в одном .cpp

Самый быстрый способ почувствовать тест‑фреймворк — написать один файл, который одновременно содержит и тесты, и runner. В doctest это делается одной строкой‑директивой, которая говорит: “сгенерируй main за меня”. Дальше вы просто пишете TEST_CASE и проверки.

Представим, что в нашем учебном приложении есть простая функция clamp (маленькая, детерминированная, с понятными границами).

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"

int clamp(int x, int lo, int hi) {
    if (x < lo) return lo;
    if (x > hi) return hi;
    return x;
}

TEST_CASE("clamp keeps value inside [lo, hi]") {
    CHECK(clamp(5, 0, 10) == 5);
    CHECK(clamp(-1, 0, 10) == 0);
    CHECK(clamp(99, 0, 10) == 10);
}

Здесь важно уловить структуру: тест‑кейс — это именованный блок, внутри которого мы делаем несколько проверок. Мы не пишем main, мы не считаем “сколько тестов прошло”, мы не печатаем отчёт — всё это делает фреймворк.

4. Как писать проверки и читать провалы

CHECK и REQUIRE: мягкая и жёсткая проверки

Когда вы пишете тест, вы иногда хотите “проверить и продолжить”, а иногда — “если это не так, дальше смысла нет”. В doctest и Catch2 для этого обычно есть два семейства проверок: CHECK и REQUIRE.

CHECK — это мягкая проверка: она фиксирует провал, но продолжает выполнение текущего тест‑кейса. Это удобно, когда вы прогоняете таблицу кейсов и хотите увидеть сразу все провалы, а не только первый.

REQUIRE — это жёсткая проверка: если она не прошла, выполнение текущего тест‑кейса прекращается. Это полезно для предусловий: например, “вектор не пустой”, “указатель не nullptr”, “мы реально получили значение, а не nullopt”.

#include "doctest.h"
#include <vector>

TEST_CASE("front exists only for non-empty vector") {
    std::vector<int> v{10, 20, 30};

    REQUIRE(!v.empty());     // если вдруг пусто — дальше бессмысленно
    CHECK(v.front() == 10);
}

На практике это экономит нервы: вы не получаете “каскад” странных ошибок, которые произошли лишь потому, что одно базовое условие не выполнилось.

Табличные кейсы: старый приём, новый комфорт

В table‑driven тестах разные входные данные прогоняются одним и тем же тестовым кодом без копипасты. С фреймворком этот стиль не исчезает, он просто становится приятнее: если кейс упадёт, вы увидите файл и строку, а иногда ещё и значения “ожидали/получили”.

Проверим clamp таблично:

#include "doctest.h"
#include <vector>

int clamp(int x, int lo, int hi);

struct ClampCase { int x, lo, hi, expected; };

TEST_CASE("clamp works for table-driven cases") {
    const std::vector<ClampCase> cases{
        {  5, 0, 10,  5},
        { -1, 0, 10,  0},
        { 11, 0, 10, 10},
    };

    for (const auto& tc : cases) {
        CHECK(clamp(tc.x, tc.lo, tc.hi) == tc.expected);
    }
}

Да, цикл выглядит “как раньше”. Но отличие в том, что теперь это не “программа, которая упала где-то в assert”, а полноценный тестовый отчёт.

Контекст в отчёте: как понять, на каком кейсе сломалось

Когда тест падает, вам нужна не трагедия, а информация: какой тест‑кейс, какая проверка, какие значения сравнивались. Именно поэтому фреймворки так ценят: они не просто говорят “ой”, они говорят “ой, и вот почему”.

В doctest (и в Catch2) обычно есть механизмы добавления контекста: можно вывести дополнительные данные, которые появятся в отчёте при провале. Это особенно полезно в циклах по таблице: вы хотите знать, на каком кейсе всё сломалось.

В doctest есть INFO(...), который добавляет “поясняющую надпись”. Используем его аккуратно:

#include "doctest.h"
#include <vector>

TEST_CASE("clamp reports failing case clearly") {
    struct Case { int x, lo, hi, expected; };
    const std::vector<Case> cases{{11, 0, 10, 10}};

    for (const auto& tc : cases) {
        INFO("x=" << tc.x << " lo=" << tc.lo << " hi=" << tc.hi);
        CHECK(clamp(tc.x, tc.lo, tc.hi) == tc.expected);
    }
}

Если проверка упадёт, вы увидите этот текст в отчёте. И это сильно лучше, чем “ну, где-то в цикле что-то не так”.

5. Отделяем “боевой” код от тестов на примере Expense Tracker

Где лежит код, а где — тесты

Когда тестов становится больше одного файла, очень хочется не превращать проект в кашу. Нормальная структура обычно такая: “боевой” код живёт в обычных .hpp/.cpp, а тесты — в отдельной папке, отдельными .cpp файлами. Это не потому что “так красиво”, а потому что тесты и приложение — разные исполняемые файлы с разными точками входа.

Представим наше маленькое приложение как “мини‑учёт расходов” (Expense Tracker). Пока без файлов, без UI, без базы данных — только ядро: модель и функции валидации. Мы начнём с маленькой модели и пары функций, которые реально удобно тестировать.

// expense.hpp
#pragma once
#include <string>

struct Expense {
    int id{};
    std::string title;
    int cents{}; // сумма в центах, чтобы не связываться с double
};

Сама модель простая, и это даже хорошо: мы сегодня не про архитектуру, а про тесты. Но уже на таком уровне у нас появляется что тестировать: например, правила валидности (id > 0, title не пустой, cents > 0).

Тестируемость как привычка: меньше cin, больше функций с параметрами

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

Поэтому для нашего “учёта расходов” сделаем отдельную функцию проверки валидности. Она не читает ничего из консоли, не пишет ничего в консоль, просто отвечает “да/нет”. И это идеально для unit‑тестов.

// expense_rules.hpp
#pragma once
#include "expense.hpp"

inline bool is_valid(const Expense& e) {
    if (e.id <= 0) return false;
    if (e.title.empty()) return false;
    if (e.cents <= 0) return false;
    return true;
}

Да, inline в заголовке здесь допустим (мы уже говорили про это в теме компоновки и ODR). Если вы пока не уверены — можно вынести в .cpp, но для простого учебного примера так тоже нормально.

Тест‑кейс для is_valid: читаемое имя и граничные случаи

Хороший тест‑кейс — это не “test1”. Хороший тест‑кейс — это короткое предложение, объясняющее правило. Так вы через месяц открываете отчёт, видите название — и уже понимаете, что сломалось, даже не глядя в код.

Сначала тест “счастливого пути”, потом — граничные случаи.

// expense_rules_tests.cpp
#include "doctest.h"
#include "expense_rules.hpp"

TEST_CASE("Expense is valid when id>0, title not empty, cents>0") {
    CHECK(is_valid(Expense{1, "Coffee", 250}));
    CHECK(!is_valid(Expense{0, "Coffee", 250}));
    CHECK(!is_valid(Expense{1, "", 250}));
    CHECK(!is_valid(Expense{1, "Coffee", 0}));
}

Обратите внимание: мы не пишем “как именно” функция проверяет валидность. Мы фиксируем контракт: какие условия должны быть выполнены. Если завтра вы поменяете реализацию (например, добавите trim для title), тест всё равно будет полезен, пока контракт тот же.

Практический мини‑пример: операции и тесты

Чтобы не оставаться в абстракции, соберём цельную мини‑историю из трёх маленьких функций. Первая — проверка валидности Expense, вторая — безопасное сложение сумм (в центах), третья — поиск по id.

// expense_ops.hpp
#pragma once
#include "expense.hpp"
#include <optional>
#include <vector>

inline int add_cents(int a, int b) { return a + b; }

inline std::optional<Expense> find_by_id(const std::vector<Expense>& v, int id) {
    for (const auto& e : v) if (e.id == id) return e;
    return std::nullopt;
}

И тесты:

#include "doctest.h"
#include "expense_ops.hpp"

TEST_CASE("add_cents sums integer cents") {
    CHECK(add_cents(100, 50) == 150);
    CHECK(add_cents(0, 0) == 0);
}

TEST_CASE("find_by_id returns value or nullopt") {
    const std::vector<Expense> v{{1,"Coffee",250},{2,"Taxi",1200}};
    CHECK(find_by_id(v, 2)->title == "Taxi");
    CHECK(find_by_id(v, 999) == std::nullopt);
}

Обратите внимание на стиль: никакого cin, никакого “запусти и посмотри глазами”. Только контракт: “вот вход, вот ожидаемый результат”.

6. Типичные ошибки

Ошибка №1: “впихнуть” тесты в main приложения и считать, что это unit‑тестирование.
Такой подход иногда выглядит как экономия времени: “я просто перед запуском меню прогоню пару assert”. Но вы смешиваете два разных продукта: приложение для пользователя и тестовый бинарник для автоматической проверки. В результате тесты начинают зависеть от окружения, а пользовательский сценарий — от того, прошли ли проверки.

Ошибка №2: два main или ноль main из‑за неправильной настройки runner.
Если вы используете doctest/Catch2 в режиме “сгенерируй main”, директива для генерации должна быть ровно в одном .cpp. Новички часто копируют “шапку” тестового файла и получают конфликт. Чуть реже бывает обратная ситуация: директиву забыли, и линковщик сообщает, что main не найден.

Ошибка №3: тесты зависят от порядка выполнения.
Иногда пишут тест, который “оставляет после себя” изменённые глобальные данные, и следующий тест начинает падать. Это классическая причина “у меня локально проходит, а у друга — нет”. Держите тест‑кейсы независимыми: каждый сам подготавливает данные, сам выполняет действие, сам проверяет.

Ошибка №4: неправильный выбор между CHECK и REQUIRE.
Если вы используете CHECK для критичного предусловия, вы можете получить “цепочку странных провалов”, где настоящая причина — в первой строке, а остальное просто последствия. И наоборот, если вы в табличных тестах ставите везде REQUIRE, то при первом же провале цикл остановится, и вы потеряете информацию о том, сколько ещё кейсов сломано.

Ошибка №5: слишком общий тест‑кейс “проверяет всё сразу”.
Когда один TEST_CASE проверяет и валидность, и парсинг, и сортировку, отчёт при падении становится мутным: непонятно, какая часть поведения сломалась. Лучше дробить по правилам поведения. Тесты — это не роман на 200 страниц, это короткие заметки “вот это правило должно работать”.

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