1. Зачем тестам структура
Когда вы только начинаете писать тесты, кажется: «Да что там, два assert и готово». И это правда… примерно на три теста. На четвёртом тест начинает обрастать подготовкой данных, какими-то проверками, повторением одного и того же кода, и внезапно вы уже не понимаете: это тест проверяет функцию, или функция проверяет вашу психику на прочность.
Типичный «бесформенный» тест выглядит так: много действий вперемешку, ожидания спрятаны, а проверка результата теряется где-то между «я тут поправил строку» и «ой, ещё надо второй кейс».
#include <cassert>
#include <optional>
#include <string_view>
std::optional<int> parse_amount(std::string_view s); // уже есть в нашем проекте
int main() {
auto x = parse_amount(" 10"); // а пробелы должны быть разрешены или нет?
assert(!x.has_value()); // почему именно так — неочевидно
assert(parse_amount("10").value() == 10); // тут вообще value() может упасть
}
Такой код может работать, но он плохо читается и плохо расширяется. Именно поэтому тестам нужна «геометрия» — стандартная форма, чтобы мозг сразу видел: где входы, где действие, где проверка.
2. AAA: Arrange → Act → Assert
AAA как «скелет» теста
AAA — это очень простая дисциплина, которая делает тесты предсказуемыми и читаемыми. Мы делим тест на три логические части: подготовка, действие, проверка.
Представьте тест как мини-сценку: сначала мы раскладываем реквизит (Arrange), потом делаем одно ключевое действие (Act), а потом сравниваем, что получилось, с тем, что ожидали (Assert). Эта структура не «для красоты», а для того, чтобы при провале вы быстро понимали: сломалась функция или вы запутались в тесте.
flowchart LR
A[Arrange: входы и ожидаемое] --> B[Act: один вызов/действие]
B --> C[Assert: сравнение got vs expected]
Важно: AAA — не религия и не закон физики, но если вы его придерживаетесь, тесты меньше превращаются в роман на 200 строк «как я дошёл до этой проверки».
Arrange: готовим входы и ожидаемый результат
В части Arrange мы должны сделать две вещи: подготовить входные данные и честно зафиксировать ожидаемый результат. Здесь очень легко случайно «смухлевать»: например, вычислить expected с помощью той же логики, которую мы тестируем. Тогда тест становится самоподтверждающейся легендой: «функция работает, потому что я проверяю её результат тем же алгоритмом».
Для нашего учебного консольного приложения (пусть это будет маленький BudgetBuddy, где мы парсим суммы расходов) у нас есть функция parse_amount: она принимает строку и возвращает std::optional<int> — либо число, либо «не получилось распарсить».
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> parse_amount(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() || value < 0) return std::nullopt;
return value;
}
Теперь пример хорошего Arrange: мы явно пишем, что подаём на вход и чего ждём.
// Arrange
const std::string_view input = "250";
const std::optional<int> expected = 250;
А вот пример «скользкого» Arrange: expected вычисляется чем-то похожим на тестируемое.
// Arrange (плохо: expected вычисляем "почти тем же способом")
const std::string_view input = "250";
const std::optional<int> expected = parse_amount(input); // тест стал бессмысленным
Если тест падает, вы хотите увидеть: «ожидал X, получил Y». Если expected тоже получен через parse_amount, вы ожидаете… то, что получит parse_amount. Очень удобно. И абсолютно бесполезно.
Act: одно действие, один «герой сцены»
В части Act мы выполняем ровно одно ключевое действие. Обычно это один вызов функции, которую тестируем. Почему так строго? Потому что если в Act вы делаете два-три вызова, а потом проверяете всё кучей, то при падении теста непонятно, что именно сломалось: первый вызов, второй, или ваша «склейка» между ними.
В нашем примере Act — это просто вызов parse_amount и сохранение результата в переменную got («получили»).
// Act
const std::optional<int> got = parse_amount(input);
Обратите внимание: мы почти всегда сохраняем результат в переменную, даже если можно написать проверку сразу. Это улучшает читаемость: взглядом видно, что именно является результатом действия, и его удобно вывести/логировать, если мы делаем более подробную диагностику.
Assert: проверяем «что получилось», а не «как получилось»
Часть Assert — это место, где мы сравниваем ожидаемое с реальным. И здесь есть два типичных желания новичка: либо сделать проверку слишком слабой («ну не nullopt же — значит норм»), либо слишком «внутренней» («пусть функция сделала ровно 3 шага цикла»). В unit‑тестах нам важнее контракт поведения: вход → результат.
С std::optional<int> удобно сравнивать напрямую: optional поддерживает operator==. То есть можно сравнить got с std::optional<int>{250} или с std::nullopt.
#include <cassert>
#include <optional>
#include <string_view>
int main() {
// Arrange
const std::string_view input = "250";
const std::optional<int> expected = 250;
// Act
const auto got = parse_amount(input);
// Assert
assert(got == expected);
}
А теперь тест на ошибочный ввод:
#include <cassert>
#include <optional>
#include <string_view>
int main() {
// Arrange
const std::string_view input = "-5";
const std::optional<int> expected = std::nullopt;
// Act
const auto got = parse_amount(input);
// Assert
assert(got == expected);
}
Заметьте тонкость: мы не вызываем got.value() в тесте без нужды. Если got == std::nullopt, value() бросит нас в «ошибка выполнения», и вместо понятного сообщения «проверка не прошла» вы получите падение программы. Тест упал — да, но «красиво» не упал.
3. Табличные наборы кейсов
Табличные кейсы: меньше копипасты, больше смысла
Когда у вас 2–3 сценария, можно писать их руками. Когда сценариев 10–20 (а для парсинга и валидации так обычно и бывает), копипаста становится вашим главным «фреймворком». Он же вашим главным багом и будет: в одном месте забыли поменять expected, в другом — вход, в третьем — вообще тестируете не то.
Табличный подход (table‑driven tests) решает это просто: вы выносите данные (входы и ожидания) в таблицу, а код теста становится циклом по таблице. Тогда добавление нового кейса — это добавление одной строки данных, а не копирование блока из 8 строк.
Небольшая «честная» таблица сравнения:
| Подход | Как добавляется новый кейс | Что обычно ломается |
|---|---|---|
| Копипаста | копируем 8–12 строк и правим | правки не везде, ожидание от другого кейса, забытый input |
| Таблица | добавляем одну запись {...} | максимум — ошиблись в данных, а не в логике теста |
Пример табличных кейсов для parse_amount:
#include <cassert>
#include <optional>
#include <string_view>
#include <vector>
struct Case {
std::string_view input;
std::optional<int> expected;
};
int main() {
const std::vector<Case> cases{
{"0", 0},
{"15", 15},
{"-1", std::nullopt},
{"abc", std::nullopt},
};
for (const auto& tc : cases) {
const auto got = parse_amount(tc.input);
assert(got == tc.expected);
}
}
Здесь красиво то, что цикл всегда один и тот же. И если вы завтра добавите кейс "999999999999" (переполнение) — вы добавите всего одну строку, а не дублируете тестовый код.
Как не потерять читаемость: какой кейс упал
Когда тесты становятся табличными, появляется новая проблема: если assert сработал, вы знаете, что «что-то упало», но не всегда сразу понятно, на каких данных. Это лечится очень простым способом: добавьте в кейс поле name (или хотя бы индекс), и при провале печатайте понятное сообщение.
Мы пока не используем тест‑фреймворк (он будет в следующей лекции), поэтому сделаем минимальную проверку руками: если не совпало — печатаем и возвращаем 1. Это по сути «мини‑раннер», но очень маленький и полезный.
#include <iostream>
#include <optional>
#include <string_view>
#include <vector>
struct Case {
std::string_view name;
std::string_view input;
std::optional<int> expected;
};
int main() {
const std::vector<Case> cases{
{"zero", "0", 0},
{"ok", "15", 15},
{"neg", "-1", std::nullopt},
{"letters","abc", std::nullopt},
};
for (const auto& tc : cases) {
const auto got = parse_amount(tc.input);
if (got != tc.expected) {
std::cerr << "FAILED case: " << tc.name << "\n";
return 1;
}
}
return 0;
}
Теперь тест «падает» с конкретным именем кейса. Да, это ещё не «богатая диагностика», но уже на порядок лучше, чем молчаливый assert.
4. Практический пример: тестируем парсер команды add
Чтобы не тестировать абстрактные clamp и is_digit в вакууме (хотя они тоже полезны), давайте сделаем мини‑часть нашего BudgetBuddy: парсер команды add.
Формат пусть будет такой: пользователь вводит строку вида "add 250 lunch", и мы хотим получить структуру {amount=250, title="lunch"}. Если строка неправильная — возвращаем std::nullopt. Мы используем то, что вы уже знаете: std::istringstream и std::optional.
Модель результата:
#include <string>
struct AddCommand {
int amount = 0;
std::string title;
};
Функция парсинга:
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
std::optional<AddCommand> parse_add(std::string_view line) {
std::istringstream in(std::string(line));
std::string cmd, title;
int amount = 0;
if (!(in >> cmd >> amount >> title) || cmd != "add" || amount < 0) return std::nullopt;
return AddCommand{amount, title};
}
Теперь тесты по AAA. Сначала один «ручной» тест, чтобы почувствовать структуру.
#include <cassert>
#include <optional>
#include <string_view>
int main() {
// Arrange
const std::string_view input = "add 250 lunch";
const std::optional<AddCommand> expected = AddCommand{250, "lunch"};
// Act
const auto got = parse_add(input);
// Assert
assert(got.has_value());
assert(got->amount == expected->amount);
assert(got->title == expected->title);
}
Смотрится неплохо, но уже видно: сравнивать структуры по полям руками — скучновато. Пока мы не проходили перегрузку operator== в курсе на этом этапе (и тест‑фреймворк тоже позже), поэтому оставим сравнение по полям, но перейдём к табличному стилю.
Таблица кейсов:
#include <cassert>
#include <optional>
#include <string_view>
#include <vector>
struct ParseCase {
std::string_view input;
std::optional<int> expectedAmount;
};
int main() {
const std::vector<ParseCase> cases{
{"add 0 water", 0},
{"add 250 lunch", 250},
{"add -5 lunch", std::nullopt},
{"sub 10 lunch", std::nullopt},
};
for (const auto& tc : cases) {
const auto got = parse_add(tc.input);
const std::optional<int> gotAmount = got ? std::optional<int>{got->amount} : std::nullopt;
assert(gotAmount == tc.expectedAmount);
}
}
Это важный приём: если вам в конкретном тесте важно проверить только часть результата (например, сумму), вы можете привести результат к удобной для сравнения форме (optional<int>), и проверка останется чистой и простой.
Если понадобится проверить ещё и title, можно сделать отдельную таблицу именно на правило про title (например, «title обязательно одно слово»), вместо того чтобы превращать один тест в «проверку всего подряд».
5. Типичные ошибки при AAA и табличных кейсах
Ошибка №1: дублировать алгоритм в Arrange.
Иногда в секции Arrange начинают вычислять «ожидаемое значение» тем же способом, что и тестируемый код. В итоге тест сравнивает два одинаковых алгоритма и подтверждает лишь то, что они дают одинаковый результат. Это не проверка поведения, а самоподтверждение. Ожидание должно быть либо заранее известно, либо вычислено другим, независимым способом.
Ошибка №2: перегружать Act несколькими действиями.
В Act порой складывают целую цепочку: «распарсили → нормализовали → отсортировали», а затем делают один общий Assert. Если тест падает, становится непонятно, какой именно шаг нарушил контракт. Act лучше держать как один главный вызов. Если нужно проверить цепочку — это отдельный сценарий и отдельный тест.
Ошибка №3: превращать табличные кейсы в «свалку всего».
Частая проблема — одна гигантская таблица, где в одном цикле проверяются и корректные входы, и ошибки, и крайние случаи. Такая таблица теряет фокус: каждый кейс проверяет своё правило, и логика теста размывается. Намного понятнее, когда одна таблица отвечает за одно конкретное правило, например: «функция принимает только неотрицательные числа».
Ошибка №4: не подписывать кейсы при использовании assert.
Если используется голый assert, при падении не всегда понятно, какой именно кейс не прошёл. Без имени кейса или вывода входных данных отладка превращается в угадайку. Минимальная маркировка (имя кейса или печать входа) делает диагностику предсказуемой и избавляет от лишних раскопок.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ