1. Введение
Когда слышишь «давайте писать тесты», мозг новичка часто рисует два ужаса: либо «тестировать придётся вообще всё» (и жить в тестах), либо «это для больших компаний, у нас тут учебный проект». Реальность спокойнее: тесты — это инструмент, а любой инструмент надо применять туда, где он быстрее всего окупается.
Проблема в том, что время и внимание ограничены. Если вы начнёте тестировать «красоту вывода в консоль» и «точное количество пробелов», вы очень быстро выгорите и решите, что тесты — зло. А если начать с правильных мест, то тесты начинают работать как ремень безопасности: вы пристёгнуты, и можете ехать быстрее, не боясь каждого поворота.
Ключевая идея лекции: первые тесты должны защищать то, что чаще всего ломается и труднее всего отлаживается вручную. Обычно это не «печать меню», а маленькие функции, парсинг строк и правила предметной области.
2. Простые критерии выбора: «дешево проверить» и «дорого сломать»
Чтобы выбирать осознанно, полезно иметь в голове простую модель. Хороший кандидат на unit‑тест — это кусок кода, который легко вызвать из теста (дать входные данные) и легко проверить (сравнить результат), а при поломке даёт неприятные последствия: неправильные данные, неверные правила, странные состояния.
Вам не нужно сложное управление рисками, достаточно «таблички здравого смысла»:
| Что тестируем | Почему это удобно | Типичная цена поломки |
|---|---|---|
| Чистые функции (вход → выход) | Детерминированы, не требуют окружения | Неверные вычисления/условия |
| Парсеры (строка → структура/число) | Много крайних случаев, легко фиксировать примерами | Команды «не распознаются», мусор в данных |
| Бизнес‑логика (правила и операции) | Самое «ценное поведение», часто меняется | Программа «работает», но делает неправильное |
| Ввод/вывод, интерактив, «меню» | Сложно стабильно проверять без инфраструктуры | Обычно не самая дорогая ошибка на старте |
Полезно также держать маленький «фильтр тестируемости». Если вы можете ответить «да» на эти вопросы, тест писать обычно просто: функция не читает std::cin, не печатает в std::cout, не зависит от времени/рандома, результат зависит только от параметров, и вы можете описать ожидаемый результат парой примеров.
Чтобы окончательно снять мистику, вот схема принятия решения:
flowchart TD
A["Кусок логики, который хотим проверить"] --> B{"Есть явные входы и явный результат?"}
B -- "нет" --> C["Рефакторим: выносим логику из main/IO в функцию"]
B -- "да" --> D{"Зависит от IO, времени, рандома?"}
D -- "да" --> E["Отделяем: оставляем IO снаружи, логику внутри"]
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;
};
Теперь — правило добавления: нельзя добавлять задачу с невалидным title. Вынесем это в функцию, которая не печатает, а возвращает 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 станет пусто)
}
Дальше — операция «пометить задачу выполненной». Здесь очень удобно использовать optional: если задача найдена — меняем и возвращаем 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: делать тесты недетерминированными.
Если тест зависит от текущего времени, случайных чисел или интерактивного ввода, он будет «то зелёный, то красный» без изменения кода. Это ломает доверие к тестам быстрее всего. Первые тесты должны быть железобетонными: фиксированные входы, фиксированный ожидаемый результат, никакой магии.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ