image


При unit-тестированиии кода рано или поздно встает вопрос тестовых данных. И если в одном случае достаточно просто несколько жестко зашитых переменных, то в других случаях необходимы сколько-нибудь большие и случайные данные. В управляемом мире нет проблем с генерацией пользовательских типов (взять тот же Autofixture), но мир C++ зачастую вызывает боль и страдание (поправьте меня, если это не так). Не так давно я познакомился с замечательной библиотекой boost::di и под ее влиянием у меня начала созревать идея библиотеки, которая позволила бы C++ программистам генерировать пользовательские типы данных, забитых случайными значаниями, и это не потребовало бы предварительного их описания. Получилось что-то вроде:


struct dummy_member{
    float a;
    int b;
};
struct dummy{
    explicit dummy(dummy_member val, std::string c) : val_(val), c_(c) {}
private:
    dummy_member val_;
    std::string c_;
};
int main(int argc, char* argv){
    auto d = datagen::random<dummy>();
    return 0;
}

> Ссылка на код. Библиотека header-only,C++14. Всех интересующихся прошу под кат.


Основные возможности библиотеки


  • Генерация встроенных типов
  • Генерация пользовательских типов(==не встроенных)
  • Ограничители на множество генерируемых значений

Генерация встроенных типов


Естественно поддерживается генерация встроенных типов (char,wchar_t и прочее). При этом целочисленные типы генерируются просто как набор битов, а float и double — как сумма случайного целочисленного (int32_t и int64_t соответственно) и случайного значений в интервале от -1 до 1. Для генерации bool значения используется сравнение двух случайных целых.


std::cout << "The answer to the question of everything is:" << datagen::random<int>() << std::endl;

Генерация пользовательских типов.


Для генерации пользовательских типов за основу была взята та же идея, что и в boost::di (спасибо ее автору), а именно возможность написания универсального типа any_type, неявно конвертируемого в любой другой(за редким исключения). Добавив НЕМНОГО шаблонов, получилась штука, генерирующая пользовательские типы, используя следующие средства:


  1. Определенный пользователем алгоритм генерации.
  2. Генерация на основе публичного конструктора с максимальным количеством параметров. Все как в примере в начале статьи (struct dymmy).
  3. Генерация на основе {}-инициализации. Все так же, как в первом примере (struct dummy_member).

Для генерации объектов на основе определенной пользователем процедуры необходимо частично или полностью специализировать шаблон


template<> struct datagen::value_generation_algorithm<TType> { 
    TType get_random(random_source_base&); 
};

Это добавляет возможность выносить некоторые параметры генерации типов в члены этого класса, что в свою очередь позволяет влиять на генерацию типов. Например, алгоритм генерации строк из std выглядит так:


namespace datagen{
    template <class CharType, class Traits, class Allocator>
    struct value_generation_algorithm<std::basic_string<CharType, Traits, Allocator>>{
        using string_t = std::basic_string<CharType, Traits, Allocator>;
        string_t get_random(random_source_base& r_source){...};
        size_t min_size{0};
        size_t max_size{30};
        std::basic_string<CharType> alphabet{"abcd...6789"};
    };
}

Ограничители на множество генерируемых значений


В библиотеке поддерживаются ограничители на генерируемые значения, например:


std::cout << "The answer to the question of everything is:" << random<int>(between(42,42)) << std::endl;

Есть 2 вида ограничителей:


  1. Ограничители на алгоритм генерации. С их помощью можно менять значения параметров в классе value_generation_algorithm<T>.
  2. Ограничители (скорее корректоры) на уже сгенерированное значение.

При это они могут быть использованы 2 способами:


  1. Передача их в качестве параметра в функцию random, как в примере выше. В этом случае они будут применены только к текущему алгоритму/значению.
  2. Создание на их основе scoped_limit и применение его к набору типов. Тогда ограничитель применяется для всех указанных типов для всей глубины генерируемого дерева типов на протяжении жизни scoped_limit-а.

Для создания пользовательских ограничителей необходимо объявить структуру/класс ограничителя и реализовать одну или обе функции:


struct dummy_algorithm_limit{};
struct dummy_value_limit{};

namespace datagen{
    namespace limits    {
        void adjust_algorithm(random_source_base&, dummy_algorithm_limit const& l, value_generation_algorithm<dummy>& a){
        // здесь можно подправить параметры генерации dummy
        }

        void adjust_value(random_source_base&, dummy_value_limit const& l, dummy& a){
        //здесь можно подправить сгенерированное значение dummy
        }
    }
}

Ограничения применяются в следующем порядке:


  1. scoped_limit для алгоритмов
  2. параметрические ограничители на алгоритм
    здесь идет генерация дерева объектов
  3. scoped_limit для значений
  4. параметрические ограничители на значения

Общая информация


Источником энтропии в библиотеке является класс random_source_impl, использующий <random>. Но есть возможность переопределить это, предоставив на этапе компиляции специализацию структуры random_source_instance<int>.
На сегодня реализована (собственно то, что необходимо мне в работе) генерация следующих контейнеров из stl:


  • std::array
  • std::map
  • std::set
  • std::string
  • std::vector

пары типов из boost:


  • boost::asio::ip::address (v4,v6)
  • boost::optional
  • boost::posix_time::ptime
  • boost::posix_time::time_duration

Ограничители для них:


  • between для встроенных типов и не только
  • greater_than,less_than, odd, even
  • container_size::between, container_size::less_than и т.д.
  • alphabet::consists_of, alphabet::does_not_contain

Тестировалось на компиляторе msvc-14.0, требует c++14. К сожалению, gcc ведет себя немного по-другому, в следствие чего код библиотеки на собрался под mingw (gcc-6.3.0), но думаю те, кто имеет с ним постоянный контакт, смогут быстро это поправить.
Библиотека лежит в открытом доступе. Идеи и реализации новых типов приветствуются.

А как вы генерируете случайные данные для C++ unit-тестов?

Проголосовал 51 человек. Воздержалось 25 человек.

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

Поделиться с друзьями
-->

Комментарии (16)


  1. apro
    15.04.2017 15:26

    А оставшаяся часть подобного тестирования, а именно — получив ошибку на каком-то случайном наборе данных, пытаемся упростить набор данных,
    до тех пор пока ошибка воспроизводится, реализована в каком-нибудь
    проекте для C++?


    1. ukhegg
      15.04.2017 15:29

      Нет, ни с чем подобным я не встречался. Однако интересную мысль вы подкинули-добавить возможность записи генерируемых данных с целью их последующего воспроизведения.


      1. apro
        15.04.2017 15:39
        +1

        Нет, ни с чем подобным я не встречался.

        Странно, просто все библиотеки для других языков с которыми я работал это умеют "из коробки",
        например quickcheck для Haskell, или его аналог для Rust, там эта фича называется "shrink".


        1. ukhegg
          15.04.2017 15:47
          +1

          но мир C++ зачастую вызывает боль и страдание
          ;)


    1. Xop
      17.04.2017 20:52
      +1

      Я реализовал такое, но на C99. Даже почти довел до первого релиза, думал статью на хабре написать, но в какой-то момент очень резко кончилось свободное время. Поэтому похоже вместо статьи будет этот коммент.


      Основной целью было сделать тестовый фреймворк, в котором максимально просто писать тесты, которым можно тестировать plain C код, и для работы которого не обязательно наличие системного malloc. Насколько получилось — можно посмотреть тут. К сожалению пока там есть места с говнокодом, с неконсистентыми именами (с этим вообще беда, иногда подолгу метался между разными вариантами) и не все покрыто тестами — но оно поэтому и не релиз пока что. Зато вроде получилось действительно просто писать тесты (тестирую фреймворк самим собой), и реализовано упрощение тестовых наборов при падении теста. Ну и если кто-то будет смотреть — хотелось бы обратной связи — оно вообще в таком виде кому-то надо/интересно? Стоит ли продолжать работу?


  1. JegernOUTT
    15.04.2017 15:54
    +1

    В текущем проекте есть нечто подобное, привязанное к boost::fusion и rttr (можно так же добавить boost::hana и magic_get) и умеющее генерировать рандомные объекты pod-типов :) для тестов различных сериализаций / десериализаций / orm-ов / и т.п. отлично подходит)


  1. marsianin
    15.04.2017 21:21

    Почему был выбран именно такой метод генерации случайных float и double? Правильно ли я понимаю, что библиотека не вернёт NaN в качестве случайного float или double? Также, как мне кажется, крайне мала вероятность получить subnormal или бесконечность.


    1. ukhegg
      16.04.2017 11:08

      пробовал генерировать float и double просто как набор бит, и как то числа 1.45e+240 ну совсем ни о чем. Готов выслушать любые предложения. А Nan можно получить, специализировав алгоритм генерации и там просто условно выбирать Nan или что-то ощутимое


      1. marsianin
        16.04.2017 21:55

        Для генерации вещественных чисел, как мне кажется, имеет смысл посмотреть на алгоритмы из Berkley Testfloat. http://www.jhauser.us/arithmetic/TestFloat.html


      1. Xop
        18.04.2017 00:05
        +1

        Посмотрите питоновскую библиотеку hypothesis — в плане идей "как надо делать" это просто праздник какой-то.


  1. nolled
    15.04.2017 21:53
    +1

    Тестирование на рандомных объектах немного антипаттерн, тесты будут то фейлится то нет.


    1. apro
      16.04.2017 02:03

      Тестирование на рандомных объектах немного антипаттерн, тесты будут то фейлится то нет.

      Здесь играет роль размер выборки, ведь можно сгенерировать несколько миллионов случайных
      вариантов. Плюс главное чтобы хоть раз зафейлился, потом берется данные на которых
      зафейлился и после починки добавляются в тест напрямую в качестве дополнения к генерируемых случайным
      образом. Плюс конечно очевидные крайние случаи стоит тестировать напрямую, типа пустое значение, максимальное значение и т.д.


      1. nolled
        16.04.2017 09:26
        -1

        ok. Есть функция, причем очень простая:

        int Foo(int num, int div)
        {
           return num / div;
        }
        


        Здесь играет роль размер выборки, ведь можно сгенерировать несколько миллионов случайных
        вариантов.

        Как эти несколько миллионов рандомных вариантов протестируют эту функцию? Подсказка, инпут позволяет 4.6e18 вариантов. Даже 100 миллионов вариантов не покроет и 1%.

        Это желание переложить создание тесткейсов на рандомный объект на практике будет означать что код будет протестирован всеголишь чуточку лучше чем smoke test, то есть практически никак.


        1. apro
          16.04.2017 10:59

          Как эти несколько миллионов рандомных вариантов протестируют эту функцию?
          Даже 100 миллионов вариантов не покроет и 1%.

          100 миллиардов в данном случае помогут, т.к. 2^32=4_294_967_296,
          т.е. у нас неминуемого будет 0 во втором аргументе, т.е. тест будет падать при каждом
          прогоне.


          Это желание переложить создание тесткейсов на рандомный объект на практике будет означать что код будет протестирован всеголишь чуточку лучше чем smoke test, то есть практически никак.

          Соглашусь с неявно витающей мыслью — это не серебренная пуля,
          и неким магическим образом она не даст 100% покрытие.


          Не соглашусь, что она только чуть-чуть улучшит smoke test.


          Давайте изменим методику подсчета :)


          Пользуясь эмпирическим опытом выскажу гипотезу, что в обычном проекте (т.е. от которого
          не зависит жизнь или огромные суммы денег), данная функция будет максимум
          протестирована для 1000 вариантов (хотя скорее максимум для 3 :) ),
          т.е. 100_000_000, увеличит вероятность нахождения
          ошибки в 100_000 раз. Да, общая вероятность увеличится незначительно,
          но ведь это инструмент, никто не мешает применять его с умом:


          int test_Foo_special(uint8_t a, uint8_t b) 
          {
              static const int SPECIAL_VALUES[] = {
                  INT_MAX,
                  INT_MIN,
                  0,
                  INT_MAX / 2,
                  INT_MIN / 2,
              };
              return Foo(SPECIAL_VALUES[a], SPECIAL_VALUES[b]);
          }

          скорее всего уже эта test_Foo_special на практике позволит найти 90% ошибок,
          хотя казалось бы общее количество тестируемых вариантов увеличилось незначительно.


          Т.е. резюмируя по сравнению с тем, что ленивый программист пишет, позволяет увеличить
          тестовое покрытие в миллионы раз, и но конечно в абсолютном значении процент
          вариантов входных данных для которых проведено тестирование, вырастет незначительно,
          поэтому надо применять с умом и других инструментов/специально обученных людей подобные
          библиотеки не заменят.


        1. ukhegg
          16.04.2017 11:11
          -1

          100`000`000 лучше чем 1-5 написанных вручную.Я же не говорю, что это единственный способ получения тестовых данных. Если вы можете для своего кода придумать граничные условия, никто не запретит вам добавить отдельные тесты для них.


        1. Xop
          18.04.2017 00:02

          В нормальных фреймворках для property-based тестирования случайности не совсем случайны. Например, если требуется какой-то int, то хорошим тоном будет считаться существенно более частое выпадение значений в районе 0, INT_MIN и INT_MAX. И шансы, что тест этой функции зафейлится даже на 10 тестах уже довольно высоки. А итераций без проблем может быть и 1000.