Здравствуйте, товарищи программисты и все кто им сочувствует. Я хотел бы предложить обзор возможностей сборочной системы QBS для интеграции покрытия кода в Qt автотесты QtTest с использованием утилит gcov/lcov. Кому эта тема интересна, добро пожаловать по кат.

Итак, давайте разберемся, что означает термин "покрытие кода". Вкратце, это когда используется некая утилита (или комплекс ПО) которая позволяет отследить и отобразить в той или иной форме результаты того, насколько тот или иной автотест покрывает исходный код чего-либо.

Обычно, эта утилита встраивает часть некоего своего кода (например, в виде библиотеки, слинкованной с автотестом) для генерации дополнительной информации в процессе работы автотеста. При этом, полученную информацию можно отобразить в любой удобной форме (обычно это HTML страницы).

Содержимое этой информации включает процентное соотношение количества строк и ветвей кода, обработанных автотестом, а также полную информацию о состоянии всех строк и ветвей.

Что мы будем использовать

В нашем текущем примере мы будем идти по пути "наименьшего сопротивления" и использовать общедоступное и свободное ПО (т.к. мы все в душе лентяи):

Компонент

Что это такое

Причина выбора

GNU/Linux

Операционная система.

Доступность, бесплатность, простота.

GCC

Набор компиляторов.

Доступность, бесплатность, простота.

GCOV

Утилита, собирающая предварительную информацию о покрытии.

Уже содержится в наборе компиляторов GCC.

LCOV

Утилита конвертирующая результаты покрытия в удобной форме.

Доступность, бесплатность, простота.

GENHTML

Утилита конвертирующая результаты покрытия в HTML формат (входит в состав lcov).

Доступность, бесплатность, простота.

Qt

Кросс-платформенный С++ фреймворк.

Доступность, бесплатность, простота. Уже содержит свой собственный тестовый фреймворк QtTest.

QtCreator

Кросс-платформенное и универсальное IDE.

Доступность, бесплатность, простота. Поддерживает автотесты QtTest и сборочную систему QBS "из коробки".

QBS

Кросс-платформенная система сборки.

Доступность, бесплатность, простота, модерн и удобство (да и вообще, няшка).

Примечание: Я здесь не буду расписывать как устанавливать те или иные пакеты, т.к. считаем, что пользователь разберется сам (например, погуглит).

Как работает GCOV/LCOV

Вкратце, чтобы включить поддержку покрытия кода в автотестах, используя компилятор GCC (или CLang), достаточно собрать приложение с отключенной оптимизацией (чтобы компилятор не выкидывал секции, ветвления и т.д., а оставлял "как есть") и передать ему флаг --coverage как в опции компилятора так и в опции линковщика.

Примечание: Именно так и никак иначе, т.к.приложение автотеста просто не скомпилируется.

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

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

Далее, этот выходной файл результатов покрытия можно передать, например, утилите lcov для генерации "дружелюбной" информации для пользователя. Например, как набор красивых HTML страниц с подробной информацией о покрытии.

Какие нужны шаги

Ниже приведу минимальный список шагов что и как мы будем делать для достижения результата:

  1. Пишем некий код, который надо проверить.

  2. Пишем некий автотест который линкуется с кодом и проверяет его.

  3. Запускаем автотест и проверяем что он работает.

  4. Добавляем опцию --coverage при сборке автотеста.

  5. Запускаем автотест и проверяем что он сгенерировал выходную информацию о покрытии в выходной файл.

  6. Передаем сгенерированный выходной файл в утилиту lcov для генерации красивых результатов в HTML формате.

  7. Открываем HTML результаты и любуемся.

ШАГ1. Создаем проект и пишем код

Давайте для простоты создадим простейшее дерево проекта COVERAGE-EXAMPLE со следующей структурой:

COVERAGE-EXAMPLE
│   .gitignore
│   coverage-example.qbs
│
├───src
│   │   src.qbs
│   │
│   └───libs
│       │   libs.qbs
│       │
│       └───foo
│               foo.cpp
│               foo.h
│               foo.qbs

, где:

  • coverage-example.qbs - это корневой файл проекта:

Project {
    name: "coverage-example" // Задаем уникальное имя корневого проекта.

    references: [
        "src/src.qbs", // Подключаем директорию с исходниками в проект.
    ]
}
  • src/- директория содержащая исходные коды под-проекты главного проекта, которые перечислены в файле src.qbs (в нашем случае это будут только под-проекты библиотек):

Project {
    name: "sources" // Задаем уникальное имя под-проекта общих исходников.
    references: [
        "libs/libs.qbs" // Подключаем директорию с исходниками библиотек в проект. 
    ]
}
  • libs/ - директория содержащая исходные коды под-проектов библиотек, которые перечислены в файле libs.qbs (в нашем случае это будет только один продукт библиотеки foo):

Project {
    name: "libs" // Задаем уникальное имя для под-проектов библиотек.
    references: [
        "foo/foo.qbs" // Добавляем директорию с исходниками библиотеки 'foo' в проект.
    ]
} 
  • libs/foo/ - директория содержащая исходные коды продукта нашей библиотеки, конфигурация которой описана в файле foo.qbs :

StaticLibrary { // Задаем тип библиотеки как статическую.
    name: "foo" // Задаем уникальное имя библиотеки (будет на выходе foo.a).
    Depends { name: "cpp" } // Задаем зависимость от QBS-ного модуля CPP.
    Depends { name: "Qt"; submodules: "core" } // Говорим линковать с модулем QtCore.

    files: [ "foo.cpp", "foo.h" ] // Перечисляем исходники библиотеки.

    // Это специальный финт для экспорта директории с заголовками библиотеки, так,
    // чтобы ее можно было подключать как '#include <foo/foo.h>' вместо 
    // '#include <foo.h>'.
    property string libIncludeBase: ".."
    cpp.includePaths: [libIncludeBase]

    // Экспорт свойств продукта библиотеки для всех других продуктов, которые будут
    // зависеть от этой библиотеки (экспортирует директорию с заголовками).
    Export {
        Depends { name: "cpp" }
        cpp.includePaths: [product.libIncludeBase]
    }
}

Библиотека foo пусть будет статической и пусть содержит всего лишь один класс Foo с одним методом encode():

#pragma once

#include <QByteArray>

class Foo
{
public:
    enum class Number { One, Two, Three };
    QByteArray encode(Number number) const;
};

Этот метод принимает на вход какое-то значение из перечисления и преобразует его в некоторое имя:

#include "foo.h"

QByteArray Foo::encode(Number number) const
{
    switch (number) {
    case Number::One:
        return "one";
    case Number::Two:
        return "two";
    case Number::Three:
        return "three";
    default:
        return "unknown";
    }
}

ШАГ2. Создаем автотест

Чтобы проверить что целевой метод:

 QByteArray Foo::encode(Number number) const

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

Для простоты эксперимента возьмем тестовый фреймворк QtTest из состава Qt, для чего немного расширим структуру проекта, добавив директорию tests:

COVERAGE-EXAMPLE
│   .gitignore
│   coverage-example.qbs
│
├───src
│   │   src.qbs
│   │
│   └───libs
│       │   libs.qbs
│       │
│       └───foo
│               foo.cpp
│               foo.h
│               foo.qbs
│
└───tests
    │   tests.qbs
    │
    └───auto
        │   auto.qbs
        │
        └───foo
                foo.qbs
                tst_foo.cpp

При этом, файл coverage-example.qbs пополнится до такого содержимого:

Project {
    name: "coverage-example"
    references: [
        "src/src.qbs",
        "tests/tests.qbs" // Подключаем директорию с тестами в проект.
    ]
}

, где:

  • tests/tests.qbs - директория содержащая исходные коды под-проектов тестов, которые перечислены в файле tests.qbs (в нашем случае это будут только под-проекты автотестов):

Project {
    name: "tests" // Задаем уникальное имя для под-проекта тестов.
    references: [
        "auto/auto.qbs" // Подключаем директорию с автотестами в проект.
    ]
}
  • auto/ - директория содержащая только под-проекты автотестов, которые перечислены в файле auto.qbs (на данный момент содержит только один автотест foo для нашей библиотеки foo):

Project {
    name: "autotests" // Задаем уникальное имя для под-проекта с автотестами.
    references: [
        "foo/foo.qbs" // Подключаем директорию с исходниками автотеста 'foo' в проект.
    ]
} 
  • auto/foo/ - директория содержащая продукт приложения автотеста для библиотеки foo, конфигурация которого описана в файле foo.qbs :

CppApplication { // Задаем тип автотеста как С++ приложение.
    name: "tst_foo" // Задаем уникальное имя приложения автотеста.
    Depends { name: "Qt"; submodules: ["test"] } // Говорим линковать с модулем QtTest.
    Depends { name: "foo" } // Говорим линковать с нашей библиотекой 'foo'.

    files: ["tst_foo.cpp"] // Перечисляем исходники автотеста.
}

Приложение автотеста tst_foo.cpp содержит следующий код:

#include <foo/foo.h>

#include <QtTest>

Q_DECLARE_METATYPE(Foo::Number)

class tst_Foo final : public QObject
{
    Q_OBJECT

private slots:
    void encode_data();
    void encode();
};


void tst_Foo::encode_data()
{
    QTest::addColumn<Foo::Number>("number");
    QTest::addColumn<QByteArray>("name");

    QTest::newRow("one") << Foo::Number::One  << QByteArray("one");
    QTest::newRow("two") << Foo::Number::Two  << QByteArray("two");
    QTest::newRow("three") << Foo::Number::Three  << QByteArray("three");
    QTest::newRow("unknown") << static_cast<Foo::Number>(123) << QByteArray("unknown");
}

void tst_Foo::encode()
{
    QFETCH(Foo::Number, number);
    QFETCH(QByteArray, name);

    Foo foo;
    const auto encoded = foo.encode(number);
    QCOMPARE(encoded, name);
}

QTEST_MAIN(tst_Foo)
#include "tst_foo.moc" 

Здесь:

  • метод теста tst_Foo:encode_data() формирует набор датасетов, подаваемых в проверяемый метод Foo::encode() библиотеки.

  • метод теста tst_Foo::encode() запускает по очереди целевую функцию из библиотеки с каджым из датасетов.

Примечание: Это все магия QtTest, с которой подробнее можно ознакомиться из оффициальной документации Qt.

ШАГ3. Запускаем автотест еще без покрытия кода

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

********* Start testing of tst_Foo *********
Config: Using QtTest library 5.14.2, Qt 5.14.2 (x86_64-little_endian-lp64 shared (dynamic) release build; by GCC 10.2.0)
PASS   : tst_Foo::initTestCase()
PASS   : tst_Foo::encode(one)
PASS   : tst_Foo::encode(two)
PASS   : tst_Foo::encode(three)
PASS   : tst_Foo::encode(unknown)
PASS   : tst_Foo::cleanupTestCase()
Totals: 6 passed, 0 failed, 0 skipped, 0 blacklisted, 0ms
********* Finished testing of tst_Foo *********

ШАГ4. Добавляем опцию --coverage

Чтобы информация о покрытии генерировалась в процессе работы автотеста, необходимо добавить опцию --coverage и компилятору и линковщику, а также собирать проект с отключенной оптимизацией (например, это дебаг режим).

В нашем случае при сборке статической библиотеки до линковки дело не доходит, т.к. здесь работает только компилятор и архиватор. А вот уже приложение автотеста и компилируется и линкуется. Поэтому опцию --coverage нужно добавить в два разных продукта в разные шаги сборки:

  • и в библиотеку (только компилятору)

  • и в автотест (и компилятору и линковщику).

Для этого QBS предоставляет модуль cpp, который имеет сециально предназначенные для этого свойства. В нашем случае, свойство cpp.driverFlags - то что нужно, оно передаст опцию --coverage и компилятору и линковщику.

Простой и самый напрашивающийся вариант - это продублировать свойство cpp.driverFlags и в продукт библиоеки и в продукт автотеста. Но это решение некрасивое, и есть более изящный подход, используя мощь QBS. ;)

Мы просто создадим дополнительный QBS модуль, и назовем его для примера как coverage. Этот модуль будет подставлять нужные опции сам автоматически. Достаточно только его подключить как зависимость к продукту. Для этого придется немного расширить дерево проекта:

COVERAGE-EXAMPLE
│   .gitignore
│   coverage-example.qbs
│
├───qbs
│   └───modules
│       └───coverage
│               coverage.qbs
│
├───src
│   │   src.qbs
│   │
│   └───libs
│       │   libs.qbs
│       │
│       └───foo
│               foo.cpp
│               foo.h
│               foo.qbs
│
└───tests
    │   tests.qbs
    │
    └───auto
        │   auto.qbs
        │
        └───foo
                foo.qbs
                tst_foo.cpp

, где:

  • qbs/ - директория в которой находятся наши вспомогательные QBS модули или файлы импорта.

  • qbs/modules/ - директория содержащая вспомогательные модули (должна иметь имя modules).

  • qbs/modules/coverage/ - директория, содержащая исходный QBS код нашего вспомогательного модуля coverage описанного в файле coverage.qbs :

Module {
    // Задаем условие что этот модуль активен только для компилятора GCC 
    // и только если текущая конфигурация есть debug.
    condition: qbs.debugInformation && qbs.toolchain.contains("gcc")
    Depends { name: "cpp" } // Добавляем зависимость от QBS-ного модуля CPP.
    cpp.driverFlags: ["--coverage"] // Задаем флаги и компилятору и линковщику.
}

Далее, добавляем этот модуль как зависимость к продукту библиотеки в файле foo.qbs:

StaticLibrary {
    name: "foo"
    Depends { name: "cpp" }
    Depends { name: "coverage" } // Добавляем зависимость от модуля coverage.
    Depends { name: "Qt"; submodules: "core" }

    files: [ "foo.cpp", "foo.h" ]

    property string libIncludeBase: ".."
    cpp.includePaths: [libIncludeBase]

    Export {
        Depends { name: "cpp" }
        Depends { name: "coverage" } // Экспортируем зависимость от модуля coverage.
        cpp.includePaths: [product.libIncludeBase]
    }
}

Теперь при сборке библиотеки, будет добавлена опция --coverage компилятору. А благодаря тому, что мы экспортировали зависимость от модуля coverage из библиотеки foo с помощью Export , то теперь любой продукт, подключивший библиотеку foo также получит эту опцию --coverage. Таким образом, мы убиваем сразу двух (и более зайцев).

И почти последгий штрих - надо указать QBS где искать наш новый модуль coverage. Для этого нужно добавить в корневой файл проекта coverage-example.qbs одну строчку:

Project {
    name: "coverage-example"
    qbsSearchPaths: "qbs" // Говорим QBS что искать наши модули в директории qbs
    references: [
        "src/src.qbs",
        "tests/tests.qbs"
    ]
} 

На этом, казалось бы, этот шаг должен был быть завершен - но нет. После включения опции --coverage после сборки библиотеки и приложения автотеста, в директории с объектными файлами автоматически будут создаваться файлы с расширением *.gcno:

user@ubuntu:~/Documents/coverage-git/build-coverage-example-Desktop-Debug/Debug_Desktop_038b678e9426a45b$ ls foo.0beec7b5/3a52ce780950d4d9/
foo.cpp.gcno  foo.cpp.o
user@ubuntu:~/Documents/coverage-git/build-coverage-example-Desktop-Debug/Debug_Desktop_038b678e9426a45b$ ls tst-foo.e3f741e4/3a52ce780950d4d9/
tst_foo.cpp.gcda  tst_foo.cpp.gcno  tst_foo.cpp.o

Но мы не хотим держать эту помойку и пускать все на самотек. Мы хотим, чтобы при операции clean очищалась вся директория сборки. Для этого нужно добавить в модуль coverage правило, которое обрабатывало бы файлы *.gcno как артефакты (т.е. включить их в граф сборки QBS).

import qbs.FileInfo // Импортирует QBS-ный сервис для работы с инфой о файлах.
import qbs.Utilities // Импортируем QBS-ные вспомогательные функции из утилит.

Module {
    condition: qbs.debugInformation && qbs.toolchain.contains("gcc")
    additionalProductTypes: ["gcno"] // Говорим что обязательно использовать тег gcno. 
    Depends { name: "cpp" }
    cpp.driverFlags: ["--coverage"]

    Rule { // Правило для фейковой генерации файлов *.gcno.
        // Говорим что как будто мы будем генерировать файлы *.gcno из
        // сорцов *.cpp или *.c. 
        inputs: ["cpp", "c"] 
        // Задаем имя тега для генерируемого артефакта (любое, 
        // пусть будет gcno).
        outputFileTags: ["gcno"]
        // Описываем свойства генерируемого артефакта:
        // - то что файлам *.gcno присваивается тег gcno.
        // - то что файлы *.gcno будут создаваться в определенном месте 
        //   (рядом с объектниками).
        outputArtifacts: {
            return [{
                fileTags: ["gcno"],
                filePath: FileInfo.joinPaths(Utilities.getHash(input.baseDir),
                                             input.fileName + ".gcno")
            }];
        }
        // Описываем код правила, т.е. как мы будем из файлов *.c *.cpp
        // делать файлы *.gcno. А никак - мы не контролируем этот процесс,
        // т.к. сам компилятор этим занимается автоматически. Поэтому код
        // этого правила - просто пуская команда, которая ничего не делает, 
        // кроме того что просто печатает в коноль сообщение:
        // generating foo.gcno
        prepare: {
            var cmd = new JavaScriptCommand();
            cmd.description = "generating " + output.fileName;
            return [cmd];
        }
    }
}

Примечание:

  • Т.к. выходной артифакт gcno никому не нужен (он не включен ни в какую зависимость) то чтобы правило заработало, необходимо явно сказать QBS-у чтобы он всегда запускал это правило с помощью опции модуля additionalProductTypes.

  • Т.к. наше правило ничего не делает (просто регистрирует файлы *.gcno как артефакты), то и его код должен ничего не делать тоже.

ШАГ5. Запускаем автотест уже с покрытием кода

Теперь запускаем снова наше приложение автотеста. И видим, что он отработал как обычно (вывел в консоль результаты):

********* Start testing of tst_Foo *********
Config: Using QtTest library 5.14.2, Qt 5.14.2 (x86_64-little_endian-lp64 shared (dynamic) release build; by GCC 10.2.0)
PASS   : tst_Foo::initTestCase()
PASS   : tst_Foo::encode(one)
PASS   : tst_Foo::encode(two)
PASS   : tst_Foo::encode(three)
PASS   : tst_Foo::encode(unknown)
PASS   : tst_Foo::cleanupTestCase()
Totals: 6 passed, 0 failed, 0 skipped, 0 blacklisted, 0ms
********* Finished testing of tst_Foo *********

Но теперь рядом с объектными файлами библиотеки и приложения автотеста автоматически сгенерировались бинарные файлы отчетов *.gcda:

user@ubuntu:~/Documents/coverage-git/build-coverage-example-Desktop-Debug/Debug_Desktop_038b678e9426a45b$ ls foo.0beec7b5/3a52ce780950d4d9/
foo.cpp.gcda  foo.cpp.gcno  foo.cpp.o
user@ubuntu:~/Documents/coverage-git/build-coverage-example-Desktop-Debug/Debug_Desktop_038b678e9426a45b$ ls tst-foo.e3f741e4/3a52ce780950d4d9/
tst_foo.cpp.gcda  tst_foo.cpp.gcno  tst_foo.cpp.o

Но мы лентяи не не хотим вручную запускать автотесты. Для этого у QBS уже имеется специальный объект AutotestRunner, который достаточно добавить в корневой проект coverage-example.qbs :

Project {
    name: "coverage-example"
    qbsSearchPaths: "qbs"

    AutotestRunner { }

    references: [
        "src/src.qbs",
        "tests/tests.qbs"
    ]
}

И всем продуктам приложениям автотестов просто добавить тег autotest (в нашем случае у нас один объект автотеста в tests/auto/foo/foo.qbs):

CppApplication {
    name: "tst_foo"
    type: base.concat("autotest") // Добавили новый тег - автотест.
    Depends { name: "Qt"; submodules: ["test"] }
    Depends { name: "foo" }

    files: ["tst_foo.cpp"]
}

И тепрерь, при сборке нашего раннера, QBS автоматически отследит все его зависимости (они являются автотестами), соберет их (если еще не собраны), и запустит по очереди.

ШАГ6. Генерируем красивые результаты

Итак, чтобы сгенерировать красивые результаты покрытия в виде HTML страниц, необходимо обработать все бинарные файлы результатов утилитой lcov.

Чтобы автоматизировать этот процесс - напишем свой зарускатель автотестов, т.к. стандартный AutotestRunner не годится для этой цели. Причина в том, что нам нужно не только запускать автотест, но и написать правила, которые будут отслеживать генерируемые файлы *.gcda как выходные артефакты и подавать их на вход утилите lcov.

Назовем наш новый запускатель как CoverageRunner и поместим его реализацию в директорию imports:

COVERAGE-EXAMPLE
│   .gitignore
│   coverage-example.qbs
│
├───qbs
│   ├───imports
│   │       CoverageRunner.qbs
│   │
│   └───modules
│       └───coverage
│               coverage.qbs
│
├───src
│   │   src.qbs
│   │
│   └───libs
│       │   libs.qbs
│       │
│       └───foo
│               foo.cpp
│               foo.h
│               foo.qbs
│
└───tests
    │   tests.qbs
    │
    └───auto
        │   auto.qbs
        │
        └───foo
                foo.qbs
                tst_foo.cpp

, где:

  • qbs/imports/ - диретория, содержащая разные реализации вспомогательных объектов.

В нашем случае она содержит только одну реализацию объекта нашего запускателя в файле CoverageRunner.qbs :

import qbs.File
import qbs.FileInfo
import qbs.ModUtils
import qbs.Probes
import qbs.Utilities

Product {
    name: "coverage-runner" // Задаем уникальное имя нашему запускателю тестов.

    // Задаем тег конечного выходного артефакта в цепочке всех промежуточных
    // артефактов, генерируемым нашим запускателем. Конечный артефакт - это 
    // информация о покрытии в виде HTML страниц.
    type: ["out_html"]

    // Говорим QBS-у чтобы он собирал этот запускатель только при явном указании.
    builtByDefault: false

    // Задаем переменные окружения для запуска тестов и утилиты lcov.
    property stringList environment: ModUtils.flattenDictionary(qbs.commonRunEnvironment)
    // Задаем путь к утилите lcov, найденный пробником.
    property path lcovPath: lcovProbe.filePath
    // Задаем путь к утилите genhtml, найденный пробником.
    property path genhtmlPath: genhtmlProbe.filePath

    // Пробник, который ишет утилиту lcov.
    Probes.BinaryProbe {
        id: lcovProbe
        names: "lcov"
    }
    // Пробник, который ищет утилиту genhtml.
    Probes.BinaryProbe {
        id: genhtmlProbe
        names: "genhtml"
    }

    // Задаем зависимости в виде любых продуктов с тегом autotest.
    Depends {
        productTypes: "autotest"
        limitToSubProject: true
    }

    // Реализуем первое правило в цепочке, которое будет запускать автотесты
    // и добавляет генерируемые ими выходные файлы *.gcna в граф сборки.
    Rule {
        id: gcnoGenerator
        inputsFromDependencies: ["application"]
        // Задаем выходной тег артефактам, и полный путь к файлам генерируемым
        // этим правилом.
        outputFileTags: ["gcda"]
        outputArtifacts: {
            var artifacts = [];

            function traverse(dep) {
                var gcnos = dep.artifacts["gcno"] || [];
                gcnos.forEach(function(gcno) {
                    artifacts.push({
                        fileTags: ["gcda"],
                        filePath: FileInfo.joinPaths(FileInfo.path(gcno.filePath), gcno.completeBaseName + ".gcda")
                    });
                });
                dep.dependencies.forEach(traverse);
            }

            product.dependencies.forEach(traverse);
            return artifacts;
        }
        prepare: {
            if (!input.product.type.contains("autotest")) {
                var cmd = new JavaScriptCommand();
                cmd.silent = true;
                return cmd;
            }
            var commandFilePath;
            var installed = input.moduleProperty("qbs", "install");
            if (installed)
                commandFilePath = ModUtils.artifactInstalledFilePath(input);
            if (!commandFilePath || !File.exists(commandFilePath))
                commandFilePath = input.filePath;
            var arguments = (input.autotest && input.autotest.arguments && input.autotest.arguments.length > 0) ? input.autotest.arguments : [];
            var workingDir = (input.autotest && input.autotest.workingDir) ? input.autotest.workingDir : FileInfo.path(commandFilePath);
            var fullCommandLine = [].concat([commandFilePath]).concat(arguments);
            var cmd = new Command(fullCommandLine[0], fullCommandLine.slice(1));
            cmd.description = "running test " + input.fileName;
            cmd.environment = product.environment;
            cmd.workingDirectory = workingDir;
            cmd.jobPool = "coverage-runner";
            if (input.autotest && input.autotest.allowFailure)
                cmd.maxExitCode = 32767;
            return cmd;
        }
    }

    // Реализуем второе правило в цепочке. Оно берет на вход все сгенерированные 
    // файлы *.gcda, подает их утилите lcov для генерации результатов покрытия
    // в текстовой форме в виде файлов с расширением *.info.
    Rule {
        id: infoGenerator
        inputs: ["gcda"] // Задаем брать все бинарные файлы *.gcda на вход правилу.
        
        // Задаем выходной тег для артефактов, и полный путь файлов, генерируемых
        // этим правилом.
        outputFileTags: ["src_info"]
        outputArtifacts: {
            return [{
                fileTags: ["src_info"],
                filePath: FileInfo.joinPaths(Utilities.getHash(input.baseDir), input.fileName + ".info")
            }];
        }
        // Этот код запускает утилиту lcov для каждого из входных файлов *.gcda и
        // формирует выходные файлы с текстовой информацией *.info.
        prepare: {
            var args = ["--quiet", "--capture"];
            args.push("--directory", FileInfo.path(input.filePath));
            args.push("--output-file", output.filePath);
            var cmd = new Command(product.lcovPath, args);
            cmd.description = "generating " + output.fileName;
            return cmd;
        }
    }

    // Реализуем третье правило в цепочке. Оно берет на вход все файлы *.info и 
    // объединяет их в один общий файл с именем <имя продукта>.info, который помещает
    // рядом с исполняемым файлом автотестов (я так захотел).
    Rule {
        id: infoMerger
        multiplex: true
        inputs: ["src_info"] // Задаем брать все текстовые файлы *.info.

        // Задаем выходной тег артифакта, и полный путь к объединенному *.info
        // файлу генерируемому этим правилом.
        outputFileTags: ["out_info"]
        outputArtifacts: {
            return [{
                fileTags: ["out_info"],
                filePath: FileInfo.joinPaths(product.destinationDirectory, product.targetName + ".info")
            }];
        }
        // Этот код запускает утилиту lcov для объелинения всех входных файлов
        // *.info в один результирующий файл *.info.
        prepare: {
            var args = ["--quiet"];
            inputs.src_info.forEach(function(info) {
                args.push("--add-tracefile", info.filePath);
            });
            args.push("--output-file", output.filePath);
            var cmd = new Command(product.lcovPath, args);
            cmd.description = "generating " + output.fileName;
            return cmd;
        }
    }

    // Реализуем четвертое (и последнее) правилло в цепочке. Оно берет на вход 
    // результирующий текстовый файл *.info и подает его утилите genhtml для
    // генерации результата в виде HTML страниц.
    Rule {
        id: htmlGenerator
        inputs: ["out_info"] // Задаем брать результирующий текстовый файл.

        // Задаем выходной тег артифакта и полный путь к директории с HTML 
        // страницами, генерируемыми этим правилом. 
        // Имя этого тега должно совпадать с именем пега продукта - запускателя.
        outputFileTags: ["out_html"]
        outputArtifacts: {
            return [{
                fileTags: ["out_html"],
                filePath: FileInfo.joinPaths(product.destinationDirectory, "html")
            }];
        }
        // Этот код запускает утилиту genhtml для генерации HTML станиц из 
        // результирующего файла *.info.
        prepare: {
            var args = ["--quiet", "--ignore-errors"];
            args.push("source", input.filePath);
            args.push("--output-directory", output.filePath);
            var cmd = new Command(product.genhtmlPath, args);
            cmd.description = "generating html";
            return cmd;
        }
    }
}

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

  • Запуск автотестов для генерации бинарных файлов *.gcda .

  • Подача всех файлов *.gcda в утилиту lcov для генерации текстовых фалов *.info.

  • Подача всех текстовых файлов *.info в утилиту lcov для их объединения в один результирующий файл *.info.

  • Подача результирующего текстового файла *.info в утилиту genhtml для генерации HTML страниц о покрытии кода.

Далее, необходимо модифицировать корневой файл проекта coverage-example.qbs, заменив в нем AutotestRunner на наш CoverageRunner :

import "qbs/imports/CoverageRunner.qbs" as CoverageRunner // Импортируем наш запускатель

Project {
    name: "coverage-example"
    qbsSearchPaths: "qbs"

    CoverageRunner { } // Декларируем наш запускатель.

    references: [
        "src/src.qbs",
        "tests/tests.qbs"
    ]
}

Теперь, для генерации результатов покрытия в виде HTML страниц достаточно просто выполнить сборку нашего продукта - запускателя тестов coverage-runner.

ШАГ7. Просматриваем результаты покрытия

После сборки нашего запускателя, он сгенерирует результаты покрытия в виде HTML страниц в выходную директорию сборки запускателя, например:

user@ubuntu:~/Documents/coverage-git/build-coverage-example-Desktop-Debug/Debug_Desktop_038b678e9426a45b$ ls coverage-runner.90e4c3ec/html/
amber.png  emerald.png  gcov.css  glass.png  home  index.html  index-sort-f.html  index-sort-l.html  QtCore  QtTest  ruby.png  snow.png  updown.png  usr

Теперь мы можем открыть файл index.html и посмотреть что получилось:

Общие результаты покрытия.
Общие результаты покрытия.

Конечно, здесь добавляется и лишняя информация. Но это не проблема, т.к. ее можно исключить на этапе генерации файлов *.info, но мы не будем касаться этого в данной статье.

Результаты покрытия по нашей библиотеки примерно следующие:

Результаты покрытия библиотеки.
Результаты покрытия библиотеки.
Детальные результаты покрытия библиотеки.
Детальные результаты покрытия библиотеки.

Теперь можете сами поиграться с содержимым автотеста, например, закомментировав некоторые строчки дата-сетов, перегенерировать HTML и посмотреть что поменяется. ;)

Заключение

В этой статье краттко рассмотрели всю мощь и гибкость QBS для реализации самых разнообразных задач.

И, конечно, ссылки:

  • Исходный код примера на github.

  • Что такое QBS.

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


  1. iroln
    13.09.2021 18:16

    Qbs — Доступность, бесплатность, простота, модерн и удобство (да и вообще, няшка).

    Оно ещё развивается?


    Потому как ещё в 2018 официально решили отказаться от её поддержки внутри The Qt Company:


    We have decided to deprecate Qbs and redirect our resources to increase support for CMake. Qbs will remain supported until the end of 2019 with the last planned release in April 2019, together with Qt Creator 4.9. Qbs is available under both commercial and open-source licenses, and we are happy to continue providing the infrastructure for further development by the Qt Project community.

    https://www.qt.io/blog/2018/10/29/deprecation-of-qbs


    1. kuzulis Автор
      13.09.2021 18:23

      Да, активно развивается, но не Qt Company, а сообществом. Вот пруф на последние коммиты: https://github.com/qbs/qbs/commits/master

      Недавно зарелизили версию 1.20 (с тех пор как задепрекейтили) и я уже и не помню, какой это релиз по счету).

      PS: На данный момент имеем желание полностью уйти от инфраструктуры Qt Company на GitHub. Также, пилится отдельный сайт (как я понял). Так что все хорошо.


  1. ABBAPOH
    14.09.2021 00:19

    Хе, прикольно. Первую половину очень легко запихнуть в апстрим - в правило генерации реальных объективов добавить выхлоп gcno файлов.

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