Test Driven Development: un esempio con una web app

I parte: Il contesto e i primi passidi

In questa miniserie affrontiamo lo 'sviluppo guidato dalle verifiche' (Test Driven Development): è una pratica agile che ben si inserisce all'interno di più ampie metodologie agili. Attraverso l'esempio di un semplice gioco sotto forma di web app, vedremo alcuni aspetti portanti del TDD.

Introduzione: il contesto

In questo e nel seguente articolo affronteremo con un esempio la tematica del Test Driven Development (TDD). Tale pratica prevede l'utilizzo di cicli brevi di sviluppo e verifica, in cui dapprima si scrive un test automatico per le funzionalità da sviluppare, il quale, ovviamente, fallisce. Successivamente si scrive la minima quantità di codice che consente il superamento del test; infine si passa al refactoring ossia alla ristrutturazione del codice sulla base dei risultati ottenuti. Con questa pratica, si riescono a individuare con esattezza specifiche e caratteristiche del codice, perche' ne viene verificato, a brevi intervalli, il comportamento "reale", che imita le situazioni a cui sarà effettivamente sottoposto. Si ottiene perciò codice funzionante, affidabile e generalmente più pulitio.

Quel che ci interessa è mostrare in modo semplice in quale modo la pratica dello sviluppo guidato dalle verifiche possa applicarsi a un contesto come quello delle web application. Ovviamente si tratta di una via "personale" a questo tema, che si è evoluta nel corso degli anni grazie ai diversi progetti di sviluppo cui ho partecipato.

Un gioco: Boss

Dal momento che da lungo tempo avevo il desiderio di creare un semplice gioco per browser simile nelle meccaniche a GTA, lo prenderò ad esempio per una nuova web application. Il nome del gioco sarà Boss e sarà giocabile in un browser, con un'interfaccia punta e clicca. Quando Boss viene chiamato via HTPP, dovrà presentare una vista dall'alto "a volo d'uccello", con una serie di "isolati" che compongono una città, e con il personaggio principale che si trova al centro della mappa. Ogni isolato sarà composto da diversi rettangoli che rappresentano i vari edifici.

Il personaggio principale, un punto colorato, dovrà muoversi nella direzione che l'utente indicherà facendo click con il mouse sulla mappa.

Un primo passo inusuale

Cominciamo con la prima caratteristica: "Quando Boss viene chiamato via HTTP, dovrà presentare una vista dall'alto a volo d'uccello, con il personaggio principale che si trova al centro della mappa". Be'… si tratta certamente di molta carne al fuoco, perche' le cose da implementare sono tante e non si realizzano certo in pochi minuti.

Pertanto, tenterò di suddividere il problema in vari elementi, per cercare di scrivere in fretta il primo test e la sua soluzione.

La chiamata HTTP

Con il primo test, mi accerterò semplicemente che Boss risponda in maniera positiva a una chiamata http. Per brevità, salterò alcuni cicli e presumerò che la classe Boss già esista, invece di effettuarne il refactoring in seguito al test stesso. Con questa premessa, il mio primo test sarà il seguente:

public class BossBehavior {     
    @Test
    public void shouldAnswerHttpCall()
    throws IOException {
         new Boss(8080);
         WebClient client = new WebClient();
         Page page = client.getPage("http://localhost:8080");
         assertThat(page.getWebResponse().getStatusCode(), is(200));
     }
}

WebClient è presente nel framework htmlunit, che si rivela un modo veloce per invocare un endpoint HTTP. Ovviamente, questo test fallisce, come ci si aspetta.

Di seguito si tenterà invece di far superare il test: non è nei miei piani re-implementare il protocollo HTTP, pertanto mi servirà un buon server HTTP: in passato ho usato Jetty, ma questa volta proverò con Grizzly.

Faccio una breve ricerca sul web e provo questo:

public class Boss {
    public Boss(int port) throws IOException, InstantiationException
        SelectorThread selector = new SelectorThread();
        selector.setPort(port);
        selector.listen();
     }
}

che restituisce una null pointer exception. Per essere più specifici, vedo questa riga:

java.lang.NullPointerException
at com.sun.grizzly.http.ProcessorTask.invokeAdapter(
                  ProcessorTask.java:824)

Bene, quindi non ho passato un Adapter al SelectorThread. Qualche altra lettura e ottengo questo:

public class Boss {
    public Boss(int port) throws IOException, InstantiationException {
        SelectorThread selector = new SelectorThread();
        selector.setPort(port);
        selector.setAdapter(new AlwaysReturn200Ok());
        selector.listen();
    }
    private static class AlwaysReturn200Ok implements Adapter {
         public void service(Request request, Response response)
        throws Exception {
            response.setStatus(200);
        }
        public void afterService(Request request, Response response) 
                  throws Exception {}
    }
}

Funziona bene: il test è verde.

È un test di unità?

Prima di procedere oltre, con il refactoring, ci si potrebbe chiedere: questo è uno unit test? A essere onesto, non mi importa… L'unica cosa veramente importante è che il test è veloce, ripetibile, breve, semplice e funzionerà a patto di avere disponibile una porta 8080. A dire il vero, la porta 8080 è piuttosto usata oggigiorno (server Tomcat per scopi di sviluppo e così via); e quindi, per stare sicuri, d'ora in poi useremo la porta 11111.

Un po' di refactoring

Anzitutto il test: quando ho cambiato la porta scegliendo la 11111, mi sono dimenticato di aggiornare lo URL su cui WebClient effettua la chiamata: un buon promemoria di questa goffa duplicazione che ho introdotto. Oltre a ciò, il test è un po' troppo legato ai dettagli di WebClient, e quindi è meglio effettuare il refactoring di questo prima di passare a Boss. Mi fermo quando arrivo a questo punto:

public class BossBehavior {
    private static final int OK = 200;
    private static final int PORT = 11111;
    @Test
    public void shouldAnswerHttpCall() throws Exception {
        new Boss(PORT);
        assertThat(Http.callOn(PORT), is(OK));
    }
}

Http non contiene nulla di speciale: solo la chiamata a WebClient e l'etrazione del codice di stato.

Refactoring di Boss

Adesso passiamo a Boss. Al momento, Boss è solo un piccolo server HTTP che risponde sempre "200 OK". Da un punto di vista della responsabilità, c'è ben poco da fare, ma di certo non mi piace quel "200" che appare sia nel test che nella soluzione.

Nel contesto della mia applicazione, il codice 200 è solo un modo per dire che la risposta è positiva. Non mi piace usare le primitive per rappresentare concetti ad alto livello, tipo "riposta positiva" e quindi non mi piacciono nemmeno valori primitivi duplicati.

public class HttpAnswer {
    private final int statusCode;
    public static HttpAnswer ok() {
        return new HttpAnswer(200);
    }
    public HttpAnswer(int statusCode) {
        this.statusCode = statusCode;
    }
    public void writeTo(Response response) {
        response.setStatus(statusCode);
    }
    ...
}

Per quanto un po' "prolissa", questa classe rimuove la duplicazione dello stato 200 nel test e nella soluzione e li snellisce ambedue, lasciando al contempo la possibilità di esprimere più chiaramente l'intento. Ecco quindi la situazione corrente per quanto riguarda sia il test che la soluzione:

public class BossBehavior {
    private static final int PORT = 11111;
    @Test
    public void shouldAnswerHttpCall() throws Exception {
        new Boss(PORT);
        assertThat(Http.callOn(PORT), is(HttpAnswer.ok()));
    }
}
 
public class Boss {
    public Boss(int port) throws IOException, InstantiationException {
        SelectorThread selector = new SelectorThread();
        selector.setPort(port);
        selector.setAdapter(new AlwaysReturn(HttpAnswer.ok()));
        selector.listen();
    }
}

Per garantire che le assertion dei test funzionino e per ottenere messaggi ben fatti in futuro, implementerò equals, hashcode e toString per HttpAnswer. Uso a tale scopo le eccellenti classi EqualsBuilder, HashcodeBuilder e ToStringBuilder che provengono da Apache.

public class HttpAnswer {
    ...
    @Override
    public boolean equals(Object other) {
        return EqualsBuilder.reflectionEquals(this, other);
    }
    @Override
    public int hashCode() {
        return HashCodeBuilder.reflectionHashCode(this);
    }
    @Override
    public String toString() {
        return ToStringBuilder.reflectionToString(this);
    }
}

Il passo successivo

Lo scopo resta il medesimo: mostrare una semplice mappa di una città con il personaggio principale nel mezzo. A questo punto mi sono assicurato che sia possibile rispondere alle chiamate HTTP, e quindi si tratta ora di dichiarare qualcosa riguardo ai contenuti della risposta. La versione più semplice di una città è un edificio (anche se Christopher Alexander potrebbe dissentire).

E che cosa è un edificio? Un rettangolo… però non un semplice rettangolo nel nostro caso, perche' dovrà essere visualizzato in un browser, pertanto è un rettangolo all'interno di un documento HTML. Una HttpAnswer deve pertanto contenere un documento HTML.

@Test public void shouldAnswerWithAnHtmlDocument() throws Exception {
    new Boss(PORT);
    assertThat(Http.callOn(PORT),
            is(HttpAnswer.with("")));
}

Però, quando effettuo questo test, ricevo come risultato che quella PORT è già in uso. Questo significa solo una cosa: il mio server Boss del test precedente è ancora attivo e quindi dovrò fermarlo dopo che il test sia stato completato. Pertanto, modifico anche il test, per aggiungerere una operazione di stop a Boss.

@Test public void shouldAnswerHttpCall() throws Exception {
    Boss boss = new Boss(PORT);
    HttpAnswer answer = Http.callOn(PORT);
    boss.stop();
    assertThat(answer, is(HttpAnswer.ok()));
}

Poi la implemento in Boss, come segue:

public class Boss {
    private final SelectorThread selector;
    public Boss(int port) throws IOException, InstantiationException {
        selector = new SelectorThread();
        selector.setPort(port);
        selector.setAdapter(new AlwaysReturn(HttpAnswer.ok()));
        selector.listen();
    }
    public void stop() {
        selector.stopEndpoint();
    }
}

Test sempre rosso

Si noti come il selettore di Grizzly debba adesso essere un campo di Boss. Il test è sempre rosso, ma adesso non dipende dalla necessità della porta, ma da quel che riportiamo di seguito:

Expected: is <org.boss.HttpAnswer@7f60c4b0[statusCode=200,
          payload= ]>
got: <org.boss.HttpAnswer@2a114025[statusCode=200,payload=]>

che è esattamente ciò che mi serve. Farò passare il test e poi procederò con un ulteriore refactoring. Per far sì che questo test sia superato, la cosa ovvia è far sì che Boss aggiunga la pagina HTML a ciascuna risposta.

public class Boss {
    ...
    public Boss(int port) throws IOException, InstantiationException {
        selector = new SelectorThread();
        selector.setPort(port);
        HttpAnswer answer = HttpAnswer.with("");
        selector.setAdapter(new AlwaysReturn(answer));
        selector.listen();
    }
    ...
}

Non devo dimenticare poi di far effettuare il parsing del payload da parte della mia piccola classe di utilità Http: ma se non lo facessi, il test rimarrebbe rosso, e quindi me ne accorgerei.

public class Http {
...
    public HttpAnswer call() throws IOException {
        Page page = client.getPage("http://localhost:" + port);
        int statusCode = page.getWebResponse().getStatusCode();
        String payload = page.getWebResponse().getContentAsString("UTF-8");
        return new HttpAnswer(statusCode, payload);
    }
    ...
}

Test verde: tutto OK?

Il test adesso è verde e sto inviando correttamente del contenuto HTML base. Però c'è una sorpresa… adesso il primo test non è più superato: il test precedente, infatti, si aspettava una risposta HTTP vuota, che contenesse semplicemente il codice di successo 200.

Ora, potrei semplicemente cancellare il test vecchio, sulla base del fatto che si trattava solo di un gradino per arrivare al test presente, il quale a sua volta è qui per farmi avvicinare all'obbiettivo della mia prima funzionalità: mostrare la mappa della città con il personaggio nel mezzo.

Microdivergenza

In realtà non lo cancellerò: cercherò di fare in modo che entrambi i test siano passati. Se Boss è un gioco, allora la mia dichiarazione HTML è lo schermo su cui il gioco apparirà: e il primo test, semplicemente, non si aspetta uno schermo.

public class BossBehavior {
    private static final int PORT = 11111;
    @Test
    public void shouldAnswerHttpCall() throws Exception {
        Boss boss = new Boss(PORT, new BlankScreen());
        HttpAnswer answer = Http.callOn(PORT);
        boss.stop();
        assertThat(answer, is(HttpAnswer.ok()));
    }
    @Test
    public void shouldAnswerWithAnHtmlDocument() throws Exception {
        Boss boss = new Boss(PORT, new HtmlScreen());
        HttpAnswer answer = Http.callOn(PORT);
        boss.stop();
        assertThat(answer,
                  is(HttpAnswer.with("")));
    }
}

Ed eccola, una nuova interfaccia che rappresenta lo schermo su cui appariranno gli oggetti costituenti il gioco. L'interfaccia è abbastanza semplice:

public interface Screen {
    String render(); }

E, ovviamente, HtmlScreen effettua il rendering dell'HTML e dei tag del body, mentre BlankScreen non effettua alcun rendering.

Refactoring

Ora che tutti i testi sono verdi, possiamo procedere con un po' di refactoring. Non mi piace la duplicazione dell'operazione boss.stop() che vedo nel test. Inoltre non mi piace il fatto che il mio test riguardi due diversi livelli di astrazione: il livello generale (che restituisce un documento HTML) e la sua implementazione (il documento HTML è ).

public class BossBehavior {
    private static final int PORT = 11111;
    private Boss boss;
    @Test
    public void shouldAnswerHttpCall() throws Exception {
        boss = new Boss(PORT, new BlankScreen());
        assertThat(Http.callOn(PORT), is(HttpAnswer.ok()));
    }
    @Test
    public void shouldAnswerWithTheScreenContents() throws Exception {
        Screen screen = new HtmlScreen();
        boss = new Boss(PORT, screen);
        assertThat(Http.callOn(PORT),
                            is(HttpAnswer.with(screen.render())));
    }
    @After
    public void stopBossServer() {
        boss.stop();
    }
}

In questo caso, ho aggiunto un nuovo test che è a un livello di astrazione più basso rispetto al primo test; non riguarda tutto il sistema, ma solo la presentazione generale del gioco.

public class HtmlScreenBehavior {
    @Test
    public void shouldRenderAnHtmlDocument() {
        assertThat(new HtmlScreen().render(),
                   is(""));
    }
}

Va notato come il concetto di Screen sia nato per consentire il funzionamento contemporaneo dei due test: "restituisci semplicemente il 200 OK" e "restituisci un documento HTML".

La forza di due test simili ma leggermente divergenti sta nel far emergere i sottosistemi: aspettare fino ad avere due storie chiaramente divergenti può rivelarsi negativo, poiche' questa divergenza si evidenzia troppo tardi. Invece, piccoli passi in una singola storia generano divergenza a piacere.

Il movimento verso il basso

Infine, ecco il terzo test con l'interfaccia Screen che preannuncia la nascita di uno strato GUI: si tratta di un passo molto importante.

Se avere un primo test che si occupa in modo ampio degli aspetti basilari di un sistema lo rende flessibile, d'altro canto usare questo stesso livello di astrazione per dichiarare tutti gli aspetti del sistema rende il test rigido e annulla all'istante il feedback sul design che dovrebbe provenire dal TDD.

Quando la porzione successiva di una storia può essere dichiarata usando una nuova e più piccola parte del sistema, occorre concentrarsi sullla dichiarazione di quella parte: se si riesce a farlo, significa che tale parte ha motivo di esistere. C'è infatti qualcosa di profondamente sbagliato in un sistema in cui l'unico oggetto utile… è il sistema stesso. È questa la ragione per cui i test di accettazione hanno ben poco valore come strumenti per la progettazione di un sistema.

Il TDD non è solo per il dominio business

È sbagliato saltare i primi test di un nuovo sistema solo perche' si tratta di una web application: si generano oltretutto un gran numero di leggende urbane sul fatto che il Test Driven Development funzioni solamente per il dominio business. Che poi… che cosa sarebbe questo famigerato "dominio di business"?

In ogni modo, muoviamoci al passaggio successivo: mostrare un edificio sullo schermo!

Torniamo con i piedi per terra

I miei primi test fanno rispettare parecchie specifiche, ma vorrei vedere come questo sistema funziona dal vero, nel caso abbia dimenticato qualche cosa. Il modo più veloce per farlo è lanciarlo: lo faccio tramite questo metodo molto complesso della classe Boss:

public class Boss() {
    public static void main(String... args) throws Exception {
        new Boss(11111, new HtmlScreen());
    }
    ...
}

Poi avvio il browser e digito:

http://localhost:11111/

Sorpresa! Il browser non analizza l'HTML!

 

 

Figura 1 - Il codice HTML viene visualizzato come testo, invece di essere interpretato e renderizzato.

 

In effetti, un browser non interpreterà il codice HTML a meno che il contenuto non venga dichiarato come tale, cioè non venga comunicato che si tratta di HTML.

È il momento di aggiornare i miei test: basta includere il tipo di content nella mia definizione di corretta HttpAnswer:

    public static HttpAnswer ok() {
        return new HttpAnswer(200,"","text/html");
    }

Adesso, quando effettuo una chiamata HTTP, leggerò contestualmente anche il tipo di contenuto e lo utilizzerò per inizializzare la risposta. Risultato: un test rosso, il primissimo:

Expected: is ...HttpAnswer...contentType=text/html]
               got: ...HttpAnswer...contentType=text/plain]

Aggiustamento veloce: la HttpAnswer dovrebbe impostare il contentType.

public class HttpAnswer {
    ...
    public void writeTo(Response response) throws IOException {
        response.setStatus(statusCode);
        response.setContentType(contentType);
        ByteChunk chunk = new ByteChunk(payload.length());
        chunk.setBytes(payload.getBytes("UTF-8"),0,payload.length());
        response.doWrite(chunk);
    }
    ...
}

Test verde! E adesso solo un po' di refactoring per ripulire questo codice al fine di mantenere lo stesso livello di astrazione per tutto il metodo writeTo:

    public void writeTo(Response response) throws IOException {
        response.setStatus(statusCode);
        response.setContentType(contentType);
        response.doWrite(payloadAsByteChunk());
    }

Conclusioni

Per questo mese ci fermiamo qui e ritorneremo a completare l'esempio nel numero di aprile. Abbiamo comunque già visto alcuni aspetti fondamentali del TDD e come, con l'aiuto dei test, abbiamo "raffinato" a poco a poco il codice. Nella prossima parte proseguiremo con questa pratica e arriveremo a una sempre più completa realizzazione dell'esempio.

 

 

 

 

Condividi

Pubblicato nel numero
192 febbraio 2014
Carlo Bottiglieri è uno sviluppatore che si concentra completamente nel creare applicazioni flessibili e di qualità. Ha iniziato a studiare e applicare il design object-oriented nei primi anni Duemila, con l‘obiettivo di mantenere costante il costo di sviluppo all‘aumentare della complessità. Oltre a sviluppare molti sistemi da zero e ad…
Articoli nella stessa serie
Ti potrebbe interessare anche