Rebecca Murphey: jak psát testovatelný JavaScript

11. července 2013

Nečekaný výskyt chyby? Houstone, máme problém… ovšem pouze za předpokladu, pokud JavaScript, který jste napsali, není snadno testovatelný.

Všichni jsme to zažili – malá funkce JavaScriptu na několik řádků se rozroste, najednou máme tucet řádků a vzápětí jich jsou dva tucty. Na funkci se postupně nabalují argumenty, podmíněné příkazy se rozrostou o pár dalších podmínek a jednoho dne se objeví chybové hlášení. Něco se porouchalo a je na nás, abychom se s tím nepořádkem vypořádali.

Spolu s čím dál větším přesunem odpovědnosti na stranu klienta (vždyť v současnosti si přece celá aplikace žije svůj vlastní život převážně v prohlížečích) jsou zřejmější dvě věci. Za prvé se nemůžeme jen tak rozhodnout, co otestovat, abychom zjistili, že vše funguje jak má – klíčovým pomocníkem k ověření správnosti kódu jsou automatizované testy. Za druhé pokud budeme chtít do skriptu zahrnout testy, budeme pravděpodobně muset změnit způsob psaní kódu.

Skutečně musíme změnit způsob kódování? Ano, musíme. Přestože víme, že jsou automatizované testy výbornou pomůckou, pravděpodobně v této chvíli píšeme pouze integrační testy. Jsou to důležité testy, ověřují fungování všech částí aplikace jako jednoho celku, avšak již se nedozvíme, zda jednotlivé části fungují tak, jak očekáváme.

Tady nastává ten správný čas pro unit testy. A dokud nezačneme psát testovatelný JavaScript, budeme mít potíže s psaním unit testů.

Unit testy versus integrační testy – v čem je rozdíl?

Psaní integračních testů není nijak komplikované, jednoduše napíšeme kód, který popisuje interakci mezi uživatelem a aplikací a to, co by měl uživatel očekávat, že při této interakci uvidí. Oblíbeným nástrojem pro automatizování prohlížeče je Selenium a komunikaci s ním usnadňuje Capybara (pro Ruby); nepřeberné množství nástrojů nalezneme i pro jiné programovací jazyky.

Zde je integrační test pro část vyhledávací aplikace:

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

Zatímco integrační test ověřuje interakci uživatele s aplikací, unit test se soustředí pouze na malou část kódu:

Pokud zadáme funkci s určitým vstupem, obdržíme očekávaný výstup?

Může být velice komplikované testovat pomocí unit testů aplikace napsané tradičním způsobem. Tyto aplikace se ostatně těžko udržují, zbavují chyb a rozšiřují. Pokud již při psaní kódu počítáme s jeho testováním pomocí unit testů, zjistíme, že není psaní testů tak komplikované, jak bychom možná čekali, a že současně píšeme i vhodnější kód.

Celou situaci lépe ozřejmí náhled do jednoduché vyhledávací aplikace.

Uživatel zadá hledávaný výraz a aplikace vyžádá od serveru příslušná data na základě odeslaného XHR požadavku; server data odešle ve formátu JSON, aplikace data převezme a za použití formátování na straně klienta je zobrazí na webové stránce. Uživatel může kliknout na zobrazené výsledky a dát tak najevo, že se mu daný výsledek líbí. Jakmile tak učiní, jméno příslušné osoby se přidá do seznamu oblíbených položek (Liked) v pravé části obrazovky.

Tradiční JavaScriptové provedení této aplikace by mohlo vypadat takto:

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;
        });
      }
    });

    $('
  • ‚, {
    ‚class‘ : ‚pending‘,
    html : ‚Searching …‘
    }).appendTo( resultsList.empty() );
    });resultsList.on(‚click‘, ‚.like‘, function (e) {
    e.preventDefault();
    var name = $(this).closest(‚li‘).find(‚h2‘).text();
    liked.find(‚.no-results‘).remove();
    $(‚
  • ‚, { text: name }).appendTo(liked);
    });});

Můj přítel Adam Sontag to nazývá kódem na vlastní nebezpečí – na každém řádku se můžeme potýkat s formátováním vzhledu, s daty, s interakcí uživatele nebo se stavem aplikace. Kdo ví? Pro tyto kódy není problém napsat integrační testy, ale je těžké otestovat jednotlivé funkční celky.

Proč je to tak těžké? Ze čtyř důvodů:

  • Nedostatečná vnitřní struktura – téměř vše se odehrává ve zpětném dotazu $(document).ready() a následně v anonymních funkcích, jež nemohou být testovány, jelikož nejsou odděleny.
  • Složité funkce – pokud má funkce vice jak deset řádků, jako například výše uvedený skript, dají se očekávat komplikace.
  • Skryté či sdílené stavy – například jelikož je pending uzavřen, není možné otestovat, zda je stav pending nastaven správně.
  • Úzké vázání – například handler $.ajax success nepotřebuje přímý přístup do DOM.

Uspořádání kódu

Prvním krokem ke zlepšení situace je přímočařejší přístup ke kódu a rozdělení kódu na několik různých oblastí na základě zpracovávaných úkolů:

  • Vzhled a interakce
  • Správa dat a stálost
  • Celkový stav aplikace
  • Nastavení a propojovací kód, díky kterým jednotlivé části programu spolupracují

Ve výše uvedeném tradičním provedení jsou tyto kategorie smíchané, na jednom řádku se potýkáme se vzhledem a o dva řádky níže můžeme komunikovat se serverem.

Pro tento kód rozhodně můžeme (a měli bychom!) psát integrační testy, unit testy jsou v tomto případě komplikovanou záležitostí. U funkčních testů můžeme použít tvrzení, pokud uživatel něco hledá, má obdržet příslušný výsledek, ale stěží můžeme být konkrétnější. Pokud něco nefunguje jak má, musíme vyhledat místo, na kterém došlo k chybě, a s tím nám funkční testy příliš nepomohou.

Pokud budeme přemýšlet jak kód přepsat, můžeme napsat unit testy, které nám poskytnou větší vhled do míst, na kterých došlo k chybám, a pomohou vytvořit kód, který lze snadno opět používat, udržovat a rozšiřovat.

Nový kód bude dodržovat několik zásad:

  • Jednotlivé odlišující se části bude prezentovat jako samostatné celky spadající do jedné ze čtyř výše uvedených oblastí; tyto celky o sobě navzájem nemusejí vědět. Takto předejdete vytváření složitých kódů.
  • Bude podporovat konfigurovatelnost oproti komplikovanému kódování; takto se vyhnete opakování celého HTML prostředí jen kvůli napsání testů.
  • Udrží jednoduché a strohé cílové metody, což vám pomůže zajistit srozumitelné testy a snadno čitelné kódy.
  • K vytvoření požadovaných objektů použije konstrukční funkce, čímž umožní vytváření čistých kopií jednotlivých částí kódu pro případné testování.

Než začneme, musíme si rozmyslet, jakým způsobem rozdělíme aplikaci na tři různé části, které budou souviset se vzhledem a s interaktivitou – na Vyhledávací formulář, Výsledek hledání a na Like Box.

Také budeme mít část věnovanou přenosu dat ze serveru a část, která vše spojí dohromady.

Začneme s nejjednodušší částí aplikace, se seznamem oblíbených položek Like Box. V původní verzi aplikace se o aktualizaci Like Boxu staral tento kód:

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();

  $('
  • ‚, { text: name }).appendTo(liked);});

Výsledky hledání jsou zcela propojeny s Like Boxem a musí toho hodně vědět o jeho HTML elementech. Mnohem lepší a testovatelnější přístup povede k vytvoření Like Box objektu zodpovědného za manipulaci s DOM, které se vztahuje k Like Boxu:

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

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

Tento kód poskytuje konstrukční funkci, které vytvářejí nové instance Likes Box. Instance jsou vytvořené metodou .add(), jež můžete použít pro přidání nových výsledků. Funkčnost můžete ověřit několika testy:

var ul;

setup(function(){
  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);
});

Není to tak těžké, co říkáte? Jako testovací framework zde používáme Mocha, a Chai jako assertion library; Mocha zajišťuje funkce test a setup zatímco Chai poskytuje assert. Existuje mnoho dalších testovacích frameworků a assertion knihoven, ze kterých si můžeme vybírat, ale tyto dvě jsou pro úvodní seznámení dostačující. Vybereme si tu, která se nejvíce hodí k našemu projektu – vedle Mocha je oblíbená QUnit a velmi slibný je i nový framework Intern.

Tento testovací kód začíná vytvářením elementu, který použijeme jako kontejner pro Like Box. Následují dva testy: jeden je logická kontrola, zda můžeme vytvořit Like Box, a druhý test zjišťuje, zda metoda .add() dosahuje kýženého efektu. Těmito testy můžeme bezpečně přepsat kód Like Boxu a můžeme si být jisti, že poznáme, pokud jsme někde udělali chybu.

Nový kód aplikace by nyní mohl vypadat takto:

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);
});

Výsledky hledání je složitější než Like Box, přesto se ho také pokusíme přepsat. Tak jako jsme vytvořili metodu .add() v Like Box, vytvoříme metody, které umožní interakci s Výsledkem hledání. Potřebujeme objevit způsob přidávání nových výsledků a způsob, kterým sdělíme ostatním aplikacím události odehrávající se ve Výsledku hledání, tedy například to, že se někomu líbil výsledek.

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);
};

Původní kód aplikace pro řízení interakce mezi výsledkem hledání a Like Boxem může vypadat takto:

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

// ...

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

Je to srozumitelnější a méně komplikované, jelikož jako souhrnnou sběrnici dat používáme document a skrze něj procházející vzkazy, jednotlivé části tedy o sobě vzájemně nemusejí vědět. (Ve skutečné aplikaci bychom k řízení událostí použili něco podobného jako je Backbone, popřípadě knihovnu RSVP – zde se kvůli jednoduchosti soustřeďujeme na document.) Nevhodné informace, například nalezení jména osob, kterým se líbil výsledek, v objektu Výsledek hledání skryjeme – nenecháme si jimi zaneřádit kód aplikace. A teď to nejlepší: nyní můžeme napsat test, který potvrdí, že Výsledek hledání funguje tak, jak jsme očekávali:

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

setup(function () {
  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' );
});

Další zajímavou částí kódu je interakce se serverem. Původní kód obsahoval přímý příkaz $.ajax() a zpětný dotaz spolupracoval přímo s 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;
    });
  }
});

Na pouhých pár řádcích kódu se odehrává mnoho různých věcí a je tedy těžké napsat pro tento kód unit test. Část dat aplikace můžeme restrukturalizovat a vytvořit samostatný objekt:

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;
  });
};

Nyní můžeme změnit kód a na stránce se objeví výsledky:

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

var searchData = new SearchData();

// ...

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

Opět jsme dramaticky zjednodušili kód aplikace a izolovali spletitosti v objektu Data hledání. Je to mnohem lepší, než je ponechat v základním kódu aplikace. Takto vytvořené vyhledávací rozhraní také můžeme testovat, ale při testování kódů komunikujících se serverem nesmíme zapomenout na tato upozornění:

Za prvé ve skutečnosti nechceme, aby docházelo ke vzájemnému ovlivňování se se serverem, nechceme totiž znovu vstoupit do světa integračních testů – jsme přece zodpovědní vývojáři a používáme testy, které nám pomohou zjistit, zda server dělá to, co má. Místo toho chceme simulovat ty interakce se serverem, které lze provést pomocí knihovny Sinon.

Za druhé bychom měli otestovat i ne právě ideální cesty, jakými jsou například prázdné dotazy.

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 ]);
  });
});

Dali jsme přednost stručnosti – vynechali jsme refactoring Vyhledávacího formuláře a zjednodušili některé další refractoringy a testy. Finální verze aplikace je k nahlédnutí zde.

Po přepsání aplikace za použití testovatelných JavaScriptových modelů získáme kód, který je jasnější a obsahuje méně chyb než kód, se kterým jsme začínali:

$(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);
  });
});

Mnohem důležitější než čistší kód aplikace je skutečnost, že na konci získáme základní kód, který je důkladně otestován, což znamená, že ho můžeme bezpečně refraktorovat a rozšiřovat, aniž bychom se museli bát, že něco pokazíme. A dokonce pokud narazíme na nový problém, můžeme napsat nové testy a následně napsat kód, který tyto testy umožní.

Provádění testů nám z dlouhodobého hlediska usnadní život

Máte po tom všem chuť říct: “Počkejte, to chcete, abych napsal víc kódů, které budou dělat to stejné?”
To máte tak, při vytváření internetových stránek musíme respektovat několik nevyhnutelných faktů: určitý čas strávíme navrhováním přístupu k problému a své řešení můžeme otestovat buď klikáním v prohlížeči, napsáním automatizované testovací sady anebo můžeme nechat uživatele testovat aplikaci v ostrém produkčním prostředí. Následně provedeme změny v kódu a dáme ho k dispozici dalším lidem. A poznámka na závěr: bez ohledu na to, kolik testů napíšeme, vždy se v kódu vyskytnou chyby.

Psaní testů sice může zpočátku zabrat více času, ale z dlouhodobého hlediska nám testy čas skutečně ušetří. Sami sebe poplácáme po zádech jakmile odchytíme chybu ještě předtím, než se dostane do procesu výroby, a budeme rádi, že máme systém, který může dokázat, že skript na opravu chyb skutečně opravuje chyby, která nám unikly.

Další zdroje

Tyto zdroje se testováním v JavaScriptu zabývají jen okrajově, ale pokud se chcete dozvědět něco dalšího, koukněte na ně:

  • Moje prezentace z konference Full Frontal 2012, Brighton, UK.
  • Grunt, nástroj, který pomáhá automatizovat process testování a mnohem více.
  • Test-Driven JavaScript Development od Christiana Johansena, autora knihovny Sinon. Je to kniha plná informací a poučná pokud jde otestování JavaScriptu.

Rebecca Murphey

Rebecca Murphey pracuje jako vedoucí softwarová inženýrka Bazaarvoice a na konferencích po celém světě přednáší o uspořádání kódů a o nejlepších příkladech z praxe. Založila TXJS konferenci, je autorkou vzdělávacího webu jQuery Fundamentals, přispívá do jQuery Cookbook od O’Reilly Media a provádí odborné recenze pro Effective JavaScript od Davea Hermana. Bloguje na rmurphey.com, tweetuje na @rmurphey a s partnerem žije v Durhamu v Severní Karolíně, USA.

Informace o překladu

  • Původní článek: Writing Testable JavaScript (A List Apart).
  • Překlad: Miluše Pokorná.
  • Odborná a jazyková spolupráce: Miroslav Kučera.

Přeloženo se svolením magazínu A List Apart. Zde naleznete další překlady.

About translation

  • Original article: Writing Testable JavaScript (A List Apart).
  • Translation: Miluše Pokorná.
  • Language and expert collaboration: Miroslav Kučera.

Language of translation: Czech (for readers from Czech and Slovak republics). Translated with the permission of A List Apart. Other translations.

Mohlo by vás také zajímat

Nejnovější

2 komentářů

  1. Alerin

    Srp 8, 2013 v 19:01

    Za překlad článku opravdu dík.
    Jsem samouk a i když angličtinu ovládám celkem dobře, vždy si přečtu takhle dlouhé texty raději v češtině. Stačí, že musím cizí jazyky používat v práci.

    Věnuju se převážně PHP, Java je pro mě celkem nová a takové tipy se vždycky hodí, určitě budu sdílet dál.

    Programování se věnuju už pár let, ale ještě nedávno jsem programoval převážně pro sebe a svý kámoše. Teprve nedávno se mi podařilo najít pořádná práce v oboru, který mě fakt baví a věnuji se programování na plný úvazek, ale sám vím, že se mám ještě co učit (vystudovanou mám hotelovku, tak to bylo celkem složitý něco najít). Dělám tam teď čtrnáct dní a nadšení mě ještě nepřechází.

    Hledání práce přes interenet už moc nevěřím a když mi bývalý kolega doporučil „nějaký nový server, něco jako práce.cz ale pro iťáky“, tak jsem tomu moc nevěřil.
    Ale řekl jsem si, že za vyplnění životopisu nic nedám (když už na sebe většinu beztak vybleju na Facebooku) a do týdne se mi ozvali s nabídkou práce.

    Trochu jsem se rozepsal, ale mám vážně radost, že se mi takhle splnil sen ;)

    (Životopis jsem vyplňoval na http://www.itresources.cz/, fakt mi to pomohlo, ale nechci tu strašit se spamem).

    Odpovědět
  2. milos

    Úno 26, 2017 v 22:15

    díky za překlad, přece je to sjízdnější učení
    jako javista nejsem zvykly na javascript, takze sem se dozvedel hodne

    Odpovědět

Napsat komentář: milos Zrušit odpověď na komentář

Vaše e-mailová adresa nebude zveřejněna. Vyžadované informace jsou označeny *