JavaRush /Курсы /C++ SELF /Что тестировать в первую очередь

Что тестировать в первую очередь

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

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"
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;
};

Теперь — правило добавления: нельзя добавлять задачу с невалидным 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: делать тесты недетерминированными.
Если тест зависит от текущего времени, случайных чисел или интерактивного ввода, он будет «то зелёный, то красный» без изменения кода. Это ломает доверие к тестам быстрее всего. Первые тесты должны быть железобетонными: фиксированные входы, фиксированный ожидаемый результат, никакой магии.

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