1. Вступ
Коли ви чуєте: «Давайте писати тести», уява новачка часто малює дві страшилки: або «тестувати доведеться взагалі все» — і жити в суцільних тестах, або «це для великих компаній, а в нас тут навчальний проєкт». Насправді все значно спокійніше: тести — це інструмент, а будь-який інструмент варто застосовувати там, де він найшвидше дає користь.
Проблема в тому, що час і увага обмежені. Якщо ви почнете перевіряти «красу виведення в консоль» і «точну кількість пробілів», то дуже швидко вигорите й вирішите, що тести — це зло. А якщо почати з правильних місць, тести працюватимуть як пасок безпеки: ви пристебнулися й можете їхати швидше, не боячись кожного повороту.
Ключова ідея лекції така: перші тести мають захищати те, що найчастіше ламається і що найважче налагоджувати вручну. Зазвичай це не «друк меню», а маленькі функції, парсинг рядків і правила предметної області.
2. Прості критерії вибору: «дешево перевірити» і «дорого зламати»
Щоб робити вибір усвідомлено, корисно тримати в голові просту модель. Добрий кандидат на unit‑тест — це фрагмент коду, який легко викликати з тесту: передати вхідні дані й перевірити результат. А в разі помилки наслідки відчутні: неправильні дані, хибні правила, неочікувані стани.
Не потрібні складні моделі керування ризиками — досить «таблички здорового глузду»:
| Що тестуємо | Чому це зручно | Типова ціна поломки |
|---|---|---|
| Чисті функції (вхід → вихід) | Детерміновані, не потребують зовнішнього оточення | Хибні обчислення/умови |
| Парсери (рядок → структура/число) | Багато граничних випадків, які легко зафіксувати прикладами | Команди не розпізнаються, у даних зʼявляється сміття |
| Бізнес‑логіка (правила й операції) | Найцінніша поведінка, яка часто змінюється | Програма «працює», але робить неправильне |
| Ввід/вивід, інтерактив, «меню» | Складно стабільно перевіряти без додаткової інфраструктури | Зазвичай не найдорожча помилка на старті |
Корисно також тримати під рукою маленький «фільтр тестованості». Якщо на ці запитання ви можете відповісти «так», тест зазвичай написати легко: функція не читає std::cin, не друкує в std::cout, не залежить від часу чи випадковості, результат визначається лише параметрами, і ви можете описати очікувану поведінку кількома прикладами.
Щоб остаточно зняти ореол загадковості, ось схема ухвалення рішення:
flowchart TD
A["Фрагмент логіки, який хочемо перевірити"] --> B{"Є явні входи й явний результат?"}
B -- "ні" --> C["Рефакторимо: виносимо логіку з main/вводу-виводу у функцію"]
B -- "так" --> D{"Залежить від вводу/виводу, часу або випадковості?"}
D -- "так" --> E["Відокремлюємо: ввід/вивід лишаємо зовні, логіку — всередині"]
D -- "ні" --> F{"Є граничні випадки й ризик регресій?"}
F -- "ні" --> G["Можна відкласти, але тест усе одно буде корисним"]
F -- "так" --> H["Пишемо unit‑тести насамперед"]
Зверніть увагу: навіть коли відповідь «ні», це часто означає не «не тестуємо», а «трохи переробимо код, щоб його можна було тестувати». Це нормальна інженерна практика, а не покарання.
3. Чисті функції: ідеальна «перша сходинка» тестування
Чиста функція — це функція, яка працює за принципом «що принесли — те й повернула». Вона не звертається до глобальних змінних, не читає введення, не друкує, не відкриває файли й не намагається пригадати, який сьогодні день тижня. У певному сенсі це найчесніша частина програми: її легко зрозуміти, легко перевірити, і вона рідко потребує «особливої атмосфери» для запуску.
У нашому навчальному застосунку уявімо просту консольну програму Tasky — невеликий менеджер завдань. Команди передаватимемо рядками: add ..., done 3, list. Сьогодні нас цікавить не інтерфейс, а внутрішня логіка.
Почнімо з функції, яка перевіряє коректність назви завдання. Це типовий приклад чистої логіки: на вході — рядок, на виході — bool.
#include <string>
bool is_valid_title(const std::string& title) {
return !title.empty() && title.size() <= 40;
}
Найпростіший тест можна написати за допомогою assert. Це ще не тест‑фреймворк, але для старту — чудовий варіант: швидко й зрозуміло.
#include <cassert>
#include <string>
bool is_valid_title(const std::string& title);
int main() {
assert(is_valid_title("Read book")); // ok
assert(!is_valid_title("")); // ok
assert(!is_valid_title(std::string(41, 'A'))); // ok
}
Що важливо: ми перевірили не лише «середній випадок», а й межі. Саме на межах найчастіше виникають помилки: «порожній рядок», «надто довгий рядок», «рівно на ліміті». Це як із ліфтом: доки людей 3 — усе працює, а от коли їх 10 і один із велосипедом — починаються пригоди.
Ще один поширений сценарій — «нормалізація» тексту. Наприклад, ми хочемо ігнорувати пробіли на краях: користувач вводить " buy milk ", а ми зберігаємо "buy milk". Це теж чиста функція.
#include <string>
std::string trim_spaces(std::string s) {
while (!s.empty() && s.front() == ' ') s.erase(s.begin());
while (!s.empty() && s.back() == ' ') s.pop_back();
return s;
}
І ось тест на граничні випадки. Зауважте: ми перевіряємо і порожній рядок, і рядок, що складається лише з пробілів.
#include <cassert>
#include <string>
std::string trim_spaces(std::string s);
int main() {
assert(trim_spaces(" hi ") == "hi"); // ok
assert(trim_spaces("") == ""); // ok
assert(trim_spaces(" ") == ""); // ok
}
Чому це «перше, що тестуємо»? Бо такі функції ви викликатимете з різних місць, вони невеликі, і якщо в них зʼявиться помилка, усе інше почне виглядати «дивно». А «дивно» — це найгірший вид бага: він не падає, він живе.
4. Парсери: рядок → дані
Парсер — це місце, де реальні рядки намагаються перетворитися на вашу акуратну структуру даних. А реальність, як ми знаємо, не надто любить бути акуратною: зайві пробіли, порожні рядки, done -7, done abc, add без тексту, add — і все це неодмінно станеться саме тоді, коли ви вже «ніби закінчили».
Добра новина: парсери майже завжди детерміновані. Даєте рядок — отримуєте результат. Отже, їх зручно тестувати. Ба більше, їх дуже вигідно тестувати, тому що ручна перевірка парсерів — це нескінченне інтерактивне введення, де ви щоразу набираєте команди й втомлюєтеся швидше, ніж компілюється проєкт.
Почнімо з маленького парсера числа: рядок → std::optional<int>. Якщо не вийшло — std::nullopt.
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> parse_int(std::string_view s) {
int value = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
return value;
}
Тестуємо типові та граничні випадки: коректне число, відʼємне число, сміття, порожній рядок.
#include <cassert>
#include <optional>
#include <string_view>
std::optional<int> parse_int(std::string_view s);
int main() {
assert(parse_int("42") == std::optional<int>{42}); // ok
assert(parse_int("-7") == std::optional<int>{-7}); // ok
assert(parse_int("7x") == std::nullopt); // ok
assert(parse_int("") == std::nullopt); // ok
}
Тепер зробімо парсер команди для Tasky. Нам поки не потрібен std::variant, тож опишемо команду простим struct + enum class.
#include <optional>
#include <string>
enum class CommandType { Add, Done, List };
struct Command {
CommandType type;
std::string text; // для add
int id = 0; // для done
};
Найпростіший парсер: якщо рядок list → команда List, якщо починається з add → Add, якщо done + число → Done. Усе інше — «не розпізнали» (std::nullopt).
#include <optional>
#include <string>
#include <string_view>
std::optional<Command> parse_command(std::string_view line) {
if (line == "list") return Command{CommandType::List, "", 0};
if (line.starts_with("add ")) return Command{CommandType::Add, std::string(line.substr(4)), 0};
if (line.starts_with("done ")) {
auto id = parse_int(line.substr(5));
if (!id) return std::nullopt;
return Command{CommandType::Done, "", *id};
}
return std::nullopt;
}
А тепер найцікавіше: тести тут потрібні не для «одного прикладу», а для набору типових поломок. Для парсерів так і проситься табличка.
| Вхід | Очікуємо |
|---|---|
|
List |
|
Add із полем text |
|
Add із порожнім полем text (і це вже привід вирішити, чи має це забороняти бізнес‑логіка, чи парсер) |
|
Done з id = 3 |
|
|
|
|
Міні‑тест — без циклів, просто щоб було видно, що саме ми перевіряємо:
#include <cassert>
#include <optional>
#include <string_view>
std::optional<Command> parse_command(std::string_view line);
int main() {
assert(parse_command("list").has_value()); // ok
assert(parse_command("done 3")->id == 3); // ok
assert(parse_command("done abc") == std::nullopt); // ok
}
Зауважте важливий дизайнерський момент: ми спеціально не змішуємо «розпізнавання команди» і «правила завдання». Парсер відповідає на запитання «яку команду користувач мав на увазі з погляду синтаксису?», а бізнес‑логіка пізніше вирішує: «чи можна це виконати?».
5. Бізнес‑логіка: правила предметної області
Бізнес‑логіка звучить як термін із корпоративних презентацій, але в навчальному проєкті це просто «правила гри». Наприклад: завдання не може мати порожню назву, завдання має мати унікальний id, done для неіснуючого id — це помилка, а не «ну гаразд». Саме це і є поведінка, важлива для користувача, і саме вона найчастіше ламається під час змін.
Саме бізнес‑логіка зазвичай спричиняє найболючіші регресії: програма продовжує працювати, але робить щось неправильно. А це гірше за падіння, тому що падіння хоча б чесне.
Опишемо модель завдання:
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Тепер — правило додавання: не можна додавати завдання з некоректною назвою. Винесемо це у функцію, яка не друкує, а повертає bool. Тоді її легко тестувати.
#include <vector>
bool add_task(std::vector<Task>& tasks, int id, std::string title) {
title = trim_spaces(title);
if (!is_valid_title(title)) return false;
tasks.push_back(Task{id, title, false});
return true;
}
І тест «успіх/неуспіх»:
#include <cassert>
#include <vector>
bool add_task(std::vector<Task>& tasks, int id, std::string title);
int main() {
std::vector<Task> tasks;
assert(add_task(tasks, 1, "Buy milk")); // ok
assert(tasks.size() == 1); // ok
assert(!add_task(tasks, 2, " ")); // ok (після trim стане порожньо)
}
Далі — операція «позначити завдання виконаним». Тут зручно повертати простий результат: якщо завдання знайдено, змінюємо його й повертаємо true, інакше — false. Знову ж таки: жодного cout, лише контракт.
#include <vector>
bool mark_done(std::vector<Task>& tasks, int id) {
for (auto& t : tasks) {
if (t.id == id) { t.done = true; return true; }
}
return false;
}
Тест:
#include <cassert>
#include <vector>
bool mark_done(std::vector<Task>& tasks, int id);
int main() {
std::vector<Task> tasks{{1, "A", false}};
assert(mark_done(tasks, 1)); // ok
assert(tasks[0].done); // ok
assert(!mark_done(tasks, 999)); // ok
}
Зверніть увагу, наскільки по‑людськи читаються ці тести: наче коротка історія. Підготували дані → виконали дію → перевірили результат. Ви ще не називали це AAA, але фактично вже мислите в цьому стилі.
І тепер добре видно, що саме вигідно тестувати першим:
Чисті функції — тому що це цеглинки. Парсери — тому що в них багато варіантів введення. Бізнес‑логіка — тому що в ній закладено сенс програми. А от main() і діалогові цикли зазвичай краще тримати тонкими й не намагатися тестувати з першого дня.
6. Що не варто тестувати насамперед
Дуже хочеться, особливо після першого успіху з assert, почати тестувати все підряд, зокрема й те, «як гарно виводиться список завдань». Зазвичай це пастка: виведення — річ крихка, воно залежить від пробілів, форматування, локалі й швидко перетворює тести на режим «охороняємо пробіли».
Мʼяко кажучи, тестувати std::cout з нуля — це як учитися водити одразу на фурі заднім ходом у вузькому дворі. Можна, але навіщо?
Правильна стратегія така: якщо якусь частину програми важко тестувати, це часто сигнал не «ну гаразд, не тестуємо», а «треба розділити відповідальність». Нехай main() читає рядок, викликає parse_command, потім викликає add_task/mark_done, а вже після цього виводить користувачеві повідомлення. Тоді основну поведінку перевіряють тести, а друк лишається тонкою оболонкою.
Цю ідею зручно подати так:
flowchart LR
A["main(): ввід/вивід"] --> B["parse_command(line)"]
B --> C["apply_command(tasks, cmd)"]
C --> D["результат або статус"]
D --> A
Тестуємо насамперед B і C (та функції всередині них), тому що вони мають стабільні вхідні та вихідні дані. main() лишається мінімальним і «майже не ламається».
Тести на assert як швидкий старт
Іноді здається, що «без тест‑фреймворка тестів не буває». Це міф. На старті цілком можна зробити окремий файл, який просто запускається і завершується з помилкою, якщо контракт порушено. CI‑системи й будь-які автоматичні перевірки розуміють просте правило: код виходу 0 — успіх, не 0 — провал. А assert якраз уміє «гучно сигналізувати», якщо умова хибна.
Приклад простого тестового файла для Tasky, який перевіряє три найвигідніші зони: чисту функцію, парсер, бізнес‑операцію.
#include <cassert>
#include <vector>
int main() {
assert(is_valid_title("Ok")); // ok
assert(parse_int("12") == 12); // ok
std::vector<Task> tasks;
assert(add_task(tasks, 1, " Read ")); // ok
assert(tasks[0].title == "Read"); // ok
}
Важливо памʼятати нюанс: у деяких збірках assert може бути вимкнено (наприклад, якщо визначено NDEBUG). Це не причина «не писати перевірки», а лише привід розуміти, що assert — це швидкий старт, а повноцінні тести краще запускати окремим тестовим виконуваним файлом. Але цим ми займемося трохи пізніше; сьогодні наша мета — вибрати правильні цілі для тестування, а не сперечатися про релігію тест‑фреймворків.
7. Типові помилки під час вибору «що тестувати першим»
Помилка № 1: тестувати оболонку замість логіки.
Новачки часто починають із перевірки того, що програма виводить «Меню: 1) додати 2) список». Це створює відчуття бурхливої діяльності, але майже не захищає від реальних поломок. Набагато корисніше винести обчислення, перевірки й операції у функції та тестувати саме їх, а main() залишити тонким.
Помилка № 2: писати один «мега‑тест на все одразу».
Іноді пишуть один тест, який додає завдання, потім робить done, потім list, потім ще щось, і наприкінці один assert(true). Такий тест важко читати й важко виправляти: якщо він упав, незрозуміло, де саме і чому. Краще тестувати невеликими сценаріями: один тест — одне правило поведінки.
Помилка № 3: ігнорувати граничні випадки.
Тест «add звичайне завдання» майже завжди проходить. А от порожня назва, рядок із пробілів, done abc, done -1, done 999 — це ті місця, де реальний користувач (або ви самі за тиждень) обовʼязково натрапить на проблему. Парсери й валідація без граничних тестів — як парасоля з діркою: формально вона є, а фактично ви мокнете.
Помилка № 4: змішувати відповідальність парсера і бізнес‑логіки.
Якщо парсер починає вирішувати, «чи можна додавати таке завдання» (а бізнес‑логіка — «як розпізнати команду»), код стає заплутаним, а тести — неприродними. Зручніше тримати межу: парсер робить «рядок → команда», бізнес‑логіка робить «команда → зміна даних», і тоді тести стають природними.
Помилка № 5: робити тести недетермінованими.
Якщо тест залежить від поточного часу, випадкових чисел або інтерактивного введення, він буде «то зелений, то червоний» без жодних змін у коді. Це руйнує довіру до тестів найшвидше. Перші тести мають бути залізобетонними: фіксовані входи, фіксований очікуваний результат, жодної магії.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ