AngularJS, Protractor, ja hidas testiympäristö

Tulipa ajankohtaiseksi miettiä miten protractorin voisi saada odottelemaan että jotain on saatavana ruudulla, ennen kuin yrittää alkaa sitä klikkailemaan.

Periaattessahan protractorin pitäisi olla Angular-elinkaaresta hyvinkin tietoinen, ja odotella asynkronisten kutsujen valmistumista ennen kuin etenee. Hätätapauksessa on taikauskonomainen browser.waitForAngular() jonka pitäisi tehdä samantapaista.

Nyt näyttäisi kuitenkin siltä, että protractor (traktorin puolesta? 🙂 ei näin aina tee, etenkin jos testikone on hidas/jähmeä *köhköhvirtuaalikoneköhköh*. Voi siis käydä niin että yhdessä koneessa testit sujahtavat iloisen vihreänä läpi ja toisessa helottavat punaisina satunnaisesti. Tämä ei ole oikein hyvä juttu testiympäristön kannalta.

Protractorissa on melkoisen epämiellyttävää kirjailla koodia joka tutkii onko elementit näkösällä – nuo element(by.xxx()) komennot kun palauttavat lupausta tulevasta, promiseja, jotka pitää ratkoa asynkronisesti. Kun haluaa tarkistaa useamman elementin näkyvyyden/enabloinnin, tulee ihastuttavia sisäkkäisiä lohkoja joita itse en tykkää koodissa katsella.

Vaan eipä hätää. Löysin taannoin juuri tähän sopivan syntaksin, joka toimii porttina estäen testin etenemisen kunnes kohde löytyy tai timeout pamahtaa. Se on näinkin simppeli:

browser.wait(element(by.id('my-id')).isPresent);

browser.wait(element(by.id('my-id')).isDisplayed);

browser.wait(element(by.id('my-id')).isEnabled);

Tämäkin on jonnekin manuaalin uumeniin haudottu. Näillä voi hidastaa tahtia sen verran että hitaammatkin myllyt ehtivät painelemaan nappeja kun käyttöliittymä morffaa silmien edessä. Tuolle browser.wait funktiolle muuten kelpaa miten hyvänsä muotoiltu promise jota voi tutkiskella.

Tietysti menisi liian helpoksi jo se olisi ihan noin toiminut. Tuo isPresent ainakin itsellä antoi jotain herkullista cannot get count for undefined erroriherjaa joten tein sille hiukan kankeamman vastineen:

browser.wait(function () {
 return browser.isElementPresent(element(by.id('zzzzz')));
 }, 10000);

Nämä voi hautoa mukaviin uudelleenkäytettäviin funktioihin, ja taas testiautomaatio jauhaa luotettavammin!

 

Angular testing cheat sheet

Sen verran useasti Angularin parissa tulee pyörittyä, että pidän seinälle tulostettuna muutamaa yleisintä testaukseen liittyvää jippoa. Javascript kun ei anna käännöksenaikaista palautetta, on erityisen tärkeää tietää API. Samoin liikkeellä on myös huonoa ja vanhentunutta tietoa; Yli puolet netin resursseista listailee vanhentuneita komentoja esim. jasminen ja protratorin rajapinnoista.

Pidemmittä puheitta siis, tässä Angular testauksen cheat sheet:

// jasmine matchers
describe('jasmine matchers', function() {
  it('demonstrate use of built-in matchers', function() {
    expect(true).toBeTruthy();
    expect(false).not.toBeTruthy();
    expect(false).toBeFalsy();
    expect(true).not.toBeFalsy();
    expect({}).toBeDefined();
    expect(undefined).not.toBeDefined();
    expect(null).toBeNull();
    expect(undefined).not.toBeNull();
    expect({}).not.toBeNull();
    expect('Hello World!').toEqual('Hello World!');
    expect('Hello World!').not.toEqual('Goodbye!');
    expect('Hello World!').toNotEqual('Hi!');
    expect([1, 2, 3]).toEqual([1, 2, 3]);
    expect(1).toEqual(1);
    expect({ foo: 1 }).toEqual({ foo: 1 });
    expect(1.223).toBeCloseTo(1.22);
    expect(1.233).not.toBeCloseTo(1.22);
    expect(1.23326).toBeCloseTo(1.23324, 3);
    expect([1, 2, 3]).toContain(2);
    expect([1, 2, 3]).not.toContain(4);
    expect('Hello Jasmine').toMatch(/jasmine/i);
    expect('phone: 123-45-67').toMatch(/\d{3}-\d{2}-\d{2}/);
    expect(2).toBeGreaterThan(1);
    expect(2).toBeLessThan(3);
    expect(object.doSomething).toThrow(new Error("Unexpected error!"));
  });
});

// Creating custom matchers
  beforeEach(function() {
    this.addMatchers({
      toBeGET: function() {
        var actual = this.actual.method;
        return actual === 'GET';
      },
      toHaveUrl: function(expected) {
        var actual = this.actual.url;
        this.message = function() {
          return "Expected request to have url " + expected + " but was " + actual
        };
      return actual === expected;
     }
   });
});
// Protractor API: http://angular.github.io/protractor/#/api
// Note: Most commands return promises, so you only resolve their values
 // through using jasmine expect API or using .then(function()) structure

// Control browser
browser.get('yoururl'); // Load address, can also use '#yourpage'
browser.navigate().back();
browser.navigate().forward();
browser.sleep(10000); // if your test is outrunning the browser
browser.waitForAngular(); // if your test is outrunning the browser
browser.getLocationAbsUrl() // get the current address

//Here's a trick how to wait for something to become present/visible:
browser.wait(element(by.id('some-element-id')).isPresent);

// Control buttons
element(by.id('create')).click(); // Click a button or other item

// Check visibility
element(by.id('create')).isPresent(); // Careful with this: element is often present while it's not displayed...
element(by.id('create')).isEnabled(); // enabled/disabled, as in ng-disabled...
element(by.id('create')).isDisplayed(); // Is element currently visible/displayed?

// Find an element by id, model, binding, ...
element(by.id('user_name'));
element(by.css('#myItem'));
element(by.model('person.name')); // refers to ng-model directive
element(by.binding('person.concatName')); // refers to ng-bind directive
element(by.textarea('person.extraDetails'));
element (by.input( 'username' ));
element (by.input( 'username' )).clear();
element(by.buttonText('Save'));
element(by.partialButtonText('Save'));
element(by.linkText('Save'));
element(by.partialLinkText('Save'));
element(by.css('[ng-click="cancel()"]')); 

var dog = element(by.cssContainingText('.pet', 'Dog'));
var allOptions = element.all(by.options('c for c in colors')); // when ng-options is used with selectbox

// Find collection of elements by css, repeater, xpath..
var list = element.all(by.css('.items li'));
var list2 = element.all(by.repeater('person in home.results'));
var list3 = element.all(by.xpath('//div'));
expect(list.count()).toBe(3);
expect(list.get(0).getText()).toBe('First');
expect(list.get(1).getText()).toBe('Second');
expect(list.first().getText()).toBe('First');
expect(list.last().getText()).toBe('Last');

// Send keystrokes, clear
element(by.id('user_name')). sendKeys("user1");
sendKeys(protractor.Key.ENTER);
sendKeys(protractor.Key.TAB);
element(by.id('user_name')).clear();

// Position and size, also how to deal with promises:
element(by.id('item1')).getLocation().then(function(location) {
  var x = location.x;
  var y = location.y;
});
element(by.id('item1')).getSize().then(function(size) {
 var width = size.width;
 var height = size.height;
});
// Jasmine spy / mocks
//How to spy on a method?
spyOn(obj, 'method') // assumes obj.method is a function
//How to verify it was called?
expect(obj.method).toHaveBeenCalled()
//How to verify it was called with specific arguments?
expect(obj.method).toHaveBeenCalledWith('foo', 'bar')

//How many times was it called?
obj.method.callCount

//What were the arguments to the last call?
obj.method.mostRecentCall.args

//How to reset all the calls made to the spy so far?
obj.method.reset()

//How to make a standalone spy function?
var dummy = jasmine.createSpy('dummy')
$('button#mybutton').click(dummy)

//How to have spied method also calls through to the real function?
spyOn(obj, 'method').and.callThrough()

//How do I fix the return value of a spy? spyOn(obj, 'method').and.return('Pow!')

//How to have spied method be replaced by fake implementation?
spyOn(obj, 'method').and.callFake(function() {
  return "HELLO";
});

// Arguments for callFake function are in arguments array, for example 1st argument: arguments[0]
// Prepare a mock
spyOn(configurations, 'getObjectId').and.callFake(function () {
  switch (arguments[0]) {
    case "HEADEROBJECT":
      return 1;
    case "FOOTEROBJECT":
      return 2;
    default:
      return -1;
   }
});

//How to get all arguments for all calls that have been made to the spy?
obj.method.argsForCall // this is an array

// if you want to mock without an object
var myFoo = jasmine.createSpyObj('sender', ['send']);

// Create fake implementation for empty mock
myFoo.send.andCallFake(function() {return ['some', 'fake', 'data'];});

// Same with some parameters
myFoo.send.andCallFake(function(arg1,arg2) {
  return [arg1*2,arg2*3];
});

// To expect any types
expect(this.sender.send).toHaveBeenCalledWith("my message", any(Function), any(Function));

Angular Protractor ja Drag&Drop testaus

No niin, mitäpä ei moderni tablettikäyttöliittymä olisi ilman drag&drop kikkailua. Ajankohtaiseksi tuli miettiä miten sellaista voisi testata, automatisoidusti.

Onnekkaasti protractorista löytyi tähän suoraan rahkeet, jotka jopa toimivat:

// Grab two panels
var panels = element.all(by.repeater(’panel in panelsInGrid’));
var panel1 = panels.get(0);
var panel2 = panels.get(1);

// Drag&drop to new location
browser.actions().dragAndDrop(
panel1,
panel3
).perform();

Ja sillä siisti. Toimii (paremmin kuin) junan vessa. Joskus on tarve absoluuttisille koordinaateille:

browser.actions().dragAndDrop(
panel1DragHandleEast,
{x: 200, y: 200}
).perform();

Jep, sekin onnistuu. Onkohan jotain mitä Protractorilla ei voi automatisoida.. 😉

Google Chrome ja Protractor timeout mysteeri ratkennut

Kirjailin taannoin tänne testiautomaatiota riivanneesta pulmasta, kun yllättäen Chrome selaimella ajettavat Protractor testit alkoivat epäonnistua timeout-virheiden vuoksi. Syyksi paljastui Chrome version 38 bugi suhteessa Selenium Webdriveriin. Tilapäinen korjaus jonka taisin tänne myös kirjoitella oli poistaa koneesta Chrome 38, asentaa tilalle Chrome 37, ja sitten disabloida auto-update.

Bugi johon tässä viittaan löytyy täältä: https://code.google.com/p/chromium/issues/detail?id=422218

Chrome 39 with updates disabled

Nyt on tuoreeltaan julkaistu uusi Chrome versio 39, jossa tuo bugi on korjattu, ja sain juuri verifioitua että homma toimii taas silläkin ihan aikuisten oikeesti. Eli protractorin kanssa puljatessa homma menee nyt näin: Chrome 39 asennettuna, ja päivitykset edelleen disabloituna. Tästedes päivitetään manuaalisesti.

Tuossa on linkki paikkaan jossa muun muassa käydään läpi tuota auto-päivitysten disablointia:

http://watirmelon.com/2014/11/05/lock-down-your-browser-versions-if-you-run-webdriver-tests/

Huomattakoon että halutessaan voi myös editoida rekisteriä käsin.

Tuossa on Chrome 39 changelog: http://googlechromereleases.blogspot.fi/2014/11/stable-channel-update_18.html

Chrome 39 on muuten myös nopeampi, ja sisältää muitakin bugikorjauksia. Saatavana myös Androidille! 😉

Protractor, Firefox ja InvalidElementStateError

No niin, aina oppii jotain uutta ja ihmeellistä. Protractor e2e testiautomaation pyörintä on vähän viime viikkoina yskinyt. Ensimmäinen ongelma oli automaatioserverin timeoutit, joiden syyksi paljastui jonkunmoinen bugi chrome selaimessa/sen driverissa.

Korjaukseksi otettiin käyttöön Firefox odotellessa Chromen palautumista toimintakuntoiseksi. Firefox ajoi iloisesti kaikki muut testit paitsi ne joissa valitaan selection listasta joku tietty arvo sisällön perusteella. Originaali koodi:

//Open selection box
selectBoxElement.click();
browser.waitForAngular();
selectBoxElement.element(by.cssContainingText('option', item)).click();
browser.waitForAngular();

SelectBoxElement on tässä se laatikko josta valinta tehdään, ja item on merkkijono jonka sisältö tulisi löytyä valittavasta arvosta. Toimii hienosti Chromessa! (Tai toimi… Grrr…)

No Firefox ei tästä tykkää. Avaa kyllä boxin ja valintakin siirtyy oikeaan kohtaan kyllä, mutta jotenkin silti boxin suljettua vanha arvo edelleen löytyy. Syyksi paljastui bugi Selenium webdriverissa, joka ai lauo change eventtiä oikein tässä tilanteessa Firefoxissa.

No eipä hätää, Interwebin voimalla löytyi korjaus tähän:

//Open selection box
 selectBoxElement.click();
 browser.waitForAngular();

// Make selection
selectBoxElement.element(by.cssContainingText('option', item)).click();
browser.waitForAngular();

// Firefox driver fix to trigger change event for Angular
browser.actions().mouseDown().mouseUp().perform();
browser.waitForAngular();

Joka toimikin viikon. Ja nyt:

InvalidElementStateError: [Exception… ”Component returned failure code: 0x80004005 (NS_ERROR_FAILURE) [nsINativeMouse.click]”

Jep jep, ja tähän ei löydy patchejä oikein mistään. Yhtäkkiä siis joko webdriver tai firefox saa halvauksen tuosta simuloidusta klikkauksesta, mahdollisesti koska se on väärässä kohtaa ruutua?

Tätä saikin sitten aikansa ihmetellä. Miten syvään suohon sitä voi fanaattisella testiautomaatiolla päätyä.. Kokeilin kaikenlaista, lähettää erinäisiä loitsuja sendKeys metodilla select laatikolle, arvolle, käyttää promiseja asynkroniseen etenemiseen siltä varalta että tämä on joku ajastusongelma. Ei apua, ei toimi.

Vaan toimiipa sittenkin. Sometimes less is more. Käy ilmi että valinnan voi tehdä myös epäintuitiivisesti avaamatta selectoboxia ennen. Käy ilmi että tämä toimii Chromessa ja Firefoxissa. Käy ilmi että nykyisellään tämä on ainoa tapa tehdä valinta selectboxista onnistuneesti.

Eli tuunauksen jälkeen, tässä oman autotallin algoritmi selectboxin valintaan protractorissa:

module.exports.selectDropdownByText = function selectOption(selectBoxElement, item) {
   selectBoxElement.element(by.cssContainingText('option', item)).click();
   browser.waitForAngular();
};

Ja taas toimii.

Angular, Protractor, Jasmine, Chromedriver ja Chrome – timeout bugi

Törmäsin testiautomaatiossa ongelmaan: Aivan yllättäen testiajot päättyvät virheilmoitukseen:

[INFO] A Jasmine spec timed out. Resetting the WebDriver Control Flow.

Tämä siis tapahtuu heti ensimmäistä protractor käskyä annettaessa, mikä se onkaan. Grunt käynnistää iloisesti kyllä testiserverin, ja selenium serverin, kaikki ajurit ja kilkkeet on asennettuna, mutta selaimen kanssa homma ei etene.

Pienen nuuskimisen jälkeen jöytyi johtolanka. Chrome selaimen versio 38 ja 39, sekä nykyinen Chromedriver eivät ole yhteensopivia. Toisin sanoen, kun Chrome pakko-päivittää itsensä uuteen, tuota bugia alkaa ilmenemään muillakin.

https://code.google.com/p/chromedriver/issues/detail?id=928

Ratkaisuja on parikin. Vaihtoehto 1 on tiputella Chrome takaisin versioon 37 – joka toimi – samalla voi joutua tiputtelemaan webdriveriakin ajassa taaksepäin. Sitten pitää estää automaattipäivitykset.

Valitsin toisen tien ja toistaiseksi testiautomaatio pyöriköön Firefoxilla, täytyy seurata tätä mielenkiinnolla. Mutta hyvä tietää jos oudot timeoutit alkavat riivaamaan.

Grunt, Protractor, ja Istanbul kattavuusraportit e2e-testeille

Tuli ajankohtaiseksi pohtia voiko API ja E2E testeistä saada koodikattavuusanalyysejä. Käy ilmi, että Angular-alustalla Protractor ajoista voi. Yritin ensin helppoa reittiä valmiin grunt-protractor-coverage plug-inin kanssa, mutta törmäsin toistuvasti kahteen ärsyttävään virheeseen joista ei löytynyt lisätietoa ja jotka eivät ratkeeneet. Joten löysin ratkaisun joka on vielä yksinkertaisemmista paloista koottu: Instanbul moduuli ja vähän magiaa.

Istanbulille on tehty monia Grunt-plugineita, itse päädyin käyttämään perusmallia taichi.

https://github.com/taichi/grunt-istanbul

Plugarin asennus ja lataus on ihan normikamaa. Tärkeät taskimäärittelyt ovat instrument, ja makeReport.

Instrument tähän tapaan:

    instrument: {
      files: 'app/*.js',
      options: {
        lazy: true,
        basePath: 'test/coverage/instrument/'
      }
    }

Tuossa app/*.js on kansio jossa koodit muhivat, ja test/coverage/instrument on kansio johon instrumentoidut versiot koodista kopioituvat. Tämä prosessi muuttaa koodien rakennetta rajusti joten todella syytä pitää erillisiä kopioita, ja suojata omat lähdekoodit.

Tämän magian kannalta on hyvä lisätä vielä coverageVariable muuttuja, eli lopullinen ratkaisu tässä:

    instrument: {
      files: 'app/*.js',
      options: {
        lazy: true,
        coverageVariable: '__coverage__',
        basePath: 'test/coverage/instrument/'
      }
    }

Näin kun tätä instrumentoitua koodia ajetaan, kutsut päivitetään tähän globaaliin coverage muuttujaan – jonka me voimme napata (oletusnimi on aika härpäke aikaleimoineen, siksi määrittelemme sen uudelleen).

Tämän ohella on syytä clean-taskilla putsata ensin coverage kansio, sitten copy-taskilla siirtää kaikki ei-javascript tauhkat joita e2e testaukseen tarvitaan, sisältäen esim. html, kuvatiedostot, tyylisivut, jne – tietysti valmiiksi prosessoituna e2e-käyttöön kelvollisina. Tässä esimerkki copy-taskin sisällöstä:

coverageE2E: {
 files: [
 {expand: true, cwd: './dist/lib', src: '**/*.*', dest: 'coverageE2E/lib'},
 {expand: true, cwd: './dist/', src: '**/*.html', dest: 'coverageE2E'},
 {expand: true, cwd: './dist', src: '**/*.png', dest: 'coverageE2E'},
 {expand: true, cwd: './dist', src: '**/*.jpg', dest: 'coverageE2E'},
 {expand: true, cwd: './dist', src: '**/*.gif', dest: 'coverageE2E'},
 {expand: true, cwd: './dist', src: '**/*.ico', dest: 'coverageE2E'},
 {expand: true, cwd: './dist', src: '**/*.svg', dest: 'coverageE2E'},
 {expand: true, cwd: './dist', src: '**/*.css', dest: 'coverageE2E'},
 {expand: true, cwd: './dist', src: '**/*.css', dest: 'coverageE2E'},
 {expand: true, cwd: './dist/fonts', src: '*.*', dest: 'coverageE2E/fonts'},
 {expand: true, cwd: './dist/lib', src: '*.*', dest: 'coverageE2E/lib'},
 {expand: true, cwd: './WEB-INF', src: '*.*', dest: 'coverageE2E/WEB-INF'},
 // Here, extra copy operation since istanbul puts instrumented code in a subdir to a wrong path
 {expand: true, cwd: './coverageE2E/dist', src: '**/*.*', dest: './coverageE2E'}
 ]
 }

Nyt kun asiaa oikein ääneen mietin, niin kenties tätäkin voisi yleistää, esim. kopioi kaikki ei- .js päätteiset tiedostot?

Nykyisellään Istanbul instrumentointi pakottaa kohdekansioon hakemistorakenteen missä lähdekansio on – joka ei ole aina haluttua. En löytänyt pluginista säätöjä, joten tuossa copy taskissa kopioidaan dist-kansion sisältö tasoa ylemmäs, jotta se on rakenteessa jota itse käytän myös testeihin ja tuotantoon.

Seuraavaksi tulee se dirty trick: Napataan kerätty __coverage__ muuttuja ja kirjoitellaan se tiedostoon, jahka kaikki coverage on kerätty. Tämä on aivan varmasti tehtävissä nätimminkin – mutta tässä on esimerkkinä protractor-taskin onComplete-funktio joka ajetaan sen lopuksi:

onComplete: function() {
  // Let's write code coverage analysis to a file!
  browser.driver.executeScript("return __coverage__;").then(function(val) {
  fs.writeFileSync("coverageE2E/coverage.json", JSON.stringify(val));
  });
}

Lopuksi analysoidaan coverage.json tiedosto makeReport-taskilla:

module.exports = {
 src: 'coverageE2E/coverage.json',
 options: {
 type: 'lcov',
 dir: '../../../target/e2ecoverage',
 print: 'detail'
 }
};

Ja tässä kooste koko e2e_coverage taskista:

 

 

module.exports = function(grunt) {
 grunt.registerTask('e2e_coverage', [
   'dev', // First do normal build to /dist folder
   'clean:coverageE2E', // clean away old instrumented code
   'instrument', // Instrument all javascript files to instrumented folder using Istanbul
   'copy:coverageE2E', // Copy all non-javascript resources from dist to instrumented code
   'express:coverageE2E', // Run express server using instrumented folder, not dist folder
   'protractor:coverage', // Run protractor tests using instrumented folder
   'makeReport' // Use Istanbul reporting
  ]);
};

Eli tiiviisti: Käännä, putsaa vanhat pois, instrumentoi koodi Istanbulilla, kopioi html:t, tyylisivut ja kuvat sinne sekaan, aja serveri ja protractor testit instrumentoidun koodin kansiosta, ja rakenna raportti tuotoksista, taas Istanbul-pluginilla.

Nyt – tätä olisi mukavaa analysoida Sonarissa, mutta näyttäisi että siellä on Javascript-pluginissa paikka vain yhdelle lcov tiedostolle…

Huom. myös – tuon coverage tiedon keruuvaiheen voisi tehdä elegantimminkin grunt-protractor-coverage pluginilla, löytyy täältä:

https://www.npmjs.org/package/grunt-protractor-coverage

Omissa kokeiluissa en kuitenkaan saanut tuota toimimaan, se antoi kahta eri virhettä – voi johtua käytössä olevista kirjastokombinaatioista, kansiorakenteista, tms, mutta en viitsinyt haaskata siihen enempää aikaa.