JavaRush /Курсы /C++ SELF /AAA: Arrange‑Act‑Assert и табличные наборы кейсов

AAA: Arrange‑Act‑Assert и табличные наборы кейсов

C++ SELF
37 уровень , 1 лекция
Открыта

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. Табличные наборы кейсов

Табличные кейсы: меньше копипасты, больше смысла

Когда у вас 23 сценария, можно писать их руками. Когда сценариев 1020 (а для парсинга и валидации так обычно и бывает), копипаста становится вашим главным «фреймворком». Он же вашим главным багом и будет: в одном месте забыли поменять 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, при падении не всегда понятно, какой именно кейс не прошёл. Без имени кейса или вывода входных данных отладка превращается в угадайку. Минимальная маркировка (имя кейса или печать входа) делает диагностику предсказуемой и избавляет от лишних раскопок.

1
Задача
C++ SELF, 37 уровень, 1 лекция
Недоступна
Датчик направления
Датчик направления
1
Задача
C++ SELF, 37 уровень, 1 лекция
Недоступна
Деление без аварий
Деление без аварий
1
Задача
C++ SELF, 37 уровень, 1 лекция
Недоступна
Прайс парсер
Прайс парсер
1
Задача
C++ SELF, 37 уровень, 1 лекция
Недоступна
Команда пополнения
Команда пополнения
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ