Property-based тестирование в C++: ловите баги, которые вы не искали

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Что, если ваши юнит-тесты врут?

Вы написали сотню юнит-тестов. Они проходят. Код на C++ выглядит идеально. Но когда вы запускаете приложение в production, оно падает с segmentation fault на 100500-м запросе. Знакомая боль?

Традиционные юнит-тесты проверяют лишь то, что вы сами придумали. Они слепы к сценариям, которые не пришли вам в голову. Property-based тестирование (или property-based testing, PBT) решает эту проблему радикально: оно не проверяет конкретные примеры, а доказывает свойства (properties) вашего кода для миллионов случайных входных данных.

В этой статье мы разберём, как внедрить property-based тестирование в C++ с помощью библиотеки RapidCheck, и увидим, как оно ловит баги, которые обычные юнит-тесты пропускают. Готовы заставить ваш код страдать по-настоящему? Поехали.

Property-based тестирование: философия за 30 секунд

Представьте, что вы тестируете функцию сортировки. Обычный тест: sort([3,1,2]) == [1,2,3]. Ок, проверили один случай.

Property-based подход: «Для любого списка целых чисел, после сортировки длина не меняется, элементы расположены по возрастанию, а каждый элемент исходного списка присутствует в отсортированном». Фреймворк генерирует сотни случайных списков (включая пустые, с дублями, с отрицательными числами) и проверяет эти свойства.

Ключевая идея: вы описываете инварианты (properties), которые должны выполняться всегда, а не конкретные входы и выходы.

Когда PBT особенно полезно в C++?

  • Алгоритмы и структуры данных (сортировки, поиск, графы)
  • Парсеры и сериализаторы (кодирование/декодирование)
  • Математические функции (обратные операции, диапазоны значений)
  • Работа с памятью (утечки, двойное освобождение)
  • Любой код, где есть чёткие инварианты

RapidCheck: ваш новый лучший друг

В C++ есть несколько библиотек для PBT, но RapidCheck — самая зрелая и удобная. Она вдохновлена Haskell-библиотекой QuickCheck и прекрасно интегрируется с Google Test и Catch2.

Установка проста: это header-only библиотека, достаточно скопировать файлы из include/rapidcheck в ваш проект. Или используйте Conan/vcpkg.

Первый тест: свойство обратной операции

Допустим, у нас есть функция, которая кодирует строку в base64 и декодирует обратно. Какое свойство очевидно? «Декодирование после кодирования возвращает исходную строку».

#include <rapidcheck.h>
#include <string>

std::string encode_base64(const std::string& input);
std::string decode_base64(const std::string& input);

int main() {
    rc::check("Декодирование после кодирования возвращает оригинал",
              [](const std::string& original) {
                  // Генератор строки уже встроен
                  auto encoded = encode_base64(original);
                  auto decoded = decode_base64(encoded);
                  RC_ASSERT(decoded == original);
              });
    return 0;
}

Что здесь происходит? RapidCheck генерирует случайные строки (включая пустые, с Unicode, с нулевыми байтами). Для каждой он проверяет свойство. Если находит контрпример — минимальный (shrinking), который всё ещё ломает тест. Это золото: вы получаете не просто «баг есть», а конкретный воспроизводимый случай.

Продвинутые свойства: больше чем равенство

Монотонность и идемпотентность

Для функции remove_duplicates:

  • Идемпотентность: если применить дважды, результат не изменится.
  • Порядок не важен: результирующий список содержит те же элементы, что и исходный, но без дублей.
#include <vector>
#include <algorithm>

std::vector<int> remove_duplicates(std::vector<int> input) {
    std::sort(input.begin(), input.end());
    input.erase(std::unique(input.begin(), input.end()), input.end());
    return input;
}

rc::check("Удаление дублей идемпотентно",
    [](const std::vector<int>& vec) {
        auto once = remove_duplicates(vec);
        auto twice = remove_duplicates(once);
        RC_ASSERT(once == twice);
    });

Обратите внимание: мы не задаём ни одного конкретного вектора. RapidCheck сам генерирует миллионы комбинаций.

Свойства для чисел с плавающей точкой

С float/double надо быть осторожнее. Вместо == используйте RC_ASSERT(std::abs(a - b) < 1e-6). Или проверяйте, что обратная операция возвращает результат в пределах допустимой погрешности.

Генераторы: создаём свой мир

RapidCheck поставляется с генераторами для стандартных типов, но вы можете создавать свои. Например, для тестирования структуры User:

struct User {
    std::string name;
    int age;
    bool is_admin;
};

// Специализация генератора для User
namespace rc {
template <>
struct Arbitrary<User> {
    static Gen<User> arbitrary() {
        return gen::construct<User>(
            gen::string<std::string>(),           // имя
            gen::inRange(0, 120),                  // возраст
            gen::element(false, true)              // флаг
        );
    }
};
}

Теперь вы можете писать:

rc::check("Пользователь с возрастом < 18 не может быть админом",
    [](const User& u) {
        RC_PRE(u.age < 18); // предусловие: только для несовершеннолетних
        RC_ASSERT(!u.is_admin);
    });

RC_PRE — это предусловие. Если оно не выполняется, тест пропускается, и генерируется новый набор данных. Это мощный инструмент для фильтрации.

Shrinking: почему это меняет всё

Допустим, RapidCheck нашёл баг на входе ["a", "b", "c", ... 1000 случайных символов ...]. Без shrinking вы бы получили гигантский лог, в котором сложно найти суть.

RapidCheck автоматически упрощает (shrinks) контрпример до минимального, который всё ещё вызывает ошибку. Вы увидите что-то вроде:

Falsifiable after 42 tests:
std::string original = "\x00\x01\x02";

Это спасло часы дебага. Вы сразу видите, что проблема в строке с нулевыми байтами.

Property-based vs Fuzzing: в чём разница?

Блоги

Рекомендаций по книгам