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

SOAPUI, autentikointi ja Groovy

Testasin taannoin SOAPUI:lla REST rajapintoja suorituskykytestimielessä. Kun rajapinnat olivat julkisia, homma on hyvin helppoa. Mutta kun rajapinta onkin autentikoinnin takana, edessä on perinteinen testityökalujen haaste: Miten kirjautua sisään siten että seuraavat kutsut menevät samaan sessioon?

No sehän riippuu autentikoinnin tavasta. SOAP UI:sta on kirjoiteltu artikkeleita joissa käydään läpi miten siihen voi tehdä mm. BASIC/DIGEST autentikoinnilla säädöt paikalleen, ja samoin on OAUTH-standardille palikoita. Mutta yleinen ratkaisu miten siirtää tavaraa pyynnöstä toiseen oli tässä yksinkertaisin. Tempun salaisuus on tehdä test caseen erillinen askel, jossa varastoidaan autentikoinnin tieto kontekstiin, josta sen voi myöhemmin pukata haluamaansa paikkaan, esim. http headeriin tms.

Eli sanotaan että askel 1 on määritetty olemaan REST kutsu nimellä ’login’, joka lähettää tunnusta ja salasanaa, ja vastaanottaa tokenin, esimerkissä nimellä ’identityToken’. Seuraava askel on avata SOAP UI:ssa test case, ja lisätä sinne uusi askel.(Add Step – Groovy Script).

Tänne kirjoitellaan koodi joka imaisee tokenin vastauksesta, esim. tähän tapaan:

// This script will grab authentication token and store it in testcase properties so it can be used later

xmlResponse = new XmlSlurper().parseText(context.expand( ’${login#ResponseAsXml}’ ))

assert xmlResponse.sessionId != null
assert xmlResponse.sessionId.toString().length() > 0
log.info(’Got ’ + xmlResponse.sessionId)
testRunner.testCase.setPropertyValue( ”authToken”, xmlResponse.sessionId.toString())

Eli pari huomiota tästä esimerkistä:

  • Tässä hyödynnetään SOAPUI:n ominaisuutta auto-konvertoida json xml muotoon – eli vastaus voi olla xml tai jsonia, kaikki käy
  • context.expand viittaa login-stepin sisältöön xml:ksi rutistettuna
  • Pseudo-xml kenttiin viitataan xmlResponse.xxx tapaan kuten yllä
  • log.info on tässä vain esimerkin vuoksi, sillä saa tulostusta
  • testcasen propertyyn lisätään authToken niminen arvo jossa on sessionid varastoituna

Tätä voi sitten vuorostaan käyttää missä haluaakin, esim. header kentässä tai asserteissa, tähän tapaan:

${#TestCase#authToken}

SOAPUI ja LOADUI ovat ihan metkoja kapistuksia joilla aika helposti pyöräyttää vähän integraatio/api/suorituskykytestejä.