TDD in javascript con RequireJS

TDD in Javascript con RequireJS

Qualche tempo fa, scorrendo gli annunci di lavoro su LinkedIn, mi sono imbattuto in una descrizione molto simpatica (e significativa per l’argomento di questo post) di cosa era richiesto al candidato dalla società proponente. Tra le richieste più comuni (conoscenza delle tecnologie X e Y, approccio TDD, programmazione secondo i principi SOLID,….) spuntava in bella vista un punto che recitava testualmente: “you test your javascript“! (traduzione estratta dal contesto: era richiesto un uso abituale del testing anche nello sviluppo di “semplice” Javascript).

Quella semplice frase riassume in sé la consapevolezza che: benché Javascript si sia diffuso molto, così non si sono diffuse le pratiche normalmente molto più accettare per gli altri linguaggi (come appunto il testing). Questa considerazione di Javascript come “fratello minore” di altri linguaggi si deve probabilmente al fatto che questo linguaggio era inizialmente molto limitato e relegato quasi unicamente allo sviluppo dell’interfaccia utente.

Ad oggi, benché sia ancora la scelta favorita da molti sviluppatori per il codice di interfaccia, le sue possibilità di utilizzo sono in realtà molto superiori. Vedremo nelle prossime righe una libreria per la modularizzazione (RequireJS) e come utilizzarla al meglio al fine di poter “testare” anche le funzionalità più complesse.

Immaginiamo di avere una semplice struttura dato ad albero e una funzione di toString(), le cose possono diventare molto complesse anche in poche righe!

Nel codice che segue vengono mostrate alcune casistiche che, per esperienza, non sono lontane da quel che succede realmente in un progetto dove il codice viene sviluppato a più mani.

Sei in grado di prevedere cosa viene stampato nella pagina principale nei div log{1|2|3|4}“?

Tree.js

 
function createTree(val,a,b){
    var res = {};
    if (val) res.value = val;
    if (a) res.left=a;
    if (b) res.right=b;
    return res;
}

function toString(a){
    
    if (a &&( a.left ||  a.right))
      return " [" +toString(a.left)+a.value+toString(a.right)+"] ";
    
    if (a && a.value)
        return " ["+a.value+"] ";
    
    return " [] ";
};

Utils.js

 
function toString(a){
         return "{"+a+"}";
};

IndexNoRJ.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>NO Require.js demo</title>
</head>
<body>
    <script src="js/libs/Tree.js"></script>
    <script src="js/libs/Utils.js"></script>
</body>

<div id="log1"></div>

<div id="log2"></div>

<div id="log3"></div>
<div id="log4"></div>
    <script>  
    // Script #1
    var a = createTree("root",Tree("left"),Tree("right",Tree(10)));
    
    document.getElementById("log1").innerHTML = 
                                  "Example #1: toString():"+toString(a);
    
    </script>
    
    
    <script>
    // Script #2
    function toString(){
      return "This is not a toString()!";
    }
    document.getElementById("log2").innerHTML = 
                                  "Example #2: toString():"+toString(a);
    </script>
    
    
    <script src="js/libs/Tree.js"></script>
    <script>
    // Script #3
    a.toString = toString;
    document.getElementById("log3").innerHTML =
                                 "Example #3: toString():"+toString(a);
    </script>
    
    <script>
    // Script #4
    function toString(){ 
        return "<>";
    }
    document.getElementById("log4").innerHTML =
                                  "Example #4: toString():"+a.toString(a);
    </script>
    
    
</body>
</html>
 

Vediamo cosa accade quando eseguiamo il codice:


results

  • Example #1: toString(): {[object Object]}

Essendo la libreria Utils, caricata dopo la Tree, il metodo toString() invocato è quello di quest’ultima libreria.

  • Example #2: toString(): This is not a toString()!

Avendo definito una nuova toString() locale, quest’ultima viene eseguita.

  • Example #3: toString(): [ [left] root [ [10] right [] ] ]

Forzando il ri-caricamento della libreria originale abbiamo il comportamento corretto: toString() di Tree.

  • Example #4: toString(): [<>root<>]

Quest’ultimo esempio è un po’ estremo: nello script #3 ci siamo salvati la chiamata al toString() di Tree in un campo di a (a.toString = toString;) e quando questo metodo viene invocato, lui richiama la toString(), che pero’ non è se stessa, come si aspettava il programmatore che ha definito la funzione, ma prende in questo caso il valore nel contesto di invocazione e quindi la toString() che ritorna solo “<>”!

Questo semplice esempio mostra come la libertà (e la mancanza di namespaces) possono fare parecchi danni alla manutenibilità del codice!

A sua volta, tuttavia, l’uso di namespaces limita però il riutilizzo del codice e obbliga a una attenta organizzazione dell’ordine delle librerie al fine di posizionare le funzionalità nel namespace corretto. Inoltre il rischio di creare script con dipendenze cicliche è dietro l’angolo (è tanto più elevato quanto più si tenta di riutilizzare il codice).

RequireJS

RequireJS è un “module loader” e il suo scopo è quello di gestire i vari script che compongono un applicativo complesso, in modo da offrire sempre il contesto corretto per ogni funzionalità/libreria.

Vediamo come RequireJS ci può aiutare nell’esempio precedente:

TreeRJ.js

 "use strict ";

define(function() {
    var tree = function Tree(val,a,b){
          var value,left,right;
          value = val;
          left=a;
          right=b;


          this.toString = function(){
               var res = "[ ";
               if (left)
                      res+=left.toString();
               if (value)
                      res+=value.toString();
               if (right)
                      res+=right.toString();
               res+= "] ";

              return res;
        };
   }

  return tree;
});

UtilsRJ.js


"use strict"

define(function() {
     var utils = function Utils(){

              this.toString = function(a) {
                      return  " {"+a+"} ";
              };
      }

  return utils;
});

Le 2 librerie sono diventate degli oggetti con metodi e campi (ove necessario), di conseguenza per creare un oggetto Tree dovremo scrivere new Tree().

IndexRJ.html


 <!DOCTYPE html>
 <html lang="en">
 <head>
 <meta charset="utf-8">
 <title>With Require.js demo </title>
 </head>
 <body>
 <!-- La riga seguente serve a linkare la libreria e specificare il metodo da invocare appena questa è stata caricata-->
 <script data-main="js/RJMain" src="js/require.js"> </script>

 </body>
 <div id="log1"> </div>

 <div id="log2"> </div>

 <div id="log3"> </div>

 <div id="log4"> </div>
 </body>
 </html>

RJMain.js

Il nuovo codice, che per semplicità ho estratto in uno script esterno, mostra RequireJS in azione.



  "use strict";

require.config({
  paths: {
      'Tree': 'libs/TreeRJ',
      'Utils': 'libs/UtilsRJ'
  }
});

require(
      ['Tree'],
      function(Tree) {

      //let me print the tree!
      var a = new Tree("root", new Tree("left"), new Tree("right", new Tree(10)));

      document.getElementById("log1").innerHTML = 
                      "Example #1: toString():"+a.toString();

     }
);

require(
       ['Utils'],
       function(Utils) {
         var u = new Utils();
         var a = {}; //Tree is not defined!
         a.val = "test";

         document.getElementById("log2").innerHTML = 
                         "Example #2: toString():"+u.toString(a);
      }
);

require(
        ['Tree',  'Utils'],
        function(Tree,Utils) {

        //let me print the tree!
        var a = new Tree("root", new Tree("left"), new Tree("right", new Tree(10)));

        document.getElementById("log3").innerHTML =
                            "Example #3: toString():"+a.toString();

        var u = new Utils();
        document.getElementById("log4").innerHTML = 
                           "Example #4: toString():"+u.toString(a);
    }
);

La funzione require(), viene invocata più volte nel codice per solo scopo didattico: ciascun developer può scegliere le sue “dipendenze” all’inizio del proprio codice (parametro #1) ed esse verranno passate come parametri alla funzione (parametro #2)!

Le dipendenze trasversali, come jQuery, possono essere facilmente impostate come requisito del main (e salvate come riferimento nel contesto principale) in modo che qualsiasi script successivo veda $ senza bisogno di importarlo.

Usando inoltre una combinazione di configurazioni è possibile usare versioni diverse di librerie per script diversi ma attivi nella stessa pagina web!

Testing con RequireJS e QUnit

Il contenuto seguente è basato sul post: http://www.nathandavison.com/article/17/using-qunit-and-requirejs-to-build-modular-unit-tests

IndexTest.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>QUnit and Require.js demo</title>
<link rel="stylesheet" href="css/qunit.css">
</head>
<body>
    <div id="qunit"></div>
    <div id="qunit-fixture"></div>
    <script data-main="js/RJMainTest" src="js/require.js"></script>
</body>
</html>

RJMainTest.js

"use strict";

require.config({
    paths: {
        'Tree': 'libs/TreeRJ',
        'Utils': 'libs/UtilsRJ',
        'QUnit': 'libs/qunit'
    },
    shim: {
        'QUnit': {
            exports: 'QUnit',
            init: function() {
                QUnit.config.autoload = false;
                QUnit.config.autostart = false;
            }
        } 
     }
});

//require the unit tests.
require(
    ['QUnit', 'tests/TreeTest', 'tests/UtilsTest'],
    function(QUnit, treeTest, utilsTest) {
        
        // run the tests.
    	treeTest.run();
    	utilsTest.run();
        
        
        // start QUnit.
        QUnit.load();
        QUnit.start();
    }
);

E ora vediamo come è possibile scrivere i test unitari corrispondenti alle 2 librerie:

TreeTest.js

"use strict";

define(
    ['libs/TreeRJ'],
    function(Tree) {
    	var a = new Tree("root", new Tree("left"), new Tree("right", new Tree(10)));
    	
    	var empty= new Tree();
    	
        var run = function() {
        	test('a Tree should be created, and answer to the toString().', 
        			function() {
                equal(a.toString(), '[[left]root[[10]right]]', 'The return should be "[[left]root[[10]right]]".');
                equal(empty.toString(), '[]', 'The return should be "[]".');
            });
        };
        
        return {run: run}
    }
);

UtilsTest.js

"use strict";

define(
    ['libs/UtilsRJ'],
    function(Utils) {
        var u = new Utils();
        var run = function() {        	
            test('Utils should print any object inside curly braces!', 
            		function() {
                equal(u.toString("A"), "{A}", 'The return should be {A}.');
                equal(u.toString(25), "{25}", 'The return should be {25}.');
	        equal(u.toString(), "{}", 'The return should be {}.');

            });
        };
        
        return {run: run}
    }
);

Ed ecco finalmente il nostro risultato finale che ci evidenzia come la libreria Utils possa essere migliorata!


qunit

La scelta delle librerie e dei framework per lo sviluppo in Javascript è forse uno dei punti più critici da affrontare quando si inizia un nuovo progetto. Le scelte sono molteplici e le domande che spesso vengono fatte (per quanto riguarda i progetti Open Source) sono: quanto è matura questa libreria? quanto è mantenuta? quanto è documentata? ….

RequireJS non è il proiettile d’argento che risolve ogni problematica, ma, passato il gradino iniziale, ho apprezzato molto il fatto che “inviti caldamente” gli sviluppatori a scrivere codice più modulare e HTML-independent.

Infine, l’approccio modulare al codice è parte integrante della versione 6 di ECMAScript “Harmony” che è attualmente in sviluppo per tutti i maggiori browser. Quale che sia il motivo: uso della nuova feature del linguaggio oppure uso di RequireJS, è tempo di iniziare a pensare il vostro Javascript in modo modulare e orientato al test!

Davide Zambon

Mi sono laureato in Scienze Informatiche e da allora la programmazione è, per me, la ricerca del modo più elegante per esprimere un algoritmo. Lavoro come Team Leader e architetto in ambiente Java ma ho avuto esperienze in ambiti molto diversi della programmazione. Le mie strutture dati preferite sono gli alberi: red/black, B, B+, binary, di ricerca, decision tree.... Ho come passione il volo libero in parapendio.