Тестирование

Property-based тестирование в C++: RapidCheck и его применение

Введение

Традиционные юнит-тесты проверяют конкретные, заранее придуманные примеры входных данных («при a=2, b=3 ожидаем 5»). Property-based тестирование переворачивает подход: вместо конкретных примеров формулируется общее свойство, которое должно выполняться для любых входных данных («результат сложения всегда больше или равен каждому из слагаемых для неотрицательных чисел»), а фреймворк сам генерирует множество случайных входных данных, пытаясь найти контрпример, нарушающий свойство. RapidCheck — одна из библиотек, реализующих этот подход для C++, с интеграцией в QTest и другие фреймворки. В статье разберём базовое использование RapidCheck.

Концепция

В property-based тестировании задача разработчика — не придумать конкретные тестовые случаи, а сформулировать инвариант, который должен сохраняться для всего класса входных данных. RapidCheck генерирует случайные значения нужных типов (используя генераторы — встроенные для примитивных типов и контейнеров, либо кастомные для пользовательских типов), вызывает проверяемую функцию и проверяет заданное свойство через RC_ASSERT. При обнаружении нарушающего свойство примера RapidCheck автоматически пытается «сократить» (shrink) входные данные до минимального воспроизводящего проблему случая, что значительно облегчает диагностику по сравнению со случайным «сырым» входом.

Пример кода

#include <rapidcheck.h>
#include <vector>
#include <algorithm>

// Функция, которую тестируем: собственная реализация сортировки
std::vector<int> mySort(std::vector<int> input)
{
    std::sort(input.begin(), input.end());
    return input;
}

int main()
{
    // Свойство 1: результат сортировки всегда отсортирован по возрастанию
    rc::check("результат отсортирован по возрастанию",
        [](const std::vector<int> &input) {
            const auto sorted = mySort(input);
            RC_ASSERT(std::is_sorted(sorted.begin(), sorted.end()));
        });

    // Свойство 2: сортировка не меняет количество элементов
    rc::check("сортировка сохраняет размер вектора",
        [](const std::vector<int> &input) {
            const auto sorted = mySort(input);
            RC_ASSERT(sorted.size() == input.size());
        });

    // Свойство 3: сортировка не теряет и не добавляет элементы (идемпотентность для мультимножества)
    rc::check("сортировка дважды равна сортировке один раз",
        [](const std::vector<int> &input) {
            const auto sortedOnce = mySort(input);
            const auto sortedTwice = mySort(sortedOnce);
            RC_ASSERT(sortedOnce == sortedTwice);
        });

    return 0;
}
// Интеграция RapidCheck с QTest через rapidcheck/qt-extras (упрощённый пример)
#include <QtTest/QtTest>
#include <rapidcheck.h>

class TestSortProperties : public QObject
{
    Q_OBJECT
private slots:
    void testSortIsIdempotent()
    {
        const bool success = rc::check([](const std::vector<int> &input) {
            auto once = input;
            std::sort(once.begin(), once.end());
            auto twice = once;
            std::sort(twice.begin(), twice.end());
            RC_ASSERT(once == twice);
        });
        QVERIFY(success);
    }
};

QTEST_APPLESS_MAIN(TestSortProperties)
#include "test_sortproperties.moc"

Пояснения к коду

Каждый вызов rc::check() принимает строковое описание свойства и лямбду, параметром которой RapidCheck автоматически передаёт случайно сгенерированное значение нужного типа (в примерах — std::vector<int>, для которого у RapidCheck есть встроенный генератор, включая граничные случаи вроде пустого вектора). Внутри лямбды RC_ASSERT проверяет сформулированное свойство — если оно нарушается хотя бы для одного из множества сгенерированных входов, RapidCheck сообщает минимальный воспроизводящий пример после автоматического сокращения (shrinking).

Второй пример показывает интеграцию с QTest — rc::check() возвращает булево значение успеха всего набора случайных проверок, которое можно передать в обычный QVERIFY, сохраняя совместимость с привычной структурой QTest-тестов и CI-раннером ctest.

Подводные камни

  • Property-based тестирование не заменяет, а дополняет example-based тесты. Конкретные граничные случаи, известные из требований или прошлых багов («регрессионные» тесты), стоит сохранять как отдельные явные тестовые случаи — генератор случайных данных не гарантированно «наткнётся» именно на исторически проблемный кейс при каждом запуске (если не зафиксировать seed).
  • Формулирование слишком слабых свойств. Свойство «функция не крашится» — валидное, но малополезное свойство; ценность property-based тестов прямо зависит от того, насколько точно сформулированный инвариант отражает реальные ожидания от поведения функции.
  • Недетерминированность запуска без фиксированного seed. По умолчанию RapidCheck использует случайный seed при каждом запуске, и тест, упавший один раз на конкретном случайном входе, может не воспроизвестись на следующем запуске — RapidCheck выводит seed обнаруженного сбоя, и его нужно зафиксировать вручную для воспроизведения проблемы при отладке.
  • Написание кастомных генераторов для сложных пользовательских типов требует заметных дополнительных усилий по сравнению с встроенными типами — для проектов с богатой собственной доменной моделью это может стать барьером входа для команды, незнакомой с подходом.