Ik heb eerder geschreven over Protractor, en waarom het een goed idee is deze tool te gebruiken om je projecten mee te testen. Bij Vevida hebben we een uitvoerige Protractor test suite gemaakt voor een van onze projecten. Hierdoor hebben we inmiddels aardig wat ervaring opgedaan met het gebruik van deze tool. In dit artikel vertel ik over de lessen die ik heb geleerd.

Protractor en de Promise Manager

Protractor is asynchroon opgebouwd. Dat betekent dat op het moment dat je een opdracht geeft, bijvoorbeeld element(by.tagName('button')).click(), deze niet direct (synchroon) wordt uitgevoerd. Maar als je op dit moment (augustus 2017) Protractor code schrijft, merk je daar weinig van. Dat is omdat er in Selenium een systeem zit genaamd de PromiseManager. Deze zorgt ervoor dat alle asynchrone handelingen netjes één voor één worden uitgevoerd.

Pas echter op: dit systeem gaat in de toekomst deprecated worden. Het was geschreven in een tijd voordat promises en async / await gestandaardiseerd waren. Bij Selenium hebben ze besloten dat, nu deze syntax standaard onderdeel van JavaScript wordt, je prima zelf de asynchrone operaties kunt beheren. Een Protractor test met async / await in plaats van de PromiseManager zal er ongeveer zo uit zien:

describe('Het contactformulier', () => {

  it('checkt dat het onderwerp ingevuld is', async () => {
    await browser.get('/contact');

    const onderwerp = element(by.css('input[name="onderwerp"]'));
    const foutmelding = element(by.id('onderwerp-fout'));

    await expect(onderwerp.getAttribute('value')).toBe('');
    await expect(foutmelding.isPresent()).toBe(false);

    await element(by.css('input[type="submit"]')).click();
    await expect(foutmelding.isPresent()).toBe(true);
    await expect(foutmelding.getText()).toBe('Onderwerp is verplicht');
  });
});

Als je nu begint met het schrijven van Protractor tests, raad ik je aan ze direct met async / await te schrijven. Vanaf Node 8 zou dat native moeten werken. Zo niet, dan kun je altijd nog transpileren met Babel of TypeScript. Vergeet daarbij niet de PromiseManager uit te schakelen, want die staat voorlopig nog wel standaard aan. Hier zijn instructies voor het handmatig uitschakelen van de PromiseManager.

Marker attributes

Het is in Protractor heel makkelijk elementen in je UI te vinden op basis van tag namen of CSS selectors. Maar voordat je zonder meer selectors in je tests gaat zetten moet je je wel beseffen dat je jezelf daarmee enorm vastbindt aan de huidige markup structuur van je UI. Wat betekent dat elke keer dat je een class wilt aanpassen of een div ergens tussen wilt zetten je het risico loopt dat je test stuk gaat, terwijl het gedrag nog precies hetzelfde is.

Een oplossing zou bijvoorbeeld zijn een conventie af te spreken over een prefix voor classes die speciaal voor Protractor tests bedoeld zijn. Iets als e2e-mijn-knop bijvoorbeeld. Aan de prefix zie je direct waar het voor bedoeld is, dus kom je niet in de verleiding deze class voor styling te gebruiken. En elke keer dat je het element waar die class op staat aanpast, weet je dat je de test ook bij zult moeten werken.

Ik ben nog een stapje verder gegaan. Aangezien classes voornamelijk voor styling bedoeld zijn leek het me netter een attribute te gebruiken om elementen mee te markeren. Iets als <div e2e="mijn-wrapper">. Ik heb zelfs een eigen Protractor locator geschreven om elementen met deze attribute te matchen:

by.addLocator("marker", (marker, parentElement) => {
  const scope = parentElement || document;
  const matches = scope.querySelectorAll(`[e2e="${marker}"]`);
  if (matches.length === 0) {
    return null;
  } else if (matches.length === 1) {
    return matches[0];
  }
  return matches;
});

Nu kan ik in mijn tests een specifiek element simpelweg vinden met element(by.marker("mijn-wrapper")). En als ik die marker naar een span of een section wil verplaatsen dan blijft de test gewoon werken.

browser.wait en ExpectedConditions

Een van de nadelen van Protractor is dat het nog wel eens wat gevoelig kan zijn voor timing problemen. Het is makkelijk om Protractor te vragen op een knop te klikken en dan te checken of een bepaald element is verschenen. Maar stel dat er eerst wat moet worden gerekend, en het wel eens honderd milliseconden kan duren voordat het element verschijnt, dan kan het zomaar gebeuren dat op het moment dat Protractor het element checkt deze nog niet verschenen is. Een fout is het gevolg. En dat is niet deterministisch: bij sommige runs gaat het goed, bij andere fout.

Als je false positives wilt voorkomen, moet je dus goed nadenken over dit soort potentiële timing problemen, en de test preventief hierop aanpassen. Voor de goeie timing van tests kun je browser.wait en de helper functies van ExpectedConditions gebruiken. Bijvoorbeeld zo:

const EC = ExpectedConditions;
element(by.tagName('button')).click();
browser.wait(EC.presenceOf(element(by.id("mijn-tekst")), 500);

Als de tekst niet binnen 500 ms verschijnt, krijg je alsnog een foutmelding. Dus de test checkt nog steeds hetzelfde gedrag als expect(element(by.id("mijn-tekst")).isPresent()).toBe(true).

Mock backend

Als de pagina die je wilt testen AJAX calls maakt naar andere resources, kan het handig zijn die resources te mocken in plaats van de live versie op te vragen. Daardoor heb je meer controle over o.a. welke data je terugkrijgt, hoe snel de response binnenkomt, en of de request slaagt of niet (en zo niet welke HTTP status code je terugkrijgt). Met name voor Single Page Apps is dit heel belangrijk.

Er zijn twee manieren waarop je een resource kunt mocken: je kunt tijdens het testen het stukje code dat de AJAX call doet patchen zodat deze de response simuleert; of je kunt de call wel door laten gaan, maar verwijzen naar een URL waarop een mock backend draait. Ik heb een sterke voorkeur voor dat laatste: door een echte call te doen test je meer realistisch het gedrag wat je in productie zult zien.

Een mock backend bouwen is helemaal niet moeilijk. Voor ons project heb ik er een gebouwd met Express (we hebben toch al Node nodig om Protractor te draaien, dus een extra proces er bij is geen grote stap). Een mock resource in Express kan er bijvoorbeeld zo uitzien:

app.get("/backend/facturen", (req, res) => {
  if (req.query["laden-mislukt"] === "true") {
    setTimeout(() => res.sendStatus(500), 500);
    return;
  }
  if (req.query["geen-facturen"] === "true") {
    res.send([]);
    return;
  }

  let facturen = DUMMY_FACTUREN;
  if (req.query["alle"] !== "true") {
    facturen = facturen.filter(f => f.saldo > 0);
  }

  setTimeout(() => res.send(facturen), 200);
});

Hier zie je dat er een aantal scenario’s getest kan worden door verschillende GET parameters met de call mee te geven. Standaard krijg je na 200 ms een reponse met dummy data, maar als je bijvoorbeeld ?laden-mislukt=true meegeeft, krijg je na 500 ms een 500 Internal Server Error. Dit is een heel flexibele constructie die het makkelijk maakt allerlei uitzonderlijke situaties uit te proberen en te kijken hoe jouw UI hier mee omgaat.

PO laag

In de Protractor documentatie wordt je aangespoord een tussenlaag te bouwen tussen je tests en de structuur van je UI: een “Page Object“. In de documentatie staat dit voorbeeld:

var AngularHomepage = function() {
  var nameInput = element(by.model('yourName'));
  var greeting = element(by.binding('yourName'));

  this.get = function() {
    browser.get('http://www.angularjs.org');
  };

  this.setName = function(name) {
    nameInput.sendKeys(name);
  };

  this.getGreeting = function() {
    return greeting.getText();
  };
};

Even los gezien van de ouderwetse ES5 syntax is dit een prima begin. Maar mijn collega’s en ik hebben iets gemerkt: als je bij een uitvoerige test suite alle PO code zo indeelt, krijg je al snel een ongeorganiseerde massa methodes op elk object. Na wat experimenteren samen met mijn collega’s (shoutout naar Mario) hebben wij uiteindelijk gekozen voor een stijl met geneste anonieme objecten:

class Adressen {

  get titel() {
    return element(by.marker("titel")).getText();
  }

  get uitleg() {
    return element(by.marker("uitleg")).getText();
  }

  get overzicht() {
    const overzicht = element(by.marker("adressen-overzicht"));
    const adressen = overzicht.all(by.marker("adres"));
    return {
      get zichtbaar() {
        return overzicht.isPresent();
      },

      get aantalAdressen() {
        return adressen.count();
      },

      getAdres(nummer: number) {
        const adres = adressen.get(nummer - 1);
        return {
          get naam() {
            return adres.element(by.marker("naam")).getText();
          },
        };
      },
    };
  }
}

class AdressenTest {

  adressen = new Adressen();

  laad({geenAdressen = false} = {}) {
    return browser.get(`/adressentest?geenAdressen=${geenAdressen}`);
  }
}

Met zo’n PO interface zien je tests er bijvoorbeeld zo uit:

describe('Het Adressen component', () => {

  const test = new AdressenTest();
  const overzicht = test.adressen.overzicht;
  beforeAll(() => test.laad());

  it('neemt de titel en uitleg over uit het CMS', async () => {
    await expect(test.adressen.titel).toBe("Uw adressen");
    await expect(test.adressen.uitleg).toBe("Beheer uw adressen.");
  });

  it('haalt de adressen op geeft deze weer', async () => {
    await expect(overzicht.zichtbaar).toBe(true);
    await expect(overzicht.aantalAdressen).toBeGreaterThan(0);
    await expect(overzicht.getAdres(1).naam).toBe("Adres 1");
  });

  describe("als er geen adressen zijn", () => {

    beforeAll(() => test.laad({geenAdressen: true}));

    it('toont het overzicht niet', async () => {
      await expect(overzicht.zichtbaar).toBe(false);
    });
  });
});

Heerlijk leesbaar toch? Ik heb voor het gemak de twee classes onder elkaar gezet, maar in onze tests zijn deze gescheiden in aparte files: adressen.po.ts voor de AdressenTest en adressen.lib.ts voor de Adressen class. Zoals je aan de extensies ziet gebruiken we TypeScript. Dat maakt het nog makkelijker te checken of je de PO laag correct aanroept in je tests.

Natuurlijk hoef jij je abstractielaag niet precies hetzelfde te organiseren. Neem voor je met schrijven begint eersty even de tijd om te bedenken hoe jij de abstractielaag het liefst indeelt. Ik heb namelijk in het begin PO code geschreven zoals in het voorbeeld uit de Protractor documentatie. Het heeft behoorlijk wat tijd gekost om uiteindelijk alle PO code te refactoren naar wat we nu hebben.

Conclusie

The moral of this story is simple: Test code is just as important as production code. It is not a second class citizen. It requires thought, design and care. It must be kept as clean as production code.

– Clean Code, Robert C. Martin

Samenvattend is dit de les die ik geleerd heb door het schrijven van een uitgebreide Protractor suite: je Protractor code is een codebase op zich, en een volwaardig onderdeel van je project. Neem dus dezelfde moeite om deze netjes in te delen en onderhoudbaar te maken als je voor je productiecode zou nemen. Vooral in het begin zag ik de tests nog als minder belangrijke “throw-away” code. Tegen de tijd dat ik begon te begrijpen hoe waardevol deze test code is moest ik behoorlijk wat technical debt wegwerken. De volgende keer zal ik dus slimmer te werk gaan en direct aan het begin bedenken hoe ik mijn Protractor code georganiseerd wil hebben. En ik raad jou aan hetzelfde te doen. 😉

Over de auteur

Dit artikel is geschreven door onze ontwikkelaar Romke van der Meulen. Als je meer van dit soort artikelen wilt lezen kun je zijn (Engelstalige) blog bekijken.

 

Gerelateerde artikelen