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 і набір test case. Застосунок, наприклад ваш 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);
}

На практиці це економить нерви: ви не отримуєте «каскаду» дивних помилок, які сталися лише тому, що одна базова умова не виконалася.

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

У табличних тестах різні вхідні дані проганяються одним і тим самим тестовим кодом без зайвого копіювання. Із фреймворком цей стиль нікуди не зникає — він просто стає зручнішим: якщо кейс упаде, ви побачите файл і рядок, а іноді ще й значення «очікували/отримали».

Перевірмо 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 сторінок, а короткі нотатки: «ось це правило має працювати».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ