dock: простая библиотека модульного тестирования кода на С++

Хотя и существуют уже библиотеки для юнит-тестирования кода на С++, например, Google Test или Bandit, но они написаны не мной здесь оно, на мой взгляд, как-то переусложнено, по сравнению с тем же JS. Там просто делаешь, например, npm i mocha assert --save-dev и можно приступать к написанию тестов, а здесь же нужно это сделать ручками, а в случае с gtest еще и собрать с помощью cmake ее. Bandit подключается просто, но не умеет в сериализацию результатов в какой-то формат данных, gtest это умеет, но его нужно собирать отдельно. А я не хочу выбирать "либо то, либо это". Мне было нужно сделать удобный и простой инструмент под мои задачи. Я хотел получить простую библиотеку без зависимостей, header-only, на несколько файлов, которую можно легко и быстро подключить к своему проекту, удобно внести в нее изменения (если это будет необходимо). Но, самое основное, мне хотелось получать удобные, машиночитаемые отчеты, причем не только в stdout (или xml, как в gtest), но и в любой другой формат, который я захочу. Далее под катом.


Как я уже писал выше, библиотека dock header-only, а значит ее подключение максимально простое:


#include <iostream>
#include <dock/dock.hpp>

using namespace dock;

int main() {
    core().run();

    return 0;
}

При сборке, например, в gcc, нужно передать только путь к папке с библиотеками и указать стандарт языка C++14. Я намеренно делаю так, потому что новые проекты я пишу на свежем стандарте, а для поддержки старых есть уже свои готовые библиотеки.


Описание тестов тоже сделано предельно простым:


using namespace dock;

Module(u8"Some module 1", [](DOCK_MODULE()) {
    Test(u8"Some test 1", []() {
        uint8_t value = 0x10;
        uint8_t expectedValue = 0x10;
        Assert::isEqual(value, expectedValue);
    });

    Test(u8"Some test 2", []() {
        uint8_t value = 0x10;
        uint8_t expectedBorder = 0x20;
        Assert::isLess(value, expectedBorder);
    });
});

Module(u8"Some module 2", [](DOCK_MODULE()) {
    Test(u8"Some test 1", []() {
        Assert::isTrue(true);
    });

    Test(u8"Some test 2", []() {
        Assert::isTrue(false);
    });
});

Для удобства тесты группируются в модули. В них передается объект std::function<void(Module*)>, внутри которого описываются непосредственно тесты. Тесты имеют примерно такой же синтаксис, только функциональный объект без параметров. Пока что я не делал проверку на уникальность имени модуля или теста, потому что это было не критично.


"Библиотека" Assert содержит простой набор методов isTrue,isEquals, isGreater, isLess, которые по умолчанию могут сравнивать объекты через операторы ==, > или <. Если операторов нет, то можно функцию сравнения передать в конце параметром (например, в виде лямбды).


static void isTrue(std::function<bool()> fcn);

template<typename T>
static void isEqual(const T a, const T b, std::function<bool(const T, const T)> compareFcn = defaultEqualsFunction<T>);

template<typename T>
static void isGreater(const T a, const T b, std::function<bool(const T, const T)> compareFcn = defaultGreaterFunction<T>);

template<typename T>
static void isLess(const T a, const T b, std::function<bool(const T, const T)> compareFcn = defaultLessFunction<T>);

А теперь как раз то, что было нужно мне: удобное преобразование результатов тестирования в необходимый формат данных. Для начала, просто хочется поработать с статистикой ведения проекта, смотреть динамику по тестам и подобные вещи, и мне это удобно делать на JS. Поэтому первый формат, который мне потребовался — JSON. В репозитории есть уже три готовых сериализатора: в JSON, в plain text и вывод в консоль с подсветкой. Использование сериализаторов очень простое:


nlohmann::json outJson;
JsonSerializer serializer(outJson, 4);

core().run();
core().collect(serializer);

std::cout << serializer << std::endl;

А сам интерфейс сериализатора выглядит следующим образом:


class ResultSerializer {
public:
    virtual ~ResultSerializer() = default;

    virtual void            serialize(std::vector<Result>& results) = 0;
    virtual std::string     toString() const = 0;
    friend std::ostream&    operator<<(std::ostream& os, ResultSerializer& s);
};

Т.е. выводить результат можем куда угодно, подставить только std::ostream и все. Логика работы сериализатора следующая:


  • Передаем сериализатор движку через collect() и он вызывает метод serialize() с вектором результатов.
  • В операторе << вызывается метод toString(), который выдает строку в std::ostream.
    Можно сделать два варианта: либо при вызове serialize() сразу создаем нужную строку, а ее потом либо просто возвращаем, либо сохраняем ссылку на результаты и генерируем выдачу непосредственно при выдаче в ostream. В любом случае, остается свобода движения — движок выдает просто std::vector<dock::Result>, а что с ним делать уже дело ваше :).

Лицензия свободная (MIT), потому что мне не жалко и будет приятно видеть её использование. Для сериализаторов использовались библиотеки termcolor и JSON for Modern C++, но можно спокойно убрать их вместе с ненужными сериализаторами.

Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 17
  • +2
    Начал использовать Catch только благодаря header-only. Ваша реализация тоже выглядит достойно, возможно попробую.
    • 0

      isEquals — это странное название. Либо isEqual либо Equals

      • 0
        Согласен, промашка вышла, поправлю.
      • +3
        А чем так Google Test сложен? Ведь достаточно применить один раз применить скрипт:
        fuse_gtest_files.py, он создаст 2 файла: cpp, hpp и можно подключать gtest к проекту станет достаточно простым делом.
        • +1
          Да, можно собрать и подключать в проекты, но остается вопрос простого добавления сериализатора, например, в тот же JSON. Здесь же я сделал максимально просто, чтобы можно было выдать данные в каком угодно формате. Можно или только по этой статье написать сразу простейший сериализатор, или заглянуть в один из готовых — там короткие примеры, в которых легко будет и новичку разобраться.
        • 0

          А почему не… ?


          std::cout << serializer << core().collect() << std::endl;
          • 0

            collect() принимает сериализатор и говорит ему какой массив нужно преобразовать, поэтому синтаксис выше будет некорректным или просто странноватым. Как вариант, можно задать сериализатор и сделать std::cout << core() << std::endl;, но это, на мой взгляд, как-то не удобно.


            Возможно, стоит переименовать метод во что-то более говорящее, но в голову не пришло такое слово.

            • +2

              Так меня именно это и удивляет. Почему вдруг collect() принимает сериализатор? Где core и где сериализатор? Зачем они так тесно связаны? У core должен быть некий метод, который отдаёт голенькие данные. И вот эти вот данные уже ловит сериализатор, к которому в результате выполнения первого << уже прилип std::cout. Т.е. на тот момент, когда в сериализатор попадают данные из core, он уже готов выводить их в конкретный поток. Более того, из метода, собирающего данные, можно возвращать не сразу сами данные, а просто фьючер со ссылкой на них. При раскрытии этого фьючера внутри сериализатора автоматом произойдёт отложенный сбор данных.

              • 0

                А, понял задумку. Хорошая идея, попробую завтра поиграться.

          • 0
            На почему макрос DOCK_MODULE сделан вызываемым внутри параметров лямбда функции? Почему, чисто по причине удобства (не люблю писать скобочки), не сделать вот так:

            Module(u8"Some module 2", DOCK_MODULE {
                //some code
            });
            


            А сам макрос DOCK_MODULE не сделать таким:

            DOCK_MODULE [](dock::_internal::Module* _dock_module)
            


            Конечно никто мне не мешает самому написать подобную обертку, просто интересует причина подобного решения. Может быть это как-то используется, но не показано в статье?
            • 0

              Пробовал так делать изначально, оставались вообще фигурные скобки и то, что внутри (Module тоже ведь макрос, поэтому я могу сразу в нем все скрыть):


              Module(u8"Module name", {
                  // ... 
              });

              Но в таком варианте почему-то ломались автоотступы в VS2015/VS2017, т.е. все блоки Test были без отступов вообще. Чтобы не раздражало пришлось перейти на менее читабельный и компактный вид.

            • +1
              Тут просто статистика по тесту типа прошел/не прошел и все?
              По моему, этого обычно мало.
              Зачастую тест содержит не один ассерт, а несколько. Как понять, кто из них сработал?
              Как понять, какие значения ожидались, а какие там были на самом деле?
              Как вывести дополнительную информацию о контексте ассерта?
              • 0

                Так только старт же, по мере необходимости буду добавлять фичи :) Пока что мне нужно было что-то очень простое, что может выдавать результат в json, и эта потребность у меня закрылась. Может быть, кому-то еще пригодится такой простой движок, поэтому я и поделился им.

                • 0
                  В итоге, это «что-то простое» превратится в большой кусок кода, который делает много вещей.
                  Иначе он будет мало полезен остальным.
                  Тогда встанет вопрос — пользоваться Вашим кодом, или протестированной либой с историей, тестами и поддержкой разных платформ, от крупного сообщества.

                  В любом случае, Вам — удачи, но я бы задумался над тем, чтобы попробовать заслать в апстрим то, что было нужно вместо велосипедостроения.
                  GTest, например, не так уж плоха.
              • 0
                static void isTrue(std::function<bool()> fcn);


                чтобы все было пареллельно и перпендикулярно — надо позволить пользователю вычислять значение из тестируемого контекста, а не просто брать из ниоткуда?

                templatestatic void isTrue(const T v, std::function<bool(const T&)> fcn = defaultIsTrueFunction());
                • 0

                  Для std::function контекст можно и с помощью std::bind задать, но я из конкретно этого метода уже убрал std::function, оставил просто bool.

                • 0
                  Не пойму как сделать следующее:
                  1. Пометить тест как skiped?
                  2. Запустить только те что упали в прошлый раз?
                  3. Запустить тесты в рандомном порядке?
                  4. Запустить тесты несколько раз?

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

                  Самое читаемое