No. 94

Titolo originale: Writing Testable JavaScript

Pubblicato in: JavaScript

Scritto da Rebecca Murphey

Ci siamo passati tutti: quel pezzo di codice JavaScript che era cominciato come una manciata di righe cresce fino ad una dozzina, poi a due dozzine e così via. Durante l'avanzamento, una funzione assume qualche altro argomento, ad un conditional vengono aggiunte altre condizioni. E poi un giorno, arriva il bug report: qualcosa non funziona e tocca a noi sbrogliare la matassa.

Chiedendo al nostro codice client side di assumersi sempre maggiori responsabilità - seriamente, al giorno d'oggi intere applicazioni vivono per lo più nel browser - due cose appaiono chiare: una, non possiamo solo cliccare per tutta l'applicazione per testare che le cose funzionino come ci aspettiamo, i test automatici sono cruciali per avere fiducia nel nostro codice; due, probabilmente dovremo cambiare il modo in cui scriviamo il nostro codice per rendere possibile la stesura di test.

Abbiamo davvero bisogno di cambiare il modo in cui programmiamo? Sì, perché anche se sappiamo che i test automatici sono una buona cosa, la maggior parte di noi probabilmente è solo in grado di scrivere degli "integration test". Questi ultimi sono preziosi perché si focalizzano sul modo in cui i pezzi di un'applicazione funzionano insieme, ma quello che non fanno è dirci se una singola unità di funzionalità si sta comportando come dovrebbe.

Qui è dove entrano in gioco gli "unit test" e faremo davvero fatica a scrivere degli unit test finché non cominceremo a scrivere JavaScript testabile.

Unit vs. integration: qual è la differenza?

Scrivere degli integration test solitamente è piuttosto facile: scriviamo semplicemente del codice che descrive il modo in cui un utente interagisce con la nostra app e cosa dovrebbe aspettarsi di vedere l'utente durante tale interazione. Selenium è un tool popolare per automatizzare i browser. Capybara per Ruby rende semplice parlare a Selenium e ci sono molti altri tool anche per altri linguaggi.

Ecco un integration test per una porzione di una app di ricerca:

def test_search
  fill_in('q', :with => 'cat')
  find('.btn').click
  assert( find('#results li').has_content?('cat'), 'Search results are shown' )
  assert( page.has_no_selector?('#results li.no-results'), 'No results is not shown' )
end

Laddove un integration test è interessato all'interazione dell'utente con una app, uno unit test è strettamente interessato a una piccola porzione del codice:

Quando richiamo una funzione con un certo input, ricevo l'output atteso?

Le app scritte nel tradizionale stile procedurale possono essere molto difficili da testare con lo unit test - e anche difficili da mantenere, debuggare ed estendere. Ma se scriviamo il codice con in mente le necessità del nostro futuro unit test, non solo troveremo che scrivere i test diventa più semplice di quello che ci aspetteremmo, ma anche che chiaramente scriveremo del codice migliore.

Per capire di cosa sto parlando, diamo un'occhiata a una semplice app di ricerca:

Srchr

Quando un utente inserisce un termine di ricerca, la app manda una XHR al server per i dati corrispondenti. Quando il server risponde con i dati, formattati come JSON, l'app prende quei dati e li mostra sulla pagina, usando un templating client side. Un utente può cliccare su un risultato di ricerca per indicare che gli piace (un "like"): quando questo avviene, il nome della persona a cui ha dato il "like" viene aggiunta alla lista dei "Liked" sulla destra.

Un'implementazione JavaScript "tradizionale" di questa app potrebbe essere la seguente:

var tmplCache = {};

function loadTemplate (name) {
  if (!tmplCache[name]) {
    tmplCache[name] = $.get('/templates/' + name);
  }
  return tmplCache[name];
}

$(function () {

  var resultsList = $('#results');
  var liked = $('#liked');
  var pending = false;

  $('#searchForm').on('submit', function (e) {
    e.preventDefault();

    if (pending) { return; }

    var form = $(this);
    var query = $.trim( form.find('input[name="q"]').val() );

    if (!query) { return; }

    pending = true;

    $.ajax('/data/search.json', {
      data : { q: query },
      dataType : 'json',
      success : function (data) {
        loadTemplate('people-detailed.tmpl').then(function (t) {
          var tmpl = _.template(t);
          resultsList.html( tmpl({ people : data.results }) );
          pending = false;
        });
      }
    });

    $('<li>', {
      'class' : 'pending',
      html : 'Searching &hellip;'
    }).appendTo( resultsList.empty() );
  });

  resultsList.on('click', '.like', function (e) {
    e.preventDefault();
    var name = $(this).closest('li').find('h2').text();
    liked.find('.no-results').remove();
    $('<li>', { text: name }).appendTo(liked);
  });

});

Il mio amico Adam Sontag la chiama codice Choose Your Own Adventure ("scegli la tua avventura"): su ogni riga potremmo aver a che fare con la presentazione, con i dati, con l'interazione utente o con lo stato dell'applicazione. Chi può dirlo? È abbastanza facile scrivere dei test degli integration test per questo tipo di codice, ma è difficile testare le singole unità di funzionalità.

Cosa lo rende difficile? Quattro cose:

  • Una generale mancanza di struttura: quasi tutto avviene in una callback $(document).ready() e poi in funzioni anonime che non possono essere testate perché non sono esposte.
  • Funzioni complesse: se una funzione è di più di 10 righe, come l'handler del submit, è molto probabile che faccia troppo.
  • Stati nascosti o condivisi: per esempio, dal momento che pending è in una closure, non c'è modo di testare che lo stato "pending" sia impostato correttamente.
  • "Tight coupling": per esempio, un handler per il successo $.ajax non dovrebbe avere accesso diretto al DOM.

Organizzare il codice

Il primo step verso la risoluzione di tutto questo è avere un approccio meno aggrovigliato al nostro codice, scomponendolo in poche differenti aree di responsabilità:

  • Presentazione ed interazione
  • Data management e persistenza
  • Stato generale dell'applicazione
  • Codice di setup e codice per far sì che i pezzi funzionino assieme

Nell'implementazione "tradizionale" mostrata sopra, queste quattro categorie sono mischiate: su una riga gestiamo la presentazione e due righe dopo magari stiamo comunicando con il server.

Code Lines

Mentre possiamo assolutamente scrivere degli integration test per questo codice - e dovremmo! - scrivere degli unit test per questo codice è piuttosto difficile. Nei nostri test funzionali, possiamo fare affermazioni come "quando un utente cerca qualcosa, dovrebbe vedere i risultati appropriati", ma non possiamo essere molto più specifici. Se qualcosa va storto, dovremo trovare esattamente dove c'è stato l'errore e i nostri test funzionali non ci potranno essere di grande aiuto.

Tuttavia, se ripensiamo il modo in cui scriviamo il nostro codice, possiamo scrivere degli unit test che ci daranno degli insight migliori riguardo a quello che è andato storto e anche aiutarci ad avere del codice che sia più semplice da usare, mantenere ed estendere.

Il nostro nuovo codice seguirà alcuni principi guida:

  • Rappresentare ciascun pezzo distinto di comportamento come un oggetto separato che ricada in una delle quattro aree di responsabilità e non abbia bisogno di sapere nulla degli altri oggetti. Questo ci aiuterà ad evitare di creare del codice ingarbugliato.
  • Supportare la configurabilità piuttosto che le cose "hard coded". Questo ci impedirà di replicare tutto il nostro ambiente HTML per poter scrivere i nostri test.
  • Mantenere i metodi degli oggetti semplici e brevi. Questo ci aiuterà ad avere test semplici e codice facilmente leggibile.
  • Usare funzioni "constructor" per creare istanze di oggetti. Questo renderà possibile la creazione di copie "pulite" di ciascun pezzo di codice per il bene del testing.

Per cominciare, dobbiamo comprendere come suddividere la nostra applicazione in pezzi differenti. Avremo tre pezzi dedicati alla presentazione e all'interazione: la Form di Ricerca (Search Form), i Risultati della Ricerca (Search Results) e la Likes Box.

Application Views

Avremo anche un pezzo dedicato alla raccolta dati dal server e un pezzo dedicato a tenere tutto unito insieme.

Cominciamo con il guardare uno dei più semplici pezzi della nostra applicazione: la Likes Box. Nella versione originale dell'app, questo codice era responsabile dell'aggiornamento della Likes Box:

var liked = $('#liked');

var resultsList = $('#results');


// ...


resultsList.on('click', '.like', function (e) {
  e.preventDefault();

  var name = $(this).closest('li').find('h2').text();

  liked.find( '.no-results' ).remove();

  $('<li>', { text: name }).appendTo(liked);

});

Il pezzo dei Risultati della Ricerca (Search Results) è completamente intrecciato con il pezzo della Likes Box e ha bisogno di conoscere molto del suo markup. Un approccio molto migliore e più testabile consiste nel creare un oggetto Likes Box che è responsabile della manipolazione del DOM in relazione alla Likes Box:

var Likes = function (el) {
  this.el = $(el);
  return this;
};

Likes.prototype.add = function (name) {
  this.el.find('.no-results').remove();
  $('<li>', { text: name }).appendTo(this.el);
};

Questo codice fornisce una funzione "constructor" che crea una nuova istanza di una Likes Box. L'istanza che viene creata ha un metodo .add(), che possiamo usare per aggiungere nuovi risultati. Possiamo scrivere un paio di test per provare che funzioni:

var ul;

setup(function(){
  ul = $('<ul><li class="no-results"></li></ul>');
});

test('constructor', function () {
  var l = new Likes(ul);
  assert(l);
});

test('adding a name', function () {
  var l = new Likes(ul);
  l.add('Brendan Eich');

  assert.equal(ul.find('li').length, 1);
  assert.equal(ul.find('li').first().html(), 'Brendan Eich');
  assert.equal(ul.find('li.no-results').length, 0);
});

Non troppo complicato, vero? In questo caso, utilizziamo Mocha come framework per i test e Chai come assertion library. Mocha fornisce le funzioni test e setup, Chai fornisce assert. Ci sono molti altri framework di test e assertion library tra cui scegliere, ma al fine di avere un'introduzione, trovo che queste due funzionino bene. Dovreste trovarne una che funzioni meglio per voi e per il vostro progetto. Oltre a Mocha, QUnit è popolare e Intern è un nuovo framework molto promettente.

Il nostro codice per il test comincia con la creazione di un elemento che useremo come container per la nostra Likes Box. Poi, fa due test: uno è un "sanity check" per essere sicuri di poter fare una Likes Box; l'altro è un test per assicurarsi che il nostro metodo .add() ha l'effetto desiderato. Mettendo in atto questi test, possiamo rifattorizzare in maniera sicura il codice per la nostra Likes Box ed essere sicuri che sapremo se danneggiamo qualcosa.

Il codice della nostra nuova applicazione ha ora questo aspetto:

var liked = new Likes('#liked');
var resultsList = $('#results');



// ...



resultsList.on('click', '.like', function (e) {
  e.preventDefault();

  var name = $(this).closest('li').find('h2').text();

  liked.add(name);
});

Il pezzo dei Risultati della Ricerca (Search Results) è più complesso della Likes Box, ma facciamo comunque un tentativo di refactoring. Proprio come abbiamo creato un metodo .add() sulla Likes Box, vogliamo anche creare dei metodi per interagire con i Risultati della Ricerca. Vorremmo un modo per aggiungere nuovi risultati così come un modo per fare il “broadcast” al resto dell'app quando succedono alcune cose all'interno dei Risultati di Ricerca, per esempio, quando qualcuno mette un like a un risultato.

var SearchResults = function (el) {
  this.el = $(el);
  this.el.on( 'click', '.btn.like', _.bind(this._handleClick, this) );
};

SearchResults.prototype.setResults = function (results) {
  var templateRequest = $.get('people-detailed.tmpl');
  templateRequest.then( _.bind(this._populate, this, results) );
};

SearchResults.prototype._handleClick = function (evt) {
  var name = $(evt.target).closest('li.result').attr('data-name');
  $(document).trigger('like', [ name ]);
};

SearchResults.prototype._populate = function (results, tmpl) {
  var html = _.template(tmpl, { people: results });
  this.el.html(html);
};

Ora, il codice della nostra vecchia app per gestire l'interazione tra Search Results e Likes Box potrebbe essere questo:

var liked = new Likes('#liked');
var resultsList = new SearchResults('#results');


// ...


$(document).on('like', function (evt, name) {
  liked.add(name);
})

È molto più semplice e meno intricato perché stiamo usando document come un message bus globale e passando i messaggi attraverso di esso, così che i componenti individuali non hanno bisogno di sapere gli uni degli altri. (Notate che in una app reale, useremmo qualcosa come Backbone o la libreria RSVP per gestire gli eventi. Qui stiamo facendo il triggering su document per mantenere semplici le cose). Stiamo anche nascondendo tutto il lavoro sporco, come trovare il nome della persona a cui è stato dato il like, all'interno dell'oggetto Search Results, piuttosto che sporcare il codice della nostra applicazione. La parte migliore è che adesso possiamo scrivere dei test per provare che il nostro oggetto Search Results funzioni come ci aspettiamo:

var ul;
var data = [ /* fake data here */ ];

setup(function () {
  ul = $('<ul><li class="no-results"></li></ul>');
});

test('constructor', function () {
  var sr = new SearchResults(ul);
  assert(sr);
});

test('display received results', function () {
  var sr = new SearchResults(ul);
  sr.setResults(data);

  assert.equal(ul.find('.no-results').length, 0);
  assert.equal(ul.find('li.result').length, data.length);
  assert.equal(
    ul.find('li.result').first().attr('data-name'),
    data[0].name
  );
});

test('announce likes', function() {
  var sr = new SearchResults(ul);
  var flag;
  var spy = function () {
    flag = [].slice.call(arguments);
  };

  sr.setResults(data);
  $(document).on('like', spy);

  ul.find('li').first().find('.like.btn').click();

  assert(flag, 'event handler called');
  assert.equal(flag[1], data[0].name, 'event handler receives data' );
});

L'interazione con il server è un altro pezzo interessante da considerare. Il codice originale includeva una richiesta diretta $.ajax() e la callback interagiva direttamente con il DOM:

$.ajax('/data/search.json', {
  data : { q: query },
  dataType : 'json',
  success : function( data ) {
    loadTemplate('people-detailed.tmpl').then(function(t) {
      var tmpl = _.template( t );
      resultsList.html( tmpl({ people : data.results }) );
      pending = false;
    });
  }
});

Di nuovo, è difficile scrivere uno unit test per questo, perché succedono così tante cose in poche righe di codice. Possiamo ristrutturare la porzione di dati della nostra applicazione come un oggetto a sé stante:

var SearchData = function () { };

SearchData.prototype.fetch = function (query) {
  var dfd;

  if (!query) {
    dfd = $.Deferred();
    dfd.resolve([]);
    return dfd.promise();
  }

  return $.ajax( '/data/search.json', {
    data : { q: query },
    dataType : 'json'
  }).pipe(function( resp ) {
    return resp.results;
  });
};

Ora, possiamo cambiare il nostro codice per ottenere i risultati nella pagina:

var resultsList = new SearchResults('#results');

var searchData = new SearchData();

// ...

searchData.fetch(query).then(resultsList.setResults);

Di nuovo, abbiamo incredibilmente semplificato il codice della nostra applicazione ed isolato la complessità all'interno dell'oggetto Search Data, piuttosto che averlo "live" nel codice principale dell'applicazione. Abbiamo anche reso testabile l'interfaccia di ricerca, sebbene ci siano alcune precisazioni da tener presente quando si testa codice che interagisce con il server.

La prima è che non vogliamo davvero interagire con il server: per farlo dovremmo rientrare nel mondo degli integration test e dal momento che siamo sviluppatori responsabili, abbiamo già dei test che assicurano che il server faccia la cosa giusta, vera? Al contrario, vogliamo “simulare” l'interazione con il server, cosa che possiamo fare usando la libreria Sinon. La seconda avvertenza: dovremmo testare anche i percorsi non ideali, come una empty query.

test('constructor', function () {
  var sd = new SearchData();
  assert(sd);
});

suite('fetch', function () {
  var xhr, requests;

  setup(function () {
    requests = [];
    xhr = sinon.useFakeXMLHttpRequest();
    xhr.onCreate = function (req) {
      requests.push(req);
    };
  });

  teardown(function () {
    xhr.restore();
  });

  test('fetches from correct URL', function () {
    var sd = new SearchData();
    sd.fetch('cat');

    assert.equal(requests[0].url, '/data/search.json?q=cat');
  });

  test('returns a promise', function () {
    var sd = new SearchData();
    var req = sd.fetch('cat');

    assert.isFunction(req.then);
  });

  test('no request if no query', function () {
    var sd = new SearchData();
    var req = sd.fetch();
    assert.equal(requests.length, 0);
  });

  test('return a promise even if no query', function () {
    var sd = new SearchData();
    var req = sd.fetch();

    assert.isFunction( req.then );
  });

  test('no query promise resolves with empty array', function () {
    var sd = new SearchData();
    var req = sd.fetch();
    var spy = sinon.spy();

    req.then(spy);

    assert.deepEqual(spy.args[0][0], []);
  });

  test('returns contents of results property of the response', function () {
    var sd = new SearchData();
    var req = sd.fetch('cat');
    var spy = sinon.spy();

    requests[0].respond(
      200, { 'Content-type': 'text/json' },
      JSON.stringify({ results: [ 1, 2, 3 ] })
    );

    req.then(spy);

    assert.deepEqual(spy.args[0][0], [ 1, 2, 3 ]);
  });
});

Per brevità, ho tralasciato il refactoring della Search Form e ho anche semplificato alcuni degli altri refactoring e test, ma potete vedere qui una versione finita dell'app se siete interessati.

Quando avremo finito di riscrivere la nostra applicazione usando dei pattern di JavaScript testabili, ci ritroveremo con qualcosa di molto più pulito di quello con cui avevamo cominciato:

$(function() {
  var pending = false;

  var searchForm = new SearchForm('#searchForm');
  var searchResults = new SearchResults('#results');
  var likes = new Likes('#liked');
  var searchData = new SearchData();

  $(document).on('search', function (event, query) {
    if (pending) { return; }

    pending = true;

    searchData.fetch(query).then(function (results) {
      searchResults.setResults(results);
      pending = false;
    });

    searchResults.pending();
  });

  $(document).on('like', function (evt, name) {
    likes.add(name);
  });
});

Tuttavia, ancora più importante che il codice molto più pulito della nostra applicazione è il fatto che ci ritroviamo una code base che è attentamente testata. Questo significa che possiamo fare tranquillamente il refactor su di essa ed aggiungere altro senza la paura di romperne il funzionamento. Possiamo addirittura scrivere nuovi test man mano che troviamo nuove questioni e poi scrivere il codice che passa quei test.

Alla lunga, testare rende la vita più facile

È facile guardare tutto questo e dire: &8220;Aspetta, vuoi che scriva più codice per fare lo stesso lavoro?”

Il fatto è che ci sono alcuni fatti inevitabili nella vita riguardanti Il Fare Cose Su Internet. Passerete del tempo a progettare un approccio per un problema, testerete la vostra soluzione, sia che lo farete cliccando in giro in un browser o scrivendo dei test automatizzati o - tremate - lasciando che i vostri utenti testino per voi in produzione. Farete dei cambiamenti al codice e altre persone useranno il vostro codice. Infine, ci saranno dei bug, non importa quanti test scriverete.

La cosa del testing è che sebbene richieda un po' più di tempo all'inizio, vi fa davvero risparmiare del tempo a lungo termine. Vi darete delle pacche sulla spalla la prima volta che un test che avete scritto intercetterà un bug prima che il codice vada in produzione. Sarete grati, inoltre, quando avrete un sistema che possa provare che il vostro bug fix sitema davvero un bug che si intrufola nel codice.

Altre risorse

Questo articolo tocca solo l'apice dell'argomento dei test JavaScript, ma se volete saperne di più, guardate qua:

  • la mia presentazione alla conferenza del 2012 Full Frontal, a Brighton, UK.
  • Grunt, un tool che aiuta nell'automazione del processo di testing e molte altre cose.
  • Test-Driven JavaScript Development di Christian Johansen, il creatore della libreria Sinon. È un esame complesso ma informativo della pratica del testing JavaScript.

Illustrazioni: Carlo Brigatti

Share/Save/Bookmark
 

Discutiamone

Ti sembra interessante? Scrivi tu il primo commento


Cenni sull'autore

Rebecca Murphey

Rebecca Murphey è senior software engineer in Bazaarvoice e un speaker abituale su argomenti che riguardano l'organizzazione del codice e le relative best practice in varie conferenze in giro per il mondo. È anche la creatrice della conferenza TXJS, l'autrice del sito di apprendimento jQuery Fundamentals, una contributor del jQuery Cookbook di O’Reilly Media e una technical reviewer di Effective JavaScript di Dave Herman. Ha un blog su rmurphey.com, twitta come @rmurphey e vive a Durham, NC, con il suo partner.