Что, если ваши юнит-тесты врут?
Вы написали сотню юнит-тестов. Они проходят. Код на 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: в чём разница?
Блоги
Рекомендаций по книгам
Читайте также