JavaRush /Курси /C++ SELF /Що тестувати насамперед

Що тестувати насамперед

C++ SELF
Рівень 37 , Лекція 0
Відкрита

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"
List
"add Buy milk"
Add із полем text
"Buy milk"
"add "
Add із порожнім полем text (і це вже привід вирішити, чи має це забороняти бізнес‑логіка, чи парсер)
"done 3"
Done з id = 3
"done abc"
nullopt
""
nullopt

Міні‑тест — без циклів, просто щоб було видно, що саме ми перевіряємо:

#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: робити тести недетермінованими.
Якщо тест залежить від поточного часу, випадкових чисел або інтерактивного введення, він буде «то зелений, то червоний» без жодних змін у коді. Це руйнує довіру до тестів найшвидше. Перші тести мають бути залізобетонними: фіксовані входи, фіксований очікуваний результат, жодної магії.

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