Dopo aver ripulito a dovere il codice siamo pronti per aggiungere nuove funzionalità alla nostra applicazione. Chiaramente seguendo le regole del tdd, ossia aggiungendo un nuovo test prima di scrivere nuovo codice.
Prima di iniziare, ho corretto un paio di minor issue sullo script bash che lancia i test, e sulla classe c++ che li raggruppa. In questo modo i test rispondono meglio alla linea di comando, ed è possibile isolare i singoli test case. Come al solito, trovate tutto sul repository git, l'url è in fondo alla pagina.
Una prima banale funzionalità che manca alla nostra applicazione è distinguere tra l'invocazione senza parametri, che come output stampa il messaggio di help, e l'invocazione con uno o più parametri, che invece fa altro.
Testare l'invocazione senza parametri è stato piuttosto facile, ma come testiamo l'invocazione con parametri, se non sappiamo ancora esattamente cosa farà?
Ci viene in aiuto la composizione fra oggetti: assumiamo infatti che la nostra classe
DownloadManagerCore avrà uno (o più) collaboratori addetti al download, in tal caso la nostra classe dovrà semplicemente dare in pasto a questi collaboratori la lista degli url da scaricare.
Dobbiamo quindi identificare questi collaboratori, dare loro un nome (e quindi una responsabilità) e scrivere la loro interfaccia. Non ci interessa implementarli davvero, lo faremo in un secondo momento nei rispettivi test unitari, ci interessa però definire la loro interfaccia in modo da poterne creare istanze "finte" da usare nel test di
DownloadManagerCore.
In una prima approssimazione possiamo pensare di creare un oggetto
Queue, con un metodo
add(QStringList) che viene invocato se l'oggetto
DownloadManagerCore riceve una lista di argomenti non vuota.
Cominciamo quindi a creare la sua interfaccia: dato che si tratterà di un'interfaccia di dominio, creiamo il suo header nella sottodirectory
domain del progetto
downloadmanagercore.
class Queue {
public:
virtual void add(const QStringList &urls)=0;
};
Ora possiamo scrivere una sua implementazione
Fake (nel progetto UnitTests) su cui faremo delle asserzioni.
Gli oggetti
Fake, detti anche
Mock Objects, sono degli utili strumenti per verificare come gli oggetti interagiscono fra loro.
In realtà alcuni autori suggeriscono varie differenze tra mock objects, fake e stub, sopratutto legate alla loro programmabilità. Per semplicità al momento assumeremo equivalenti mock e fake, la rete è comunque piena di approfondimenti.
Vale la pena di specificare che in C++ è indispensabile che un oggetto abbia dei metodi
virtual per poter essere correttamente mockato.
Un buon design fa comunicare fra loro le classi sopratutto tramite interfacce piuttosto che tramite oggetti concreti, questo facilita molto la creazione di
mock object adeguati.
Tornando alla classe
FakeQueue, per poter fare delle asserzioni un field
addWasCalled di tipo bool, e un field di tipo QStringList chiamato
receivedUrls. Nel nuovo test verificheremo quindi che
addWasCalled venga settato a true, e che
receivedUrls sia uguale alla lista degli url ricevuti da
DownloadManagerCore.
Questo tipo di oggetto permette di testare l'interazione tra la classe sotto test (
DownloadManagerCore) ed i suoi collaboratori.
class FakeQueue : public Queue
{
Q_OBJECT
public:
explicit FakeQueue(QObject *parent = 0);
bool addWasCalled;
QStringList receivedUrls;
void add(const QStringList &urls);
};
FakeQueue::FakeQueue(QObject *parent) :
Queue(parent), addWasCalled(false), receivedUrls(QStringList())
{
}
void FakeQueue::add(const QStringList &urls)
{
addWasCalled=true;
receivedUrls=urls;
}
Aggiungiamo il nuovo test case, che verifica che la chiamata
add viene invocata istanziando
DownloadManagerCore con almeno un argomento.
void DownloadManagerCoreTest::itShouldQueueUrlsWhenCalledWithArguments()
{
QStringList urlList = QStringList() << "first url" << "second url";
QString output;
QTextStream outputStream(&output);
FakeQueue queue;
DownloadManagerCore *lib = new DownloadManagerCore(&outputStream, urlList, &queue, this);
lib->start();
QCOMPARE(queue.receivedUrls, urlList);
QCOMPARE(output, QString() );
QVERIFY2(queue.addWasCalled, "Add method should be called on queue");
}
In questo momento il test non compila neanche, questo perchè
DownloadManagerCore non accetta ancora un puntatore a
Queue in costruzione; come prima cosa quindi modifichiamo la firma del costruttore di
DownloadManagerCore.
DownloadManagerCore(QTextStream *output, const QStringList &arguments, Queue *queue, QObject *parent = 0);
Naturalmente dovremo sistemare anche gli altri test, che adesso avranno bisogno di
Queue istanziando
DownloadManagerCore.
Adesso vediamo finalmente il test fallire come ci aspettiamo: ci dice che si aspetta una
QStringList di due stringe, e ne trova una vuota. Dobbiamo quindi far si che
DownloadManagerCore accodi i parametri. Ma se invochiamo il metodo
add su
queue, il test fallisce perchè si aspetta che l'output (il messaggio di help) non venga scritto. Dobbiamo quindi aggiungere della logica di controllo dei parametri.
class DownloadManagerCorePrivate {
public:
DownloadManagerCorePrivate(QTextStream *output, const QStringList &arguments, Queue *queue);
QTextStream *output;
QStringList arguments;
Queue *queue;
void printHelpMessage();
};
void DownloadManagerCore::start()
{
Q_D(DownloadManagerCore);
if(d->arguments.isEmpty()) {
d->printHelpMessage();
emit finished();
return;
}
d->queue->add(d->arguments);
}
Adesso abbiamo bisogno di un'implementazione vera di
Queue, in modo da poter anche compilare l'eseguibile principale. Questa classe deve:
- ricevere la lista dei parametri
- memorizzarla in una lista di url
- effettuare validazioni
- avviare il download
Sappiamo già che la classe
Queue dovrà collaborare in qualche modo con
QNetworkAccessManager, la classe di Qt addetta alle comunicazioni http.
Dovremo inoltre farla collaborare con un'altra entità, un
FileWriter, che a fronte della ricezione di dati scrive il tutto su filesystem. Normalmente per testare questa classe è meglio far ricorso agli
IntegrationTests, che però affronteremo più avanti. Se invece volessimo testare unitariamente una classe che collabora con alcune classi, come
QNetworkAccessManager, di cui non possiamo creare implementazioni
mock, come potremmo fare?
Purtroppo non c'è una risposta semplice, ed è proprio per questo che in questi casi si preferiscono di gran lunga i test di integrazione; tuttavia come esercizio proviamo ad elaborare una strategia.
Quella forse più efficace è di incapsulare le classi Qt da mockare in oggetti
decorator, che implementeranno un'interfaccia che sarà la base del nostro
mock object. In questo modo possiamo facilmente mockare i
decorator, che saranno sufficientemente "stupidi" da non richiedere ulteriori test. Il
decorator avrà anche il vantaggio di esporre solo i metodi che ci serviranno davvero, ripulendo l'interfaccia pubblica della classe decorata.
Per semplicità al momento ci occuperemo solo di testare la funzionalità di avvio e accodamento downlod.
Le classi che dovremo decorare saranno principalmente due:
- QNetworkAccessManager, che diventerà NetworkAccess
- QNetworkReply, che diventerà NetworkReply.
Chiaramente i test sono fatti apposta per evolversi man mano che le funzionalità vengono aggiunte, intanto però possiamo fare una lista dei metodi dell'interfaccia pubblica che avrà la classe
NetworkAccess:
- NetworkReply * get ( const QNetworkRequest & request)
- [signal] void finished ( NetworkReply * reply )
Ecco invece l'interfaccia di NetworkReply:
- [signal] void QIODevice::readyRead ()
- QByteArray QIODevice::readAll ()
Quello che dovremo testare, in sintesi, è che l'implementazione
HttpQueue dell'interfaccia
Queue dovrà dire a
NetworkReply di cominciare il download, fermandosi appena raggiunto un limite, accodando le richieste successive una volta finito uno dei download in corso.
Il codice risultante è un po' lunghetto da pubblicare qui, rimando quindi direttamente al
branch github per questa puntata. In ogni caso, per quanto un po' tedioso, il test ha comunque fatto il suo dovere, permettendoci di sviluppare una funzionalità molto legata alla rete senza di fatto avviare alcuna connessione.
* normalmente è uno smell avere in una classe dei field pubblici. È però vero che questa è una classe non applicativa, che esiste solo nei test e che non ha vere responsabilità se non memorizzare il valore di quei field e fornirlo a chi li testa.