Test Driven Development: un esempio con una web app

II parte: Completiamo il nostro esempiodi

Concludiamo la miniserie sullo 'sviluppo guidato dalle verifiche' (Test Driven Development): in questo secondo articolo portiamo avanti il nostro esempio consistente in un semplice gioco sotto forma di web app, concentrandoci sui test per il comportamento e il controllo degli elementi della web app.

Disegnare un edificio

A questo punto, qual è il prossimo compito in lista? Desidero che sullo schermo appaia un rettangolo che rappresenti un edificio. Da una ricerca su Google, ho reperito una comoda libreria JavaScript che consente di disegnare elementi vettoriali su un browser: si chiama Raphael e vale la pena provarla.

Pertanto, usando questa libreria, scriviamo un file HTML che disegni un rettangolo:
    
    

Grazie a questo codice ottengo quanto illustrato in figura 1: niente male. L'edificio è il piccolo rettangolo in alto a sinistra.

Figura 1 - Il rettangolo in alto a sinistra rappresenta schematicamente un edificio.

A questo punto, vorremmo che questo fosse restituito dal nostro HtmlScreen.

Effettuare il test del comportamento

 

Si potrebbe scrivere un test come il seguente:
@Test
     public void shouldRenderABuildingAsARectangle() {
         assertThat(new HtmlScreen().render(), is("" +             "" +
             "   " +
             "   " +
             "" +             "" +
             "" +             ""));
     }

Si tratterebbe però di un test terribilmente fragile, perche', per essere più precisi, non dichiarerebbe ciò che mi interessa veramente. E che cosa mi interessa? Se fosse un test che punta a verificare alcune funzionalità interne, sarebbe più facile, poiche' bisognerebbe preoccuparsi solo di ciò che il client di un oggetto si aspetta come comportamento di questo. Qui, però il "client" della resa a schermo da parte di HtmlScreen è l'utente, ossia quel bipede che sta guardando lo schermo del computer…

Quel che davvero mi piacerebbe scrivere nel test è:
@Test
     public void shouldRenderABuildingAsARectangle() throws Exception {
         User user = new User().lookAt(new HtmlScreen().render());
         assertThat(user.currentSight(),
       is("A Rectangle at [10,10], 40px high and 50px wide"));
     }

Pertanto, penso che scriverò proprio questo e troverò un modo per farlo funzionare. Un test solido e significativo vale sicuramente lo sforzo che è necessario per realizzarlo.

Tre aspetti principali

Ci sono qui tre differenti punti cruciali:
  • L'utente dovrebbe essere in grado di guardare alla resa a schermo ed "estrarre" le parti significative per lui, il che adesso significa JavaScript.
  • L'utente dovrebbe essere in grado di effettuare una valutazione di tali parti significative e fornirmi una breve descrizione del risultato finale.
  • HtmlScreen dovrebbe restituire l'HTML corretto, con lo script necessario a soddisfare l'utente.

Prima di affrontare questi punti, colloco il mio HTML dentro una stringa all'interno del testo stesso, e lo passo all'utente: voglio essere sicuro che saprò quando il mio utente lo userà.

@Test
    public void shouldRenderABuildingAsARectangle() throws Exception {
         String html = "" +             "" +
             "   " +
             "   " +
     "" +     "" +
     "";
         User user = new User().lookAt(html);
         assertThat(user.currentSight(),
     is("A Rectangle at [10,10], 50px high and 40px wide"));
     }
E adesso procediamo con l'implementazione dell'utente.

Il primo problema si risolve facilemente: farò in modo che l'utente effettui il parsing dell'HTML ricevuto ed estragga tutti gli script.

Il secondo problema viene parzialmente risolto utilizzando Rhino per valutare gli script. C'è un aspetto che però resta in sospeso: anche se posso far girare il JavaScript nel mio user, questo JavaScript non produce una descrizione testuale creativa e facile da controllare di quello che l'utente vede, ma invece effettua una chiamata a Raphael che a sua volta compie qualche "magia" di grafica vettoriale.

Fortunatamente, creare stub di oggetti o di intere libreria in JavaScript è abbastanza facile. Pertanto, nei nostri test, sostituiremo la libreria Raphael con questo file:

    function Raphael(x, y, width, height){
         this.rect = function(x, y, width, height) {
         output += "A Rectangle at [" + x + "," + y + "],
           " + height + "px high and " + width + "px wide";
         }
     }
     var output = "";

Primi risultati con i test

Lanciando questo, il test è sempre rosso: Rhino dice che non c'è alcun oggetto Window a disposizione. A dire il vero, l'oggetto finestra è fornito dal browser e quindi mi dovrò assicurare che, nella valutazione di HtmlScreen, il mio user esegua un file browser.js prima di qualunque altra operazione.

    function Window() {}
     var window = new Window();

Adesso il test è verde. Sposto html to HtmlScreen, e il test è sempre verde, ma il vecchio test, il primo per HtmlScreen che avevamo visto nella parte precedente, adesso è rosso.

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

Questo, però, non è più vero. La progettazione della mia app potrà trarre giovamento da un'interpolazione nella microdivergenza tra questi due test? Forse, ma per questo ultimo test ho aggiunto molte cose e non voglio continuare con ulteriori cambiamenti nel codice di produzione solo per soddisfare il test iniziale che adesso non è più aggiornato, visto oltretutto che la ragione per cui il test fallisce è che esso è fortemente dipendente dalla effettiva stringa HTML. Questo non va molto bene, specialmente visto che, nel test più recente, si è fatto così tanto per evitare di essere dipendenti dalla stringa HTML.

Infine, ho aggiunto una libreria DOM per l'uso all'interno dello user: è davvero veloce modificare il test affinche' dichiarli la stessa cosa, senza però essere così fragile:

    @Test
     public void shouldRenderAnHtmlDocument() throws Exception {
         Document document = new Builder().build(new HtmlScreen().render(), null);
         Element root = document.getRootElement();
         assertThat(root.getLocalName(), is("html"));
         assertThat(root.getChildElements("body").size(), is(1));
     }

Librerie XML

È un problema mia, o ancora non esiste una libreria XML per Java che sia davvero semplice? Certo, c'è XOM, che ho deciso di provare dopo anni di JDOM, DOM4J e la DOM base di Java (ahi!): XOM ha un costruttore più semplice (bene!), una navigazione non eccelsa (male!), e costringe a passare un valore null nel costruttore dal momento che non ho un URL di base (molto male!).

Ad ogni modo, i test di HtmlScreenBehavior sono tutti verdi; ho un solo test rosso, ed è quello di BossBehavior, che si "lamenta" del fatto che Raphael non esiste.

Risorse statiche

Di fatto, il WebClient che fa la chiamata HTTP sta effettuando il parsing dell'HTML dalla risposta del server e sta facendo una chiamata al server per ottenere il raphael-min.js; ma attualmente il mio server non restituisce risorse statiche.

Anche in presenza di un test rosso, ne aggiungo in BossBehavior uno nuovo, molto più "specializzato", che mi fornirà un feedback chiaro e potrà confermare la mia ipotesi sull'origine dell'errore.

    @Test
     public void shouldReturnAStaticFileWhenAPathIsProvided() throws Exception {
         boss = new Boss(PORT, new BlankScreen());
         assertThat(Http.callOn(PORT, "sample.content.txt"),
         is(HttpAnswer.with("foo content bar").type("text/plain")));
     }

All'interno dell file sample.content.txt ottengo la stringa "foo content bar". E il test infatti fallisce, come ci si attendeva.

Grizzly ha un adattatore ingegnoso per questo tipo di situazioni, che si chiama StaticResourcesAdapter; ma come si fa a scegliere tra questo e il mio adattatore originario? Be'… quando una risorsa è richiesta, impiegheremo lo StaticResourcesAdapter, quando nessuna risorsa sia richiesta, basterà il mio adattore originario: non vi sembra una sorta di mappatura?

public class AlwaysReturn{
 ...
     public void service(Request request, Response response) throws Exception {
         String path = request.unparsedURI().toString();
         if(path != null && !path.equals("/")) {
         filesRetriever.service(request, response);
         } else {
             HttpAnswer.with(screen.render()).writeTo(response);
         }
     }
     ...
 }

Qui, il filesRetriever è l'adapter per le risorse statiche di Grizzly. A questo punto si effettuano i test e… sono tutti verdi! Ma voglio effettuare un ulteriore controllo: faccio partire Boss con il suo metodo main, ed effettuo una chiamata tramite browser, ottenendo quanto riportato in figura 2: eccolo il primo edificio della mia città.

Figura 2 - Un primo riscontro dell'applicazione Boss nel browser: la chiamata alla web app di esempio mostra correttamente il primo edificio nella finestra.

Ulteriori aggiustamenti

Uno sguardo onesto all'ultima porzione di codice scritta, comunque, mostra alcune cose che non mi piacciono: il metodo del servizio è troppo "sporco". Svolge operazioni a partire da livelli multipli di astrazione, e armeggia con l'adattatore statico mentre svolge, al contempo, anche azioni che sono specifiche dello schermo. Infine, appartiene a una classe AlwaysReturn il cui nome ha perso il suo originale significato. Per ora, lo ripulirò in maniera abbastanza diretta:

    private static class RootAdapter implements Adapter {
         private final StaticResourcesAdapter resourcesAdapter;
         private final ScreenAdapter screenAdapter;
  
         public RootAdapter(StaticResourcesAdapter resourcesAdapter,
               ScreenAdapter screenAdapter) {
             this.screenAdapter = screenAdapter;
             this.resourcesAdapter = resourcesAdapter;
         }
         public void service(Request request, Response response) throws Exception {
             if(pathIsPresentIn(request)) {
             resourcesAdapter.service(request, response);
             } else {
             screenAdapter.service(response);
             }
         }
         ...
     }
E i test sono ancora verdi…

Controllare l'edificio

Nel semplice gioco che stiamo sviluppando e che rappresenta la web app di esempio, fin qui abbiamo effettuato la resa a video di un edificio: ma questa resa è statica e in realtà non è possibile contrallara in alcun modo.

Pertanto aggiungerò un nuovo test:
    @Test
     public void shouldRenderTheBuildingWithTheRightPositionAndDimensions() {
         User user = new User().lookAt(new HtmlScreen().addBuilding(50,30,40,80).render());
         assertThat(user.currentSight(), is("A Rectangle at [50,30], 40px high and 80px wide"));
     }

Il test non compila: mi serve il metodo addBuilding, che è aggiungibile con facilità, ovviamente vuoto. Il test infatti fallisce, perche' continuo a ottenere il vecchio rettangolo nella vecchia posizione.

Passare il test

Questo codice, invece, mi dà test verde.
public class HtmlScreen implements Screen {
     private Building building = new Building(10, 10, 40, 50);
     public String render() {
         String start = "" +
     "   " +
     "   ";
        return start + building.render() + end;
     }
     public Screen addBuilding(int x, int y, int height, int width) {
         building = new Building(x, y, height, width);
         return this;
     }
 }

Però questo codice è brutto. Lo HtmlScreen assembla l'HTML generale, importando Raphael e componendo lo script. Anche Building.render() è fortemente accoppiato con Raphael. È inoltre accoppiato con il fatto che il canvas di Raphael è denominato map, come è possibile vedere di seguito:

    public String render() {
         return "map.rect(" + x + ","+ y + "," + width + "," + height + ");";
     }

Così non va bene. Voglio che tutto        ciò che è relativo a Raphael stia isolato e desidero che HtmlScreen si focalizzi sull'assemblaggio dell'HTML in generale, non sui dettagli dello script.

Credo che serva un oggetto VectorGraphics.
 public class HtmlScreen {
     ...
     public String render() {     return header() +
     vectorGraphics.include() +
     openScript() +
     openFunction() +
     vectorGraphics.init() +
     building.render(vectorGraphics) +
     closeFunction() +
     closeScript() +
     ending();
     }
     ...
 }
  
 public class VectorGraphics {
     public String include() {
         return "";
     }
         public String init() {
         return "var map = new Raphael(0,0,600,400);";
     }
     public String rect(int x, int y, int width, int height) {
         return "map.rect(" + x + ","+ y + "," + width + "," + height + ");";    }
 }
  
 public class Building {
     ...
     public String render(VectorGraphics vectorGraphics) {
         return vectorGraphics.rect(x, y, width, height);            }
     ...
 }

Non so se questa idea di un renderer per grafica vettoriale, usato dall'edificio e anche dall'HtmlScreen, rappresenti un nuovo e più basso livello di astrazione. In teoria lo è, dal momento che gestisce ogni dettaglio dell'uso di JavaScript per la grafica; ma le forti astrazioni non sono sempre le più evidenti, e solo i test potranno confermarlo.

Molteplici edifici!

Scommetto che non sarà difficile per i lettori comprendere dove stiamo andando a parare: abbiamo un singolo edificio, ma desidero averne molti altri sulla pagina, per poi finalmente collocare il giocatore in mezzo a loro.

Pertanto, aggiungo due ulteriori rettangoli al mio investigation.html:
            ...
             var map = new Raphael(0,0,600,400);
             map.rect(10,10,50,40);
             map.rect(80,10,30,40);
             map.rect(10,70,100,40);
             ...
che produce il risultato di figura 3.

Figura 3 - Ed ecco molteplici edifici nella nostra pagina.

Ed ecco il test che mi consentirà di implementare tutto questo:
    @Test
     public void shouldRenderMultipleBuildings() throws Exception {
         HtmlScreen htmlScreen = new HtmlScreen();
         htmlScreen.addBuilding(10,10,50,40);
         htmlScreen.addBuilding(80,10,30,40);
         htmlScreen.addBuilding(10,70,100,40);
         User user = new User().lookAt(htmlScreen.render());
         assertThat(user.currentSight(), is("A Rectangle at [10,10], 40px high and 50px wide"));
         assertThat(user.currentSight(), is("A Rectangle at [80,10], 40px high and 30px wide"));
         assertThat(user.currentSight(), is("A Rectangle at [10,70], 40px high and 100px wide"));
     }
Il risultato del test è:
Expected: is "A Rectangle at [10,10], 40px high and 50px wide"
     got: "A Rectangle at [10,70], 40px high and 100px wide"

che però è soltanto l'ultimo edificio. La ragione di tutto ciò è dovuta alla mia implementazione dello schermo HTML, il quale mantiene l'ultimo edificio disegnato, ma sovrascrive quelli precedenti. Non è difficile sistemare il problema:

    private String renderBuildings() {
         String renderedBuildings = "";
         for (Building building : buildings) {
             renderedBuildings += building.render(vectorGraphics);
         }
         return renderedBuildings;
  
     }
         public Screen addBuilding(int x, int y, int width, int height) {
     buildings.add(new Building(x, y, width, height));
     return this;
         }
     function Raphael(x, y, width, height){
     this.rect = function(x, y, width, height) {
         output[invocations++] = "A Rectangle at [" + x + "," + y + "], " +
        height + "px high and " + width + "px wide";
     }
 }
 var output = [];
     public String currentSight() {
         return (String) output.get(current++); 
 }
Ecco il test reale:
    public static void main(String... args) throws Exception {
         HtmlScreen htmlScreen = new HtmlScreen();
         htmlScreen.addBuilding(10,10,50,40);
         htmlScreen.addBuilding(80,10,30,40);
         htmlScreen.addBuilding(10,70,100,40);
         new Boss(11111, htmlScreen);
     }

Ma, a questo punto, i primissimo test HtmlScreenBehavior non è più felice, perche' si aspetterebbe un rettangolo; ora il rettangolo deve essere aggiunto esplicitamente:

    @Test
     public void shouldRenderABuildingAsARectangle() throws Exception {
         User user = new User().lookAt(
       new HtmlScreen().addBuilding(10, 10, 50, 40).render());        
     assertThat(user.currentSight(),
       is("A Rectangle at [10,10], 40px high and 50px wide"));
     }
Adesso il test è superato: tutti i test sono verdi.

Refactoring

Sono felice che sia arrivato il momento di effettuare un refactoring, poiche' i testi che ho scritto appaiono veramente brutti. Per esempio, diamo un'occhiata a questo test modificato, che corrisponde a quello appena mostrato:

    @Test
     public void shouldRenderTheBuildingWithTheRightPositionAndDimensions() {
         User user = new User().lookAt(
        new HtmlScreen().addBuilding(50, 30, 80, 40).render());
         assertThat(user.currentSight(),
        is("A Rectangle at [50,30], 40px high and 80px wide")); 
             }

Sì, sono sostanzialmente uguali, e l'unica differenza sta nei valori. È questo l'unico caso in cui prendo in considerazione di cancellare un test senza che ci sia un cambiamento nelle caratteristiche: quando esso dice esattamente le stesse cose di un altro test.

E quindi… addio! Cancello il secondo, poiche' preferisco il nome del primo. Che altro dire? Be', comincio a essere stufo di dover scrivere tutti questi "A Rectangle"….

    @Test
     public void shouldRenderABuildingAsARectangle() throws Exception {
         User user = new User().lookAt(new HtmlScreen().addBuilding(10, 10, 50, 40).render());        
     assertThat(user.currentSight(), is(aRectangle(10, 10, 50, 40)));
     }
     @Test   public void shouldRenderMultipleBuildings() throws Exception {
         HtmlScreen htmlScreen = new HtmlScreen();
         htmlScreen.addBuilding(10, 10, 50, 40);
         htmlScreen.addBuilding(80, 10, 30, 40);
         htmlScreen.addBuilding(10, 70, 100, 40);
         User user = new User().lookAt(htmlScreen.render());
         assertThat(user.currentSight(), is(aRectangle(10, 10, 50, 40)));
         assertThat(user.currentSight(), is(aRectangle(80, 10, 30, 40)));
         assertThat(user.currentSight(), is(aRectangle(10, 70, 100, 40)));
     }
  
     private String aRectangle(int x, int y, int width, int height) {
         return "A Rectangle at [" + x + "," + y + "], " +
        height + "px high and " + width + "px wide";
     }

La classe User

Ecco la classe User e i suoi collaboratori così come è adesso. È un po' più evoluta rispetto all'originale: quando l'ho scritta per la prima volta, tutta la logica si trovava in User stessa, e non avevo alcun bisogno di valutare JavaScript al di fuori di html; successivamente, ho separato le due responsabilità (parsing XML/HTML e valutazione del JavaScript), visto che era necessario valutare il JavaScript indipendentemente da dove si trovasse.

public class User {
     private final JavaScript javaScript = new JavaScript();
     private final Result result = new Result();
     public User() {
         javaScript.evaluateFile("browser.js");
     }
     public User lookAt(String htmlPage) {
         JavaScriptSource source = new XomJavaScriptSource(htmlPage);      
     source.evaluateWith(javaScript);
         triggerOnLoad();
         result.readOutput(javaScript);
         return this;
     }
     public String currentSight() {
         return result.nextValue();
     }
     private void triggerOnLoad() {
         javaScript.evaluateScript("window.onload();", "onload");
     }
 }

Ed ecco la classe JavaScript che gestisce tutto quello che ha a che fare con Rhino.

public class JavaScript {
     private final Context context;
     private final ScriptableObject scope;
     public JavaScript() {           context = Context.enter();
         scope = context.initStandardObjects();
     }
     public Object valueOf(String variableName) {
         return scope.get(variableName);
     }
     public void evaluateScript(String script, String scriptName) {
         context.evaluateString(scope, script, scriptName, 1, null);
     }
     public void evaluateScript(String script) {
         evaluateScript(script, "script");
     }
     public void evaluateFile(String sourceFileName) {
         try {
             context.evaluateReader(scope, read(sourceFileName), sourceFileName, 1, null);
         } catch (IOException e) {
             throw new RuntimeException(e);
         }
     }
     private InputStreamReader read(String sourceFileName) {
         return new InputStreamReader(getClass()
       .getClassLoader().getResourceAsStream(sourceFileName));
     }
 }
Questa, invece, è Result, che estrae valori dagli array di risultati in JavaScript:
public class Result {
     private NativeArray output = new NativeArray(0);
     private int current = 0;
     public void readOutput(JavaScript javaScript) {
         output = (NativeArray) javaScript.valueOf("output");
     }
     public String nextValue() {
         return (String) output.get(current++);
     }
  }

Infine, questa è la classe che nasconde il fatto che gli script siano mescolati con l'HTML:

public class XomJavaScriptSource implements JavaScriptSource {
     private final Document document;
     public XomJavaScriptSource(String htmlPage) {
         document = parsePage(htmlPage);
     }
     @Override        public void evaluateWith(JavaScript javaScript) {
         Nodes scriptNodes = document.query("//script");
         for (int i = 0; i < scriptNodes.size(); i++) {
             evaluateNode(scriptNodes.get(i), javaScript);
         }
     }
     private final Document parsePage(String htmlPage) {
         try {
             return new Builder().build(htmlPage, null);
         } catch (Exception e) {
             throw new RuntimeException(e);
         }
     }
     private void evaluateNode(Node scriptNode, JavaScript javaScript) {
         if (scriptNode instanceof Element) {
             Attribute sourceAttribute
           = ((Element) scriptNode).getAttribute("src");
             if (sourceAttribute != null) {
             javaScript.evaluateFile(sourceAttribute.getValue());
             return;
             }
         }
         javaScript.evaluateScript(scriptNode.getValue());
     }
 }

Conclusioni

Con questi due articoli, abbiamo voluto fornire un esempio di TDD, applicandolo a un processo di scrittura di parte di una semplice applicazione. Quel che ci interessava mettere in luce è come il Test Driven Development sia una pratica agile che possa incarnare, nel concreto della programmazione della scrittura di codice, certi principi fondanti delle più ampie metodologie agili.

Attraverso la progressione che viene garantita dai successivi test, il codice assume, a poco a poco, maggiori funzionalità e maggior robustezza.

 

 

 

Condividi

Pubblicato nel numero
194 aprile 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