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 та 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 кроки циклу»). У юніт-тестах нам важливіший контракт поведінки: вхід → результат.

Зі 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 << "Не пройшов кейс: " << 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, у разі падіння не завжди зрозуміло, який саме кейс не пройшов. Без імені кейса або виведення вхідних даних налагодження перетворюється на вгадування. Мінімальне позначення (імʼя кейса або друк входу) робить діагностику передбачуваною й позбавляє зайвих пошуків.

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