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.
Можно держать у себя примерно такую мысленную схему:
| Сущность | Что это | Что внутри |
|---|---|---|
|
программа для пользователя | main, ввод/вывод, сценарий работы |
|
программа для проверки | test runner + TEST_CASE + CHECK/REQUIRE |
|
библиотека/набор .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 страниц, это короткие заметки “вот это правило должно работать”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ