Přejít k navigační liště

Zdroják » JavaScript » AngularJS direktivy a testování

AngularJS direktivy a testování

Články JavaScript, Různé

V dnešním díle se budeme věnovat především testování. Řeč bude o end2end testech a o nástroji Testacular pro automatizované testování skriptů klientského JavaScriptu. Vytvoříme si také první AngularJS direktivu pro inline editaci.

Všechny zdrojové kódy jsou dostupné na githubu v repozitáři Zdrojak. Aktuální verzi dílu stáhnete přes git příkazem git checkout -f dil12. A jak je již zvykem, k dispozici je i demo aktuální verze.

Direktivy

Direktivy jsme si představili v minulém díle, takže jen zopakuji, že umožňují rozšíření HTML o nové možnosti, ať již formou nových elementů, nebo přidáním atributů k stávajícím elementům.

Velké množství direktiv je dodáváno přímo s frameworkem a jsou popsány v API dokumentaci. U každé direktivy je k dispozici funkční ukázka vč. testů a podrobný popis. Pokud máte o AngularJS zájem a ještě jste si API dokumentaci neprošli, tak doporučuji začít právě u direktiv a zjistit tak, jaké možnosti jsou k dispozici přímo v jádře frameworku. Mnohem zajímavější je však možnost vytvářet vlastní direktivy přímo na míru konkrétní aplikaci.

Inline editace

Jako příklad si můžeme uvést direktivu pro inline editaci.

Editace v administraci se často řeší tak, že má správce k dispozici formulář pro editaci s mnoha textovými položkami. To znamená, že když např. najde překlep v názvu stránky, musí složitě přecházet do administrace na onen editační formulář, kam se mu nahrají všechny možnosti editace, vyhledat příslušný textový box, provést změnu, odeslat formulář a znova přejít na detail stránky pro kontrolu. Tedy je potřeba provést řadu úkonů, i když je nutné změnit byť jen jedno písmeno. Pro správce by mohlo být použitelnější, kdyby pouze klikl na danou položku a ta se změnila v textové pole, kde by bylo možné text okamžitě upravit a uložit.

Problém by šlo vyřešit třeba pomocí HTML atributu contenteditable (mimochodem jako první ho implementoval IE už ve verzi 5.5). My problém vyřešíme pomocí vlastní direktivy v AngularJS, na které si ukážeme, jak se vlastní direktivy programují.

V kódu (v šabloně detailu stránky) bude výsledný inline element vypadat takto:

<h1>
  <inline model='page.title' action='update'></inline>
</h1>

Výše uvedený zápis platí pro všechny moderní prohlížeče a Internet Explorer od verze 9. Má-li vaše aplikace podporovat i nižší verze IE, přečtěte si v dokumentaci, jak aplikaci pro tyto verze optimalizovat. V ukázce používáme také pro jednoduchost jednu direktivu pro nahrazení textu za element input i textarea. Alternativní implementaci pro dvě direktivy můžete vidět v ukázce na Githubu.

Pro náš element si definujeme tři atributy:

  • model – jakou hodnotu chceme vypisovat a editovat (zde titulek stránky);
  • action – název akce, která se má provést po editaci;
  • textarea – pokud je hodnota true, bude se vypisovat element textarea místo elementu  input;

Nejprve se podíváme na kompletní zdrojový kód direktivy:

var zdrojak = angular.module('zdrojak', ['zdrojakServices']);
zdrojak.directive('inline', function(){
  var KEY_CODE_ENTER = 13;
  return {
    restrict: 'E',
    replace: true,
    scope: {
      action: '=action',
      model: '=model',
      textarea: '@textarea'
    },
    template:
      '<div>' +
        '<span ng-hide="mode">{{model}}</span>' +
        '<input type="text" ng-show="mode && !textarea" ng-model="model" required>' +
        '<textarea ng-show="mode && textarea" ng-model="model"></textarea>' +
      '</div>',
    link: function(scope, element) {
      var children = element.children();
      var span  = angular.element(children[0]);
      var input = angular.element(children[1]);
      var area  = angular.element(children[2]);

      //puvodni obsah
      var oldContent;

      //zmenit editaci na text a zavolat akci po editaci
      function send() {
        var newContent = element.text().trim();
        if (newContent) {
          scope.$apply('mode=false');
        }
        if (newContent !== oldContent) {
          scope.action();
        }
      }

      function focusInput() {
        input[0].focus();
      }

      function focusArea() {
        area[0].focus();
      }

      function focus() {
        scope.textarea ? focusArea(): focusInput();
      }

      function blur() {
        if (!scope.mode) return;
        send();
      }

      function enter(e) {
        if (!scope.mode) return;
        if (e.charCode === KEY_CODE_ENTER) {
          send();
        }
      }

      //ztrata focusu, ulozit zmenu
      input.bind('blur', blur);
      area.bind('blur', blur);

      //uzivatel kliknul na enter, ulozit zmenu
      input.bind('keypress', enter);

      //po kliknuti na text zobrazit input pro editaci
      span.bind('click', function(){
        oldContent = element.text().trim();
        scope.$apply('mode=true');
        focus();
      });
    }
  }
});

Zdrojový kód si rozebereme po částech.

var zdrojak = angular.module('zdrojak', ['zdrojakServices']);
zdrojak.directive('inline', function(){
  var KEY_CODE_ENTER = 13;

Vytváříme zde modul zdrojak, který bude výchozí pro celou aplikaci (bude automaticky nahrán při inicializaci aplikace). K němu přidáváme novou direktivu inline, kterou definujeme ve funkci, která má jako návratovou hodnotu objekt s konfigurací direktivy. Definujeme také konstantu s hodnotu 13, což je kód klávesy Enter (když uživatel zmáčkne Enter, chce ukončit editaci).

  return {
    restrict: 'E',
    replace: true,
    scope: {
      action: '=action',
      model: '=model',
      textarea: '@textarea'
    },
    template:
      '<div>' +
        '<span ng-hide="mode">{{model}}</span>' +
        '<input type="text" ng-show="mode && !textarea" ng-model="model" required>' +
        '<textarea ng-show="mode && textarea" ng-model="model"></textarea>' +
      '</div>',

Zde definujeme onen konfigurační objekt direktivy, který obsahuje několik vlastností:

  • restrict – v jakém HTML kontextu se bude direktiva zobrazovat (E = element, A = atribut, C = class, M = komentář),
  • replace – znamená, že se element zamění za HTML (viz vlastnost  template),
  • scope – kontext direktivy, v našem případě vytváříme nový kontext, do kterého si přetáhneme hodnoty atributů action, model a textarea (rovnítko říká, že změna uvnitř direktivy se promítne i navenek, zavináč způsobí prosté zkopírování hodnoty),
  • template – HTML, za které se direktiva zamění.

U vlastnosti template se zastavíme. Uvnitř elementu div máme element span s direktivou ng-hide, která nastaví elementu CSS vlastnost display:none, pokud je hodnota direktivy vyhodnocena jako true. V našem případě testujeme model mode. Ten není nikde nastaven, takže bude vyhodnocen jako false a element span bude zobrazen. Jeho obsahem bude model, který byl v úvodní ukázce nastaven na page.title, takže při načtení direktivy se vypíše do nadpisu text titulku stránky.

Dále definujeme elementy input a textarea s direktivami ng-show a ng-model. Direktiva ng-show funguje stejně jako ng-hide v opačném směru. Zde je řečeno, že se zobrazí jen tehdy, pokud není model mode vyhodnocen jako true. Protože je mode vyhodnocen jako false, zobrazí se pouze span a input textarea se skryje. Direktiva ng-model říká, že se model má vypsat do textového pole nebo do elementu textarea. Zda se má zobrazit textové pole nebo textarea závisí na tom, je-li atribut textarea nastaven či nikoliv.

V poslední části kódu ještě určujeme funkci pro vlastnost link, která se provede po transformaci DOM a vypadá takto:

    link: function(scope, element) {
      var children = element.children();
      var span  = angular.element(children[0]);
      var input = angular.element(children[1]);
      var area  = angular.element(children[2]);

      //puvodni obsah
      var oldContent;

      //zmenit editaci na text a zavolat akci po editaci
      function send() {
        var newContent = element.text().trim();
        if (newContent) {
          scope.$apply('mode=false');
        }
        if (newContent !== oldContent) {
          scope.action();
        }
      }

      function focusInput() {
        input[0].focus();
      }

      function focusArea() {
        area[0].focus();
      }

      function focus() {
        scope.textarea ? focusArea(): focusInput();
      }

      function blur() {
        if (!scope.mode) return;
        send();
      }

      function enter(e) {
        if (!scope.mode) return;
        if (e.charCode === KEY_CODE_ENTER) {
          send();
        }
      }

      //ztrata focusu, ulozit zmenu
      input.bind('blur', blur);
      area.bind('blur', blur);

      //uzivatel kliknul na enter, ulozit zmenu
      input.bind('keypress', enter);

      //po kliknuti na text zobrazit input pro editaci
      span.bind('click', function(){
        oldContent = element.text().trim();
        scope.$apply('mode=true');
        focus();
      });
    }
  }
});

Nejprve definujeme odkazy na všechny elementy, se kterými budeme pracovat. Do proměnné oldContent si uložíme obsah před první editací (abychom mohli změnu vrátit a nebo abychom věděli, zda se vůbec změna provedla a je potřeba ji ukládat na server).

Definujeme dále funkce focus(), které nastaví focus na příslušný element před začátkem editace. Funkce blur() iniciuje uložení změn, pokud ztratil element focus a funkce enter() provede to samé, pokud uživatel klikl na Enter. 

Následně se všechny události zaregistrují (v případě elementu textarea nechceme změny odesílat, pokud uživatel kliknul na Enter).

Dále definujeme událost onClick pro element span. Jakmile uživatel na element klikne, uloží se původní obsah modelu a nastaví se přes scope.$apply model mode na true, takže se span skryje a input nebo textarea se zobrazí. Navíc přidá konkrétnímu elementu focus.

Poslední je funkce send(), která zpět zobrazí span a skryje formulářové prvky, není-li pole prázdné. Nakonec se kontroluje, zda se liší původní a nový obsah. Pokud ano, zavoláme akci definovanou v atributu  action.

Šablonu pro editaci stejně jako controller již nepotřebujeme. Stačí nám pouze upravit šablonu a controller detailu stránky, do kterého přibude akce  update():

$scope.update = function() {
  page.update({page: $routeParams.page}, $scope.page);
}

Jakmile se změní hodnota page.title a zavolá se akce update(), odešle se na server nová verze stránek.

Naše direktiva neobsahuje žádnou možnost, jak změny vrátit, tedy když uživatel něco do pole napíše, bylo by dobré dát mu možnost změny vrátit. Můžeme direktivu nastavit tak, aby se změny vrátily, pokud uživatel zmáčkne klávesu Esc. Můžete si sami funkčnost do direktivy přidat a odeslat do projektu pull request.

U direktiv je toho možné nastavit mnohem více, kompletní přehled možností je v Developer Guide. Ukázka editace je dostupná zde (na konkrétní stránce): http://stormy-coast-2090.herokuapp.com/pages.

End2end testování

AngularJS obsahuje Scenario Test Runner a další nástroje, přes které je možné vytvářet testy, které jsou podobné testování se Seleniem. Vše je založeno na frameworku Jasmine, který AngularJS rozšiřuje o další možnosti. Názvosloví je stejné jako v případě frameworku Mocha, který používáme pro serverovou část.

Scenario Test Runner je jednoduchá HTML stránka, do iframe načítá testovanou stránku aplikace a nad ní provádí sadu testů.

Kromě end2end testů AngularJS obsahuje i nástroje pro jednotkové testování. Vytváření jednotkových testů ale v případě Angularu vyžaduje pokročilejší znalosti frameworku, proto se jimi budeme zabývat až později.

Zkusme se podívat, jak může vypadat jednoduchý test, který testuje počet vyrenderovaných položek seznamu stránek, tedy jestliže přes API získáme 3 stránky, musí se zobrazit všechny v seznamu:

describe('/pages', function() {
  it('zobrazi seznam vsech stranek', function() {
    browser().navigateTo('/pages');
    var repeater = using('#pages-list').repeater('ul li');
    expect(repeater.count()).toBe(3)
  });
});

Funkce browser().navigateTo() načte stránku /pages, kde vypisujeme seznam všech stránek v databázi. Funkce repeater().count() spočítá počet vypsaných stránek v seznamu uvnitř elementu div s ID pages list. Protože získáváme přes API pole tří stránek, chceme vypsat 3 položky v seznamu.

V dalším testu můžeme otestovat naši direktivu třeba takto:

describe('/pages/:page', function() {

  beforeEach(function() {
    browser().navigateTo('/pages/test');
  });

  it('zobrazi detail stranky', function() {
    expect(element('h1').text()).toBe('Kontakt');
  });

  it('edituje nazev stranky', function() {
    var h1Elm = element('h1');
    var spanElm = element('h1 span');
    var inputElm = element('h1 input');

    expect(spanElm.css('display')).not().toBe('none');
    expect(inputElm.css('display')).toBe('none');

    element('h1 span').click();

    expect(spanElm.css('display')).toBe('none');
    expect(inputElm.css('display')).not().toBe('none');

    input(‘model’).enter('test');
    expect(h1Elm.text()).toBe('test');
  });
});

Před každým testem budeme prohlížeč navigovat na stránku /page/test. Zde nejprve ověříme, že se v elementu h1 vypsal název stránky přijatý přes API. V dalším testu pak ověřujeme funkčnost direktivy inline, tedy zda je ve výchozí podobě element span zobrazen a input skryt, zda se to změní po kliknutí na span a zda se také změní nadpis, pokud uživatel do input vloží nový nadpis.

Přehled všech funkcí, které je možné při testování použít, je dostupný v dokumentaci e2e testing.

Spuštění testů

Scenario Test Runner se spouští přes prohlížeč, takže nejjednodušší je pro vývojové a testovací prostředí přidat další middleware static:

app.use(express.static(process.cwd() + '/test/frontend'));

V prohlížeči se testy spustí na adrese http://localhost:5000/e2e/run­ner.html. Výsledek by měl vypadat nějak takto:

  

Testacular

Spuštění testů v prohlížeči vypadá hezky, ale potřebovali bychom nějak spuštění testů automatizovat. Navíc by bylo vhodné, aby testy bylo možné pouštět v různých prohlížečích. A právě k tomu slouží nástroj Testacular.

Na úvodní stránce projektu je dostupné krátké video, kde můžete vidět Testacular v akci. Velmi doporučuji video zhlédnout.

Nejprve je potřeba vytvořit nastavení. Konfigurační soubor testacular-e2e.conf.js pro naši aplikaci může vypadat takto:

files = [
  ANGULAR_SCENARIO,
  ANGULAR_SCENARIO_ADAPTER,
  'test/frontend/e2e/**/*.js'
];

proxies = {
  '/': 'http://localhost:5000/'
};

autoWatch = false;

browsers = ['Chrome'];

Nejprve říkáme, že všechny e2e testy chceme načíst ze složky test/frontend/e2e. Dále chceme všechny adresy směřovat na localhost:5000. Nastavení autoWatch slouží pro automatické spuštění testů při změně souborů. To se hodí pro unit testy, které jsou velmi rychlé, testy e2e jsou mnohem pomalejší, proto je direktiva nastavena na false. Nakonec nastavujeme prohlížeče, pod kterými chceme testy spouštět, nám stačí zatím jen Google Chrome.

V projektu angular-seed jsou skripty pro jednodušší spuštění e2e testů a jsou dostupné i v našem projektu ve složce scripts.

Spuštění testů

Testy se spustí ze složky scripts souborem e2e-test.bat (Windows) nebo e2e-test.sh (Linux, Mac). Před spuštěním testů přes AngularJS musí být aplikace spuštěna (příkaz node server.js). Výstup z testování může vypadat takto:

  

Testovací data s ngMockE2E

Výše uvedenému řešení má jeden výrazný nedostatek. V testech pracujeme s daty, které jsme získali přes API z databáze. Pokud do ní přidáme další stránku, první test selže, protože budeme (správně) vypisovat 4 stránky v seznamu místo třech, se kterými test počítá. Potřebujeme tedy nějak vyřešit, aby aplikace pro testy pracovala s testovacími daty, jejichž změny budeme mít pod kontrolou.

Protože část v AngularJS je naprosto nezávislá na API (může být v jiném repozitáři, může ji vyvíjet jiný tým či úplně jiná firma), potřebujeme vytvořit nějaký způsob, jak v testovacím režimu vracet přes API předdefinovaná data.

Pro tyto účely slouží v AngularJS modul ngMockE2E. Můžeme si pro testy nadefinovat, pro jaká URL a HTTP hlavičky bude AngularJS vracet jaká testovací data. Jak takové nastavení provést, je uvedeno v dokumentaci.

I když jsme se posunuli v nastavení testovacího prostředí o kus dál, pořád zde máme jeden nepříjemný problém. Pokud bychom testovací případy pro ngMockE2E definovali ručně, museli bychom testovací odpovědi upravovat při každé změně API. Pokud bychom na to zapomněli, aplikace by byla testována s daty, které neodpovídají realitě. Takové automatizované testy jsou ještě horší, než když aplikace neobsahuje testy vůbec, protože nám dávají falešný pocit jistoty, že vše funguje správně, což nebude pravda. Potřebujeme lepší řešení.

Apiary.io

A to přináší Apiary, o kterém jsme psali v 9. díle. Stačilo by nám pouze říct, že dotazy na API budeme směřovat na jinou doménu. Dokumentace v Apiary je vždy aktuální, protože proti testovanému rozhraní v Apiary pracují všechny systémy. Vždy, když uděláme nějakou změnu v API, uděláme ji nejprve na straně Apiary a jednotlivé části systému se jí přizpůsobí. Máme jistotu, že používáme vždy aktuální data.

Tohle je už mnohem lepší, ale pořád je tady jeden problém. Jsme závislí na internetovém připojení a na tom, že bude Apiary vždy k dispozici. Pokud se rozhodneme, že budeme pracovat třeba při dlouhé cestě ve vlaku, máme smůlu. Potřebujeme naše řešení ještě nějak vylepšit.

Blueprint Parser a Grunt.js

Apiary umožňuje automatickou synchronizaci s repozitářem na Githubu. Kdykoliv dojde ke změně dokumentace rozhraní API, Apiary odešle do repozitáře aktuální verzi v souboru apiary.apib do rootu projektu. Kdykoliv pak bude chtít někdo commitovat nějakou změnu do repozitáře, bude upozorněn, že došlo k změně a musí nejprve stáhnout soubor apiary.apib k sobě. Takže před odesláním změn do repozitáře bude mít vždy u sebe aktuální verzi rozhraní API.

Nyní nám stačí, abychom vytvořili Grunt.js task (o Grunt.js jsme psali v 10. díle seriálu), který převede data z apiary.apib  na pole všech dotazů a odpovědí, které pak už stačí jen zaregistrovat pro ngMockE2E. Nyní máme k dispozici vždy aktuální verzi testovacích dat lokálně.

Nejprve tedy vytvoříme Grunt.js task (v souboru grunt.js):

  grunt.registerTask('apiary2js', 'Generate js version of apiary file.', function() {
     var parser  = require('apiary-blueprint-parser');
     var content = grunt.file.read('apiary.apib');
     var blueprint = parser.parse(content);
     var json = JSON.stringify(blueprint.sections, null, 2);
     grunt.file.write('test/frontend/apiary.js', "var apiary = " + json);
  });

Používáme zde npm balíček Blueprint Parser, který přes metodu parse() převede data ze souboru apiary.apib na objekt. Dále data převedeme na JSON a vložíme je do souboru test/frontend/apiary.js k proměnné apiary. Nyní když z příkazového řádku zadáme příkaz grunt apiary2js (na win grunt.cmd apiary2js), vytvoří se nám soubor apiary.js s objektem reprezentujícím naše API.

Dále bude potřeba upravit výchozí layout aplikace, který načte ngMockE2E a soubor apiary.js takto:

<% if (env === 'development' || env === 'test') { %>
<script src="/lib/angular/angular-mocks.js"></script>
<script src="/apiary.js"></script>
<% } %>

Používáme zde šablonovací systém EJS, o kterým si povíme někdy příště. Důležité je vědět, že pro testovací prostředí development nebo test se načte vše nutné pro testování.

Nakonec stačí data z apiary.js nastavt pro ngMockE2E. Úplně nejjenodušší implementace může vypadat takto (v souboru services.js):

var mock = angular.module('zdrojakMock', ['ngMockE2E']);
mock.run(function($httpBackend) {

  var resources = apiary[0].resources;
  resources.forEach(function(res){
    var url = res.url.replace('{id}', 'test');
    switch (res.method) {
      case 'GET':
        $httpBackend.whenGET(url).respond(res.responses[0].body);
        console.log(url);
        break;
      case 'POST':
        $httpBackend.whenPOST(url).respond(res.responses[0].body);
        break;
      case 'PUT':
        $httpBackend.whenPUT(url).respond(res.responses[0].body);
        break;
      case 'DELETE':
        $httpBackend.whenDELETE(url).respond(res.responses[0].body);
        break;
    }
  });

  //nechat projit pozadavky na sablony
  $httpBackend.whenGET(/^/partials//).passThrough();
});

A nastavení aplikace budeme načítat podle toho, zda je aplikace spuštěna pro testy či nikoliv (soubor app.js):

var scenario = parent.scenario || false;
var services;
if (scenario) {
  services = ['zdrojakMock', 'zdrojakServices'];
} else {
  services = ['zdrojakServices'];
}
var zdrojak = angular.module('zdrojak', services);

Proměnná scenario je nastavená ve scénářích (test/frontend/e2e/scenarios.js). Protože Scenario Test Runner spouští aplikaci v iframe, je proměnná dostupná v aplikaci přes  parent.scenario.

Nakonec potřebujeme ještě upravit skript pro spuštění testů pro Testacular. Stačí do scripts/e2e-test.bat nebo scripts/e2e-test.sh přidat příkaz grunt apiary2js. Nyní kdykoliv testy spustíme, nejprve se vygenerují testovací data z Apiary a nad nimi se spustí testy.

Co dále

Příští (vánoční) díl bude věnován několika atraktivním tématům kolem Node.js. Můžete se těšit třeba na informace o tom, jak psát Node.js aplikace v CoffeeScriptu.

Na tvorbě tohoto článku se svými připomínkami podílel také Pavel Lang (skolajs.cz). Díky!

Komentáře

Subscribe
Upozornit na
guest
6 Komentářů
Nejstarší
Nejnovější Most Voted
Inline Feedbacks
View all comments
vidya

Ak niekto preferuje mochu pred jasmine aj na klientovi, od angular 1.1.1 test helpre funguju aj s mocha.
Testing with mocha

David Majda

Jak autor Apiary blueprint parseru mám jednu drobnou poznámku k jeho použití. Třída Blueprint, jejíž instance z parseru vypadne, má metodu toJSON. Tu je třeba zavolat před serializací do JSONu.

Místo

var json = JSON.stringify(blueprint.sections, null, 2); 

by tedy mělo být

var json = JSON.stringify(blueprint.toJSON().sections, null, 2); 

V současnosti je rozdíl mezi instancí třídy Blueprint a její JSON reprezentací zanedbatelný, ale do budoucna to platit nemusí.

Pavel Lang

Nevím jak jinde, ale ve Firefoxu, Chrome, node.js má metoda toJSON speciální význam.
V případě, že objekt má tuto metodu, tak ji JSON.stringify volá automaticky:

var C = function() {
  this.faked = false;
};

C.prototype.toJSON = function() {
  return { faked: true };
}

console.log(JSON.stringify(new C, null, 2));

Výsledek je tady takovýto:

{
  "faked": true
}

i když property faked má hodnotu false

Bas

Dokázal by někdo objektivně v pár bodech srovnat angular a ember.js ? Zatím se mi více líbí ember, ale taky vidím, že sousta lidí používá angular.

iki

na zacatku videa je srovnani todomvc v backbone, angular a ember:

Dan Steigerwald: TodoMVC a Este.js – DevFest Praha 2012 ~ http://youtu.be/6vm1X_X2ztc

Bruce

Díky za skvělé články.
Poraďte, mám potíž: po skončení práce mi testacular nezabije chrome a ten pořád běží v pozadí a musím ho zabít ručně (resp. jsem si udělal dávku, ta ale zabije i chrome běžící v oknech). Dá se s tím něco dělat? Mám Windows 8. Dík

Enum a statická analýza kódu

Mám jednu univerzální radu pro začínající programátorty. V učení sice neexistují rychlé zkratky, ovšem tuhle radu můžete snadno začít používat a zrychlit tak tempo učení. Tou tajemnou ingrediencí je statická analýza kódu. Ukážeme si to na příkladu enum.