Test end-to-end con Nightwatch.js

C’è una categoria di test che spesso sono snobbati dagli sviluppatori: i test end-to-end (E2E). Per test end-to-end si intende un test che copre l’intero flow dell’applicazione integrando tutti gli elementi di cui è composta. In questo post ci occuperemo in particolare di test E2E di un’applicazione web, in questo caso questi test sono detti anche UI o funzionali. In pratica simuleremo i click e le altre interazioni dell’utente con un browser reale che navigherà l’applicazione reale. Mike Cohn nella sua famosa piramide del testing li posiziona in cima.

Testing pyramid

Photo by https://www.scrumalliance.org/

Il fatto che siano in cima alla piramide sta a significare che il loro numero dovrebbe essere inferiore a quello dei test unitari o di integrazione. Questo perché i test E2E sono molto costosi a livello di architettura da mettere in piedi, come tempi di esecuzione e come tempi di scrittura. Questo però non significa che non debbano essere presenti all’interno delle vostre applicazioni. Soprattutto se lavorate in grossi progetti frontend ci sono alcuni tipi di bug che sono difficilmente replicabili se non con questa tipologia di test. Il software che useremo per creare questi test è Nightwatch.js. Come altri strumenti simili è un wrapper Node.js di Selenium il famoso tool di browser automation scritto in Java.

Il logo di Nightwatch.js

Il logo di Nightwatch.js

Installazione e Configurazione

Nightwatch.js è un pacchetto npm, quindi per installarlo va aggiunto al package.json. Altra dipendenza utile è selenium-standalone, pacchetto ci permette di installare agevolmente il jar di selenium ed i WebDriver per i vari browser. Ecco il package.json completo.

{
  "name": "nightwatchjs-example",
  "version": "1.0.0",
  "repository": "git@github.com:coding-jam/nightwatchjs-example.git",
  "author": "Francesco Strazzullo <francesco.strazzullo86@gmail.com>",
  "license": "MIT",
  "devDependencies": {
    "nightwatch": "0.9.15",
    "selenium-standalone": "6.4.1"
  },
  "scripts": {
    "test": "nightwatch",
    "postinstall": "selenium-standalone install"
  }
}

Nello script di postinstall usiamo proprio selenium-standalone per installare selenium in automatico ogni volta che viene lanciato il comando npm install. Utilizzeremo invece l’alias npm test per lanciare i test di Nightwatch.

La configurazione di Nightwatch va inserita nel file nightwatch.conf.js che dovrà essere presente nella root del nostro progetto. Questo è un file di configurazione di esempio.

const selenium = {
  start_process: true,
  server_path: './node_modules/selenium-standalone/.selenium/selenium-server/3.4.0-server.jar',
  cli_args: {
    'webdriver.chrome.driver': './node_modules/selenium-standalone/.selenium/chromedriver/2.29-x64-chromedriver'
  }
}

const test_settings = {
  default: {
    desiredCapabilities: {
      browserName: 'chrome'
    }
  }
}

module.exports = {
  src_folders: 'tests',
  selenium,
  test_settings
}

Questa configurazione è divisa in due macroblocchi. Il primo è la configurazione di selenium stesso. In questa sezione sono presenti i path di selenium e del webdriver di Chrome. L’altro invece riguarda gli enviroment di testing. Nel nostro caso abbiamo solo quello di default dove indichiamo che il browser che vogliamo utilizzare durante i test è Chrome.

Scriviamo dei test

In questo primo test vedremo come testare la navigazione di un sito web. Ovviamente come sito d’esempio prenderemo proprio il nostro blog.

module.exports = {
  'Coding Jam - Navigation': browser => {
    browser
      .url('http://codingjam.it/')
      .waitForElementVisible('.entry-title a', 5000)
      .getAttribute('.entry-title a', 'href', (result) => {
        browser.click('.entry-title a')
        .pause(1000)
        .assert.urlEquals(result.value)
        .end()
      })
  }
}

Durante l’esecuzione di questo test nightwatch cerca il primo link all’interno di un elemento del DOM con la classe entry-title (in pratica i titoli dei post sulla nostra homepage). Una volta individuato l’elemento questo viene cliccato e infine facciamo un’asserzione sul fatto che il nuovo URL sia esattamente identico all’attributo href del link stesso.

Testing Coding Jam!

Testing Coding Jam!

Continuiamo con altri test: questa volta testeremo una single page application. In particolare utilizzeremo con target una delle innumerevoli versioni di TodoMVC: in particolare quella in Polymer. In pratica TodoMVC è una raccolta di semplici applicazioni todo-list tutte identiche tra loro, sviluppate con framework differenti per valutarne peculiarità e differenze. Potete vedere il funzionamento di questa applicazione della gif qui sotto.

TodoMVC

TodoMVC

Il prossimo test è sull’aggiunta di un todo. Controlliamo che all’inizio la lista dei todo sia vuota. Dopodiché, dopo aver inserito del testo nella casella di input e simulato la pressione del tasto invio, verifichiamo che un elemento contenente il testo inserito sia effettivamente nella lista.

module.exports = {
  'TodoMVC - Add': browser => {
    const TODO_ITEM_SELECTOR = 'ul[id="todo-list"] li'
    const TODO_TEXT = 'This is my first Todo'

    browser
      .url('http://todomvc.com/examples/polymer/index.html')
      .waitForElementVisible('body', 1000)
      .assert.elementNotPresent(TODO_ITEM_SELECTOR)
      .setValue('input', TODO_TEXT)
      .keys(browser.Keys.ENTER)
      .assert.elementPresent(TODO_ITEM_SELECTOR)
      .assert.containsText(TODO_ITEM_SELECTOR, TODO_TEXT)
      .end()
  }
}

Questo test è facilmente leggibile, ma immaginate di avere un’applicazione con decine di viste differenti dove per ognuna l’utente può effettuare decine di operazioni differenti. Se volessimo testare un flusso complesso di un’applicazione del genere, il codice dei test diventerebbe immediatamente poco chiaro. Il pattern che di solito utilizzo per risolvere questo problema è quello dei Page Objects. In pratica si crea un oggetto per ognuna delle viste dell’applicazione. Questi oggetti hanno poi tanti metodi pubblici quante sono le operazioni possibili sulla quella vista da parte dell’utente. Questa è una versione non completa della nostra TodoMVC, in quanto sono presenti solo le azioni di aggiunta ed eliminazione di un todo.

class TodoMVC {
  constructor (browser) {
    this.browser = browser
  }

  open () {
    this.browser
        .url('http://todomvc.com/examples/polymer/index.html')
        .waitForElementVisible('body', 1000)

    return this
  }

  addTodo (text) {
    this.browser
    .setValue('input', text)
    .keys(this.browser.Keys.ENTER)

    return this
  }

  deleteTodo (index) {
    const todoItemSelector = `ul[id="todo-list"] li .td-item:nth-of-type(${index + 1})`
    const deleteItemSelector = `.destroy:nth-of-type(${index + 1})`

    this.browser
        .moveToElement(todoItemSelector, 10, 10)
        .waitForElementVisible(deleteItemSelector, 1000)
        .click(deleteItemSelector)
    return this
  }
}

In questo caso il test precedente sull’aggiunta di un todo diventa:

const TODO_ITEM_SELECTOR = 'ul[id="todo-list"] li'
const FIRST_TODO_TEXT = 'This is my first Todo'
const SECOND_TODO_TEXT = 'This is my second Todo'

module.exports = {
  'TodoMVC - Add': browser => {
    const todomvc = new TodoMVC(browser)
    todomvc.open()

    browser.assert.elementNotPresent(TODO_ITEM_SELECTOR)

    todomvc.addTodo(FIRST_TODO_TEXT)

    browser
        .assert.elementPresent(TODO_ITEM_SELECTOR)
        .assert.containsText(TODO_ITEM_SELECTOR, FIRST_TODO_TEXT)
        .end()
  },
}

Come potete vedere la lettura (e quindi la manutenibilità) del test è notevolmente migliorata. La cosa diventa ancora più palese nei prossimi due test che riguardano invece la cancellazione di un todo.

module.exports = {
  'TodoMVC - Delete': browser => {
    const todomvc = new TodoMVC(browser)

    todomvc
        .open()
        .addTodo(FIRST_TODO_TEXT)
        .deleteTodo(0)

    browser
        .assert.elementNotPresent(TODO_ITEM_SELECTOR)
        .end()
  },
  'TodoMVC - Delete More': browser => {
    const todomvc = new TodoMVC(browser)

    todomvc
        .open()
        .addTodo(FIRST_TODO_TEXT)
        .addTodo(SECOND_TODO_TEXT)
        .deleteTodo(0)

    browser
        .assert.elementPresent(TODO_ITEM_SELECTOR)
        .assert.containsText(TODO_ITEM_SELECTOR, SECOND_TODO_TEXT)
        .end()
  }
}

Conclusioni

Scrivere test E2E per applicazioni “reali” non è mai semplice. Un assaggio del tipo di complessità che vi troverete ad affrontare lo potete trovare nel codice della funzione deleteTodo. In questa funzione infatti bisogna simulare lo spostamento del mouse sopra il testo del todo e attendere la fine dell’animazione che fa comparire il pulsante per la cancellazione. Ovviamente questo tipo di codice è davvero fragile e rischia di rompersi con ogni piccola modifica, anche solo di uno stile CSS. Ma come detto in apertura del post alcune funzionalità possono essere testate esclusivamente con questa modalità. Un consiglio è affiancare a questa modalità di test anche del monkey testing. Se siete curiosi e volete sporcarvi le mani potete trovare tutto il codice su Github. Alla prossima.

Francesco Strazzullo

Faccio il Front-end engineer per extrategy dove mi occupo di applicazioni Angular, React e applicazioni mobile. Da sempre sono appassionato di Architetture software e cerco di applicarle in maniera innovativa allo sviluppo frontend in generale.