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.
Можна тримати в голові приблизно таку схему:
| Сутність | Що це | Що всередині |
|---|---|---|
|
програма для користувача | 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);
}
На практиці це економить нерви: ви не отримуєте «каскаду» дивних помилок, які сталися лише тому, що одна базова умова не виконалася.
Табличні кейси: старий прийом, новий комфорт
У табличних тестах різні вхідні дані проганяються одним і тим самим тестовим кодом без зайвого копіювання. Із фреймворком цей стиль нікуди не зникає — він просто стає зручнішим: якщо кейс упаде, ви побачите файл і рядок, а іноді ще й значення «очікували/отримали».
Перевірмо 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 сторінок, а короткі нотатки: «ось це правило має працювати».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ