Test Driven Development di applicazioni C++/Qt
Nel corso degli anni ho conosciuto molte persone che, pur comprendendo l’importanza di scrivere test per il proprio codice, non ne scrivevano a sufficienza. Le ragioni che ho raccolto erano le più svariate, dal “non so come si scrivono”, al “è complicato scriverli nella nostra base di codice” fino al mitico “su embedded non si fa unit testing”. In questo articolo vi proporrò una metodologia pragmatica per rendere la scrittura di test appagante.
Esempio di base
Vorrei partire descrivendo una configurazione di classi che userò in tutto l’articolo. Immaginiamo di avere una classe Gate
che fa I/O tramite una classe Protocol
: invia comandi e riceve risposte da una connessione di rete di qualche tipo. Ad oggi ipotizziamo che la connessione di rete sia MQTT, ma nulla vieta di immaginarla WebSockets.
Userò il framework Qt Test, perché ha delle funzionalità specifiche per Qt che lo rendono comodo, ma è anche utilizzabile senza problemi per codice C++ standard.
In questo articolo mi concentrerò sulla scrittura di unit test, ma gran parte delle nozioni di cui parleremo sono applicabili anche ai test di integrazione.
Struttura del progetto
Cominciamo dalla cosa più banale: se i test sono complicati da aggiungere o rendono la build più complicata, non li aggiungeremo mai. Quindi la struttura del progetto deve essere tale per cui:
- i test sono ben isolati dal resto del codice in una sotto directory
- i test sono compilati come parte della normale build del software
- deve essere possibile fare mock di classi concrete
Un’altra caratteristica utile per i test è che siano eseguibili multipli. In questo modo diventa possibile lanciarli in parallelo, risparmiando tempo, e se uno crasha gli altri possono proseguire e darmi una visione d’insieme sui fallimenti.
La struttura con cui mi sono trovato bene è la seguente:
Una struttura di questo tipo è particolarmente importante per i progetti QMake, perché ogni singolo file .pro può gestire un solo target. Se vogliamo quindi fare una build unica dobbiamo usare il target subdirs, che richiede sottodirectory nel filesystem.
Focalizziamoci sulla directory tests
: racchiude al suo interno le sottodirectory per i singoli test case, più una directory di utilità che ci serve per accogliere le implementazioni di codice a comune tra i test.
In alcuni casi, già solo la ristrutturazione del progetto può rendere la scrittura di nuovi test più agevole.
Scrivere classi testabili
L’interfaccia che create per le vostre classi influenza la facilità di testing, e purtroppo nessuno mi ha mai spiegato questa cosa all’inizio della mia carriera!Riprendendo una parte dello zen di Python, “Esplicito è meglio che implicito”. Nel caso del C++ e del testing, vuol dire che il costruttore della classe deve elencare tutte le dipendenze, siano esse oggetti, path, callbacks etc. Detto altrimenti, la classe non deve “andare a prendersi” quello che gli serve all’interno del costruttore. Vediamo un esempio:
class GuiSettings {
public:
GuiSettings();
private:
QString m_path;
QObject *m_other;
};
GuiSettings::GuiSettings() {
m_path = "fixed/path/settings.ini";
m_object = buildMyObject();
}
Questa classe è poco testabile per alcuni motivi:
- compilare un test di questa classe richiede di poter compilare e linkare la funzione
buildMyObject()
e tutte le sue dipendenze; - in questa configurazione, è abbastanza tipico che
buildMyObject()
ritorni uno static (o un singleton) e questo è un problema perché sto introducendo delle dipendenze tra test, in quanto la variabile static è viva finché il processo non termina. Mi è capitato più volte che cambiando l’ordine dei test case cambiasse anche il risultato… - il path del file di settings è fisso e il file system è un sistema di storage condiviso tra processi per definizione, per cui prima di ogni test mi devo preoccupare di ripristinare lo stato del file ad una condizione nota.
class GuiSettings {
public:
GuiSettings(QString settings_path, QObject *other);
};
Questa classe invece è molto più testabile perché dichiara tutte le sue dipendenze nel costruttore, quindi ogni singolo test può passare ciò che serve. Tipicamente il path sarà quello di un file temporaneo, mentre l’oggetto è un’istanza che viene distrutta alla fine di ogni singolo test case.
Resta però un problema: come faccio l’inizializzazione? Serve un punto nell’applicazione in cui si collegano tutte le classi tra di loro. Può essere in main()
oppure un’altra classe builder; io la chiamo GlobalProperties
e nel suo costruttore metto tutto quello che mi serve:
GlobalProperties::GlobalProperties() {
m_notifications = new Notifications;
m_guiSettings = new GuiSettings("", m_notifications);
}
Dependency Inversion
In estrema sintesi, usare la dependency inversion vuol dire scrivere il codice basandosi su interfacce invece che su tipi concreti. Per approfondire potete guardare l’articolo su Wikipedia o un’ottima spiegazione con figure colorate sul sito di Azure cloud.
Usare in modo estensivo la Dependency Inversion (e le classi helper che vedremo sotto) semplifica di un ordine di grandezza la scrittura di test per il codice C++.Riprendendo l’esempio che abbiamo fatto all’inizio, la classe di alto livello Gate
conosce solo un’interfaccia IProtocol
invece che la classe concreta Protocol
. Questo permette di scrivere i test con estrema facilità. Vediamo come:
// iprotocol.h
class IProtocol : public QObject {
Q_OBJECT
public:
IProtocol(QObject *parent) : QObject(parent) {}
signals:
void incomingMessage(QByteArray b);
};
// gate.h
class Gate: public QObject {
Q_OBJECT
public:
explicit Gate(IProtocol *protocol, QObject *parent);
int percentage();
private:
IProtocol *m_protocol;
};
// test_gate.cpp
class MockProtocol : public IProtocol {
public:
MockProtocol();
void emitMessage(QByteArray msg) { emit incomingMessage(msg); }
};
void TestGate::test_readLevel() {
MockProtocol m;
Gate g(&m);
m.emitMessage("{\"gateLevel\": 30}");
QCOMPARE(g.percentage(), 30);
}
Il test readLevel()
istanzia un oggetto MockProtocol che implementa l’interfaccia necessaria. Avere un’interfaccia mi permette anche di testare casi più particolari in modo semplice, come l’arrivo ritardato di un messaggio dal protocollo.
// test_gate.cpp
class DelayedProtocol : public IProtocol {
public:
DelayedProtocol();
void emitMessage(QByteArray msg) { QTimer::singleShot(500, []{emit incomingMessage(msg);}; }
};
Test Driven Development
Il TDD è una pratica di sviluppo software in cui i test si sviluppano prima del codice. Applicata quotidianamente a una base di codice in evoluzione, ci permette di creare i test insieme all’applicazione. Numerosi studi* certificano anche che si ha un miglioramento all’umore dei programmatori (*: potrei essermi inventato tutto e basarmi solo sulla mia esperienza).
Il TDD si basa principalmente su due assunti:
- scrivere il test prima di scrivere il relativo codice: il test è il primo utilizzatore dell’interfaccia pubblica della classe e, grazie ai test, riusciamo a raffinare l’interfaccia di una classe prima di usarla nel programma;
- vedere il test fallire prima di scrivere il codice: questo ci garantisce che il test è rilevante, ossia espone un comportamento errato della nostra classe. Solo così possiamo stare certi che il test ci protegga da future regressioni di quella funzionalità.
Mi è capitato più di una volta di avere un bug, capire dov’era, scrivere il relativo test e vederlo passare prima di aver toccato il codice. In questi casi sono tornato indietro a modificare il codice del test perché non esponeva il bug che volevo sistemare. Se non avessi controllato, avrei scritto del codice che non era protetto da regressioni.
In concreto, una sessione di TDD per nuovo codice si divide in questi passi:
- scrivere l’interfaccia del metodo nel .h
- fare un’implementazione vuota nel .cpp, così il test può linkare
- scrivere i test che esercitano l’interfaccia
- eventualmente migliorare l’interfaccia della classe
- scrivere l’implementazione che passa tutti i test
Abbiamo visto come si fa il testing, adesso concentriamoci su cosa testare. La regola generale è testare solo l’interfaccia pubblica della classe, perché è quella che è esposta all’interazione con gli altri oggetti. Inoltre, pensare ai test in termini di interfaccia pubblica permette di concentrarci sulle interazioni di alto livello, senza preoccuparci dei dettagli implementativi che potrebbero cambiare con il tempo.
Facciamo un esempio: mi è capitato di implementare una parte di codice che mostra un popup dopo una settimana da un dato evento. La prima implementazione usava un QTimer, poi sono passato a un’implementazione a polling. Siccome avevo scritto il test solo in termini di interfaccia pubblica, non ho dovuto cambiarlo e anzi ho avuto la conferma che il mio refactoring non ha introdotto regressioni.
Nel caso di codice molto complicato, mi servo spesso di classi helper; una classe helper è una piccola classe iper specializzata, generalmente usata in un singolo punto del programma. Le uso per dare una API semplice e lineare a una logica contorta o con molto stato.
In questo caso, ha senso testare l’interfaccia pubblica di una classe helper, anche se è quasi un dettaglio implementativo.
Infine, se proprio non c’è altro modo di scrivere i test, è possibile fare check sui membri privati della classe, mettendo la classe di test “friend” della classe sotto test. Però vi lascio una linea guida: non usate questa scappatoia più di una o due volte all’anno. Vi assicuro che è possibile e vi spinge a rendere testabili le vostre classi.
Quando non scrivo test
Ci sono alcune condizioni in cui non scrivo test: quando faccio script, quando scrivo codice GUI, quando faccio dei prototipi. Pensandoci bene, sono quelle situazioni in cui ritengo che il costo di sviluppare una suite di test sia più oneroso che non sviluppare e testare a mano. Potete anche consultare questa tabella per una stima di massima.
Per quanto riguarda il codice GUI, cerco di stare molto attento a lasciare la UI più semplice possibile e usare i binding per gestire gli aggiornamenti.
Riguardo ai prototipi invece, sappiamo benissimo che poi si trasformano nel prodotto finale, quindi dopo una prima fase di studio di fattibilità comincio a scrivere test.
Come testare classi esistenti
Vediamo adesso come testare classi concrete che già esistono. Abbiamo dunque la classe Gate
che usa un’istanza concreta di Protocol
. Come possiamo fare per avere un’implementazione finta (mock) con metodi e membri aggiuntivi? Quello che vogliamo ottenere è far vedere a gate.h
dichiarazioni diverse della classe Protocol
a seconda di cosa stiamo compilando: vogliamo vedere la dichiarazione “vera” quando compiliamo il programma (o la libreria), mentre vogliamo la dichiarazione mock quando compiliamo il test.
Premetto che la soluzione è dipendente dal compilatore e dal build system, quindi non è detto che funzioni in ogni caso. Il trucco è usare gli include <> invece degli include “” all’interno di gate.h
. Poi, massaggiando un po’ il build system dovreste essere in grado di compilare il test con la versione della classe Protocol
che preferite.
L’unica accortezza da avere è implementare tutti i metodi dell’interfaccia Protocol
usati da Gate
, altrimenti il test non linkerà… ma su questo direi che siamo tutti d’accordo, giusto? 😉
Come esempio, potete guardare il codice su Github che usa CMake.
Un altro piccolo inconveniente è che non è facile avere implementazioni multiple della classe Protocol
come abbiamo visto nella sezione sulla dependency inversion. Tuttavia con un po’ di creatività dovrebbe essere possibile implementarlo, come potete vedere nell’esempio.
Conclusioni
Programmare usando il test driven development è così soddisfacente e utile che lo consiglio a tutti. Purtroppo a volte non è un facile per una serie di motivi:
- struttura del progetto: il linguaggio C++ non rende semplice la scrittura di test e molto dipende dal build system e dalle librerie usate;
- mancanza di formazione: come scrivo praticamente un test? Cosa devo testare?
- codice esistente: come faccio a testare codice esistente non pensato per il testing?
Con questo articolo spero di avervi dato gli strumenti e le giuste dritte per iniziare a scrivere test sul vostro progetto. Per quanto riguarda gli approfondimenti ci sono tantissime risorse online, ma le mie fonti di ispirazione preferite sono questo articolo di Miško Hevery, il Google testing blog e Testing on the Toilet.
Guarda anche il video di Luca Ottaviano su Youtube: “Test Driven Development di applicazioni C++ Qt“