[Qt] Come misurare la copertura dei test su un progetto Qt con Gcov

Code Coverage

Un’attività importante al testing del codice è quella del code coverage, ovvero quanta percentuale del nostro codice è coperta da uno o più unit test. La strada per raggiungere dei test efficaci passa sicuramente dall’avere una percezione di quanto i test vadano in profondità nel testare il nostro codice. Attenzione, anche avere il 90% o addirittura il 100% della copertura non da indicazione dell’efficacia dei test stessi, ma fornisce una metrica di quanto i nostri test controllino sul nostro codice .

Come possiamo struttura un progetto per misurare la copertura dei test su Qt? Su Linux è estremamente semplice utilizzando GCC e uno dei suoi utility tool, GCov.

Creare il progetto

Cominciamo creando un semplice progetto di esempio così composto: un subdir project con due sotto-progetti contenenti in uno la nostra ipotetica libreria, nell’altro quello dei test. In un caso reale il sotto-progetto test dovrebbe essere a sua volta un subdir project contenente più progetti di test

Vediamo la composizione del .pro principale

TEMPLATE = subdirs

SUBDIRS += \
   lib \
   test

test.depends = lib

DISTFILES += \
   common.pri

Indichiamo la dipendenza in compilazione del progetto di test con quello delle librerie, e poi andiamo ad includere un file .pri comune per la compilazione dei sotto-progetti.

BUILD_DIR   = $$PWD/build
INCLUDE_DIR = $$BUILD_DIR/include
LIB_DIR     = $$BUILD_DIR/lib
OBJ_DIR     = $$BUILD_DIR/objs
MOCS_DIR    = $$BUILD_DIR/moc
TEST_DIR    = $$BUILD_DIR/test

Generare i report per il coverage

Adesso passiamo al progetto di libreria. Dobbiamo dire a GCC di generare i file di profilazione che verranno usati poi per generare le statistiche di coperatura. Niente di più semplice su Qt, è sufficiente aggiungere

QMAKE_CXXFLAGS += --coverage
QMAKE_LFLAGS   += --coverage

per far sì che in compilazione, oltre gli object file vengano generati anche dei file .gcno. La presenza di questi file ci mostrerà che tutto è stato fatto correttamente.

–coverage è un flag Qt per abilitare i file di report mantenendo la compatibilità con tutte le piattaforme. Su Linux e GCC equivale a compilare con i flag

gcc -fprofile-arcs -ftest-coverage ...

Il file pro della libreria si presenta quindi così

QT -= gui

TEMPLATE = lib
DEFINES += LIB_LIBRARY

CONFIG += c++14

SOURCES += \
   angle.cpp

HEADERS += \
    angle.h

TARGET = mylib

!include(../common.pri){
   error(common.pri not found)
}

QMAKE_CXXFLAGS += --coverage
QMAKE_LFLAGS   += --coverage

QMAKE_POST_LINK += mkpath($$INCLUDE_DIR)
QMAKE_POST_LINK += $$QMAKE_COPY $$quote($$HEADERS) $$quote($$INCLUDE_DIR) $$escape_expand(\\n\\t)
QMAKE_POST_LINK += $$QMAKE_COPY $$quote(../generate_coverage.sh) $$quote($$BUILD_DIR) $$escape_expand(\\n\\t)

Le direttive di QMAKE_POST_LINK servono a far sì che tutti i file necessari vengano spostati sulla directory build. In questo caso la libreria compilata, gli headers e il file di generazione del report che vedremo più avanti.

Testiamo qualcosa

Dobbiamo scrivere qualcosa da testare, no? Creiamo una classe in maniera motlo semplice in modo da poterla poi testare.

#ifndef ANGLE_H
#define ANGLE_H

#include <cmath>

#include <QDebug>

static constexpr float degToRad(float deg) { return deg * M_PI / 180.0; }
static constexpr float radToDeg(float rad) { return rad * 180.0 / M_PI; }

class Angle
{
public:
   enum Type {
      Deg,
      Rad
   };

   //! Default angle is 0.0 deg
   Angle();
   Angle(float value, Angle::Type type);

   float deg() const;
   float rad() const;

   bool isDeg() const;
   bool isRad() const;

private:
   friend QDebug operator<<(QDebug dbg, const Angle& a);

   float _value;
   Type _type;
};

QDebug operator<<(QDebug dbg, const Angle& a);

#endif // ANGLE_H
#include "angle.h"

Angle::Angle()
   : _value{0.0}, _type{Deg}
{
}

Angle::Angle(float value, Angle::Type type)
   : _value{value}, _type{type}
{
}

float Angle::deg() const
{
   if(_type == Deg){
      return _value;
   }

   return radToDeg(_value);
}

float Angle::rad() const
{
   if(_type == Rad){
      return _value;
   }

   return degToRad(_value);
}

bool Angle::isDeg() const
{
   return _type == Deg;
}

bool Angle::isRad() const
{
   return _type == Rad;
}

QDebug operator<<(QDebug dbg, const Angle& a)
{
   return dbg << "Angle(" << a._value << (a.isDeg() ? "deg" : "rad") << ")";
}

Adesso non ci resta che scrivere la classe di test nel suo progetto

#include <QtTest>
#include "angle.h"

class Test : public QObject
{
   Q_OBJECT

private slots:
   void initTestCase_data();
   
   void test_type();
   void test_value();

private:
   float pi = M_PI;
   float p2 = M_PI_2;
};

void Test::initTestCase_data()
{
   QTest::addColumn<float>("degrees");
   QTest::addColumn<float>("radians");

   QTest::newRow("1") <<  0.0f   <<  0.0f;
   QTest::newRow("2") <<  180.0f <<  pi;
   QTest::newRow("3") <<  -90.0f <<  -p2;
   QTest::newRow("4") <<  360.0f << 2.0f*pi;
   QTest::newRow("5") <<  365.0f <<  6.370451f;
   QTest::newRow("6") <<  -400.0f <<  -6.98131f;
}

void Test::test_type()
{
   QFETCH_GLOBAL(float, degrees);
   QFETCH_GLOBAL(float, radians);

   Angle deg(degrees, Angle::Deg);
   Angle rad(radians, Angle::Rad);

   QCOMPARE(deg.isDeg(), true);
   QCOMPARE(deg.isRad(), false);
   QCOMPARE(rad.isDeg(), false);
   QCOMPARE(rad.isRad(), true);
}

void Test::test_value()
{
   QFETCH_GLOBAL(float, degrees);
   QFETCH_GLOBAL(float, radians);

   Angle deg(degrees, Angle::Deg);
   Angle rad(radians, Angle::Rad);

   QCOMPARE(deg.deg(), degrees);
   QCOMPARE(deg.rad(), radians);
   QCOMPARE(rad.deg(), degrees);
   QCOMPARE(rad.rad(), radians);
}

QTEST_APPLESS_MAIN(Test)

#include "tst_test.moc"

Tutto adesso e pronto. Potremo notare come compilando il progetto verranno generati i file .gcno e una volta lanciati i testi verranno aggiunti altrettanti file di tipo .gcda. Lanciamo i test.

Tutto è andato a buon fine. Adesso siamo pronti per potere generare le statistiche.

Generare il report

Abbiamo strutturato il progetto per fare in modo che tutti i file generati vadano nella cartella build. Spostiamoci lì e da terminale lanciamo il comando per generare il file di report

lcov -c -d objs -o mylib.info

Adesso generiamo un output leggibile per l’occhio umano tramite genhtml

genhtml mylib.info -o coverage

Aspetta un momento. 33.3% ? Sono discretamente sicuro di avere provato la maggior parte della classe Allora da dove arriva una percentuale così bassa? Il comando appena utilizzato ha generato una cartella chiamata coverage, dentro di essa troveremo il report in formato html. Diamo un’occhiata.

Ecco subito visibile l’inghippo. Il coverage effettivo dei test è al 77%, ma possiamo vedere che sono stati inseriti nel report delle cartelle relative a Qt e a gcc, che non fanno parte del progetto o dei test e che quindi falseranno le statistiche.

Filtriamo il report iniziale escludendo i path che non ci interessano generando un nuovo file di report

lcov -r "mylib.info" "*QtCore*" "/usr/*" -o "mylib-filtered.info"

Possiamo intuire subito dal risultato che le cartelle di troppo sono state rimosse. Il filtro usato è specifico per il progetto, ma si possono sempre utilizzare dei filtri più generici per rimuovere path dei file generati da Qt o dai test, più cartelle di sistema che possono essere linkate dal compilatore o librerie esterne. Questo ne è un esempio:

lcov -r "mylib.info" "*QtCore*" "*QtGui*" "*QtWidgets*" "*Qt*.framework*" "/usr/*" "*.moc" "*moc_*.cpp" -o "mylib-filtered.info"

Rigeneriamo il file html con il file report filtrato

lcov -r "mylib.info" "*QtCore*" "*QtGui*" "*QtWidgets*" "*Qt*.framework*" "/usr/*" "*.moc" "*moc_*.cpp" -o "mylib-filtered.info"

Lanciamo nuovamente il comando di gentml e otteniamo il nuovo report

Adesso vediamo solo la cartella che ci interessa. La percentuale di copertura è al 77.4%, accettabile secondo gcov che lo marca con un’icona gialla. Però questo sta ad indicare che abbiamo mancato di testare qualcosa, ma cosa? Navigando dentro la cartella lib e i singoli file possiamo vedere ogni singolo blocco e linea quante volte è stata eseguita da un test.

Eccolo lì, non abbiamo mai chiamato il costruttore di default. Inseriamo un test che copre questo caso

void Test::test_constructor()
{
   Angle a;
   qDebug() << a;

   QCOMPARE(a.isDeg(), true);
   QCOMPARE(a.deg(), 0.0f);
}

Se vi state chiedendo “ma è davvero necessario testare anche il costruttore di default?” Beh, sì. anzi, è la prima cosa che dovreste testare. In fondo state costruendo un oggetto, prima di verificare che i suoi metodi siano corretti, non sarà meglio accertarsi che le sue invarianti siano come quelle previste? E potremmo anche cominciare a parlare di come possa tornare utile testare tramite i type_traits i vari costruttori di default, ma non è lo scopo di questo articolo…

Altra passata di report…

…e voilà, adesso la classe è interamente testata.

Codice

Trovate il sorgente del progetto completo su questo repository.

Link utili

https://gcc.gnu.org/onlinedocs/gcc/Gcov-Data-Files.html

https://en.wikipedia.org/wiki/Code_coverage

Lascia un commento