d

End to end -testaus

Olemme tehneet backendille sitä apin tasolla kokonaisuutena testaavia integraatiotestejä ja frontendille yksittäisiä komponentteja testaavia yksikkötestejä.

Katsotaan nyt erästä tapaa tehdä järjestelmää kokonaisuutena tutkivia End to End (E2E) -testejä.

Web-sovellusten E2E-testaus tapahtuu käyttäen selainta jonkin kirjaston avulla. Ratkaisuja on tarjolla useita, esimerkiksi Selenium, joka mahdollistaa testien automatisoinnin lähes millä tahansa selaimella. Toinen vaihtoehto on käyttää ns. headless browseria eli selainta, jolla ei ole ollenkaan graafista käyttöliittymää. Esim. Chromea on mahdollista suorittaa Headless-moodissa.

E2E testit ovat potentiaalisesti kaikkein hyödyllisin testikategoria, sillä ne tutkivat järjestelmää saman rajapinnan kautta kuin todelliset käyttäjät.

E2E-testeihin liittyy myös ikäviä puolia. Niiden konfigurointi on haastavampaa kuin yksikkö- ja integraatiotestien. E2E-testit ovat tyypillisesti myös melko hitaita ja isommassa ohjelmistossa niiden suoritusaika voi helposti nousta minuutteihin, tai jopa tunteihin. Tämä on ikävää sovelluskehityksen kannalta, sillä sovellusta koodatessa on erittäin hyödyllistä pystyä suorittamaan testejä mahdollisimman usein koodin regressioiden varalta.

Ongelmana on usein myös se, että käyttöliittymän kautta tehtävät testit saattavat olla epäluotettavia eli englanniksi flaky, osa testeistä menee välillä läpi ja välillä ei, vaikka koodissa ei muuttuisi mikään.

Cypress

Cypress-niminen E2E-testaukseen soveltuva kirjasto on kasvattanut nopeasti suosiotaan viimeisen reilun vuoden aikana. Cypress on poikkeuksellisen helppokäyttöinen, kaikenlaisen säätämisen ja tunkkaamisen määrä esim. Seleniumin käyttöön verrattuna on lähes olematon. Cypressin toimintaperiaate poikkeaa radikaalisti useimmista E2E-testaukseen sopivista kirjastoista, sillä Cypress-testit ajetaan kokonaisuudessaan selaimen sisällä. Muissa lähestymistavoissa testit suoritetaan Node-prosessissa, joka on yhteydessä selaimeen ohjelmointirajapintojen kautta.

Tehdään tämän osan lopuksi muutamia end to end -testejä muistiinpanosovellukselle.

Aloitetaan asentamalla Cypress frontendin kehitysaikaiseksi riippuvuudeksi

npm install --save-dev cypress

ja määritellään npm-skripti käynnistämistä varten.

{
  // ...
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "eslint": "eslint .",
    "cypress:open": "cypress open"  },
  // ...
}

Toisin kuin esim. frontendin yksikkötestit, Cypress-testit voidaan sijoittaa joko frontendin tai backendin repositorioon, tai vaikka kokonaan omaan repositorioonsa.

Cypress-testit olettavat että testattava järjestelmä on käynnissä kun testit suoritetaan, eli toisin kuin esim. backendin integraatiotestit, Cypress-testit eivät käynnistä testattavaa järjestelmää testauksen yhteydessä.

Tehdään backendille npm-skripti, jonka avulla se saadaan käynnistettyä testausmoodissa, eli siten, että NODE_ENV saa arvon test.

{
  // ...
  "scripts": {
    "start": "NODE_ENV=production node index.js",
    "dev": "NODE_ENV=development nodemon index.js",
    "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend",
    "deploy": "fly deploy",
    "deploy:full": "npm run build:ui && npm run deploy",
    "logs:prod": "fly logs",
    "lint": "eslint .",
    "test": "jest --verbose --runInBand",
    "start:test": "NODE_ENV=test node index.js"  },
  // ...
}

Kun backend ja frontend ovat käynnissä, voidaan käynnistää Cypress komennolla

npm run cypress:open

Cypress kysyy minkä tyyppisiä testejä haluamme tehdä. Valitaan "E2E Testing":

fullstack content

Valitaan sopiva selain (esim. Chrome) ja "Create new spec":

fullstack content

Annetaan testille nimeksi note_app.cy.js ja sijoitetaan se ehdotettuun hakemistoon cypress/e2e:

fullstack content

Voisimme tehdä testejä Cypressin kautta, mutta käytetään kuitenkin VS Codea testien editointiin:

fullstack content

Suljetaan Cypressin testin editointinäkymä.

Muutetaan testin sisätö seuraavanlaiseksi

describe('Note ', function() {
  it('front page can be opened', function() {
    cy.visit('http://localhost:3000')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2023')
  })
})

Testin suoritus käynnistetään kilkkaamalla testin nimeä Cypressin näkymästä:

fullstack content

Testin suoritus näyttää, miten sovellus käyttäytyy testin edetessä:

fullstack content

Testi näyttää rakenteeltaan melko tutulta. describe-lohkoja käytetään samaan tapaan kuin Jestissä ryhmittelemään yksittäisiä testitapauksia, jotka on määritelty it-metodin avulla. Nämä osat Cypress on lainannut sisäisesti käyttämältään Mocha-testikirjastolta.

cy.visit ja cy.contains taas ovat Cypressin komentoja, joiden merkitys on aika ilmeinen. cy.visit avaa testin käyttämään selaimeen parametrina määritellyn osoitteen ja cy.contains etsii sivun sisältä parametrina annetun tekstin.

Olisimme voineet määritellä testin myös käyttäen nuolifunktioita

describe('Note app', () => {  it('front page can be opened', () => {    cy.visit('http://localhost:3000')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2023')
  })
})

Mochan dokumentaatio kuitenkin suosittelee että nuolifunktioita ei käytetä, ne saattavat aiheuttaa ongelmia joissain tilanteissa.

HUOM: tässä materiaalissa suoritetaan Cypress-testejä pääasiassa graafisen test runnerin kautta. Testit on luonnollisesti mahdollista suorittaa myös komentoriviltä, komennolla cypress run, joka kannattaa halutessa lisätä npm-skriptiksi.

Jos komento cy.contains ei löydä sivulta etsimäänsä tekstiä, testi ei mene läpi. Eli jos laajennamme testiä seuraavasti

describe('Note app', function() {
  it('front page can be opened',  function() {
    cy.visit('http://localhost:3000')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2023')
  })

  it('front page contains random text', function() {    cy.visit('http://localhost:3000')    cy.contains('wtf is this app?')  })})

havaitsee Cypress ongelman

fullstack content

Poistetaan virheeseen johtanut testi koodista.

Testeissä käytetty muuttuja cy aiheuttaa ikävän ESlint-virheen

fullstack content

Siitä päästään eroon asentamalla eslint-plugin-cypress kehitysaikaiseksi riippuvuudeksi

npm install eslint-plugin-cypress --save-dev

ja laajentamalla tiedostossa .eslintrc.js olevaa konfiguraatiota seuraavasti:

module.exports = {
    "env": {
        "browser": true,
        "es6": true,
        "jest/globals": true,
        "cypress/globals": true    },
    "extends": [ 
      // ...
    ],
    "parserOptions": {
      // ...
    },
    "plugins": [
        "react", "jest", "cypress"    ],
    "rules": {
      // ...
    }
}

Lomakkeelle kirjoittaminen

Laajennetaan testejä siten, että testi yrittää kirjautua sovellukseen. Oletetaan että backendin tietokantaan on tallennettu käyttäjä, jonka käyttäjätunnus on mluukkai ja salasana salainen.

Aloitetaan kirjautumislomakkeen avaamisella.

describe('Note app',  function() {
  // ...

  it('login form can be opened', function() {
    cy.visit('http://localhost:3000')
    cy.contains('log in').click()
  })
})

Testi hakee ensin napin sen tekstin perusteella ja klikkaa nappia komennolla cy.click.

Koska molemmat testit aloittavat samalla tavalla, eli avaamalla sivun http://localhost:3000, kannattaa yhteinen osa eristää ennen jokaista testiä suoritettavaan beforeEach-lohkoon:

describe('Note app', function() {
  beforeEach(function() {    cy.visit('http://localhost:3000')  })
  it('front page can be opened', function() {
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2023')
  })

  it('login form can be opened', function() {
    cy.contains('log in').click()
  })
})

Ilmoittautumislomake sisältää kaksi input-kenttää, joihin testin tulisi kirjoittaa.

Komento cy.get mahdollistaa elementtien etsimisen CSS-selektorien avulla.

Voimme hakea lomakkeen ensimmäisen ja viimeisen input-kentän ja kirjoittaa niihin komennolla cy.type seuraavasti:

it('user can login', function () {
  cy.contains('log in').click()
  cy.get('input:first').type('mluukkai')
  cy.get('input:last').type('salainen')
})  

Testi toimii mutta on kuitenkin sikäli ongelmallinen, että jos sovellukseen tulee jossain vaiheessa lisää input-kenttiä, testi saattaa hajota, sillä se luottaa tarvitsemiensa kenttien olevan sivulla ensimmäisenä ja viimeisenä.

Parempi ratkaisu on määritellä kentille yksilöivät id-attribuutit ja hakea kentät testeissä niiden perusteella. Eli laajennetaan kirjautumislomaketta seuraavasti

const LoginForm = ({ ... }) => {
  return (
    <div>
      <h2>Login</h2>
      <form onSubmit={handleSubmit}>
        <div>
          username
          <input
            id='username'            value={username}
            onChange={handleUsernameChange}
          />
        </div>
        <div>
          password
          <input
            id='password'            type="password"
            value={password}
            onChange={handlePasswordChange}
          />
        </div>
        <button id="login-button" type="submit">          login
        </button>
      </form>
    </div>
  )
}

Myös lomakkeen napille on lisätty id, jonka perusteella se voidaan hakea testissä.

Testi muuttuu muotoon

describe('Note app',  function() {
  // ..
  it('user can log in', function() {
    cy.contains('log in').click()
    cy.get('#username').type('mluukkai')    cy.get('#password').type('salainen')    cy.get('#login-button').click()
    cy.contains('Matti Luukkainen logged in')  })
})

Viimeinen rivi varmistaa, että kirjautuminen on onnistunut.

Huomaa, että CSS:n id-selektori on risuaita, eli jos koodista etsitään elementtiä, jolla on id username on sitä vastaava CSS-selektori #username.

Huomaa, että testin läpimeno tässä vaiheessa edellyttää, että backendin ympäristön test tietokannassa on käyttäjä, jonka username on mluukkai ja salasana salainen. Luo käyttäjä tarvittaessa!

Muistiinpanojen luomisen testaus

Luodaan seuraavaksi testi, joka lisää sovellukseen uuden muistiinpanon:

describe('Note app', function() {
  // ..
  describe('when logged in', function() {    beforeEach(function() {      cy.contains('log in').click()      cy.get('#username').type('mluukkai')      cy.get('#password').type('salainen')      cy.get('#login-button').click()    })
    it('a new note can be created', function() {      cy.contains('new note').click()      cy.get('input').type('a note created by cypress')      cy.contains('save').click()      cy.contains('a note created by cypress')    })  })})

Testi on määritelty omana describe-lohkonaan. Muistiinpanon luominen edellyttää että käyttäjä on kirjaantuneena, ja kirjautuminen hoidetaan beforeEach-lohkossa.

Testi luottaa siihen, että uutta muistiinpanoa luotaessa sivulla on ainoastaan yksi input-kenttä, eli se hakee kentän seuraavasti

cy.get('input')

Jos kenttiä olisi useampia, testi hajoaisi

fullstack content

Tämän takia olisi jälleen parempi lisätä lomakkeen kentälle id ja hakea kenttä testissä id:n perusteella.

Testien rakenne näyttää seuraavalta:

describe('Note app', function() {
  // ...

  it('user can log in', function() {
    cy.contains('log in').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()

    cy.contains('Matti Luukkainen logged in')
  })

  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('log in').click()
      cy.get('#username').type('mluukkai')
      cy.get('#password').type('salainen')
      cy.get('#login-button').click()
    })

    it('a new note can be created', function() {
      // ...
    })
  })
})

Cypress suorittaa testit siinä järjestyksessä, missä ne ovat testikoodissa. Eli ensin suoritetaan testi user can log in, missä käyttäjä kirjautuu sovellukseen, ja tämän jälkeen suoritetaan testi a new note can be created, jonka beforeEach-lohkossa myös suoritetaan kirjautuminen. Miksi näin tehdään, eikö käyttäjä jo ole kirjaantuneena aiemman testin ansiosta? Ei, sillä jokaisen testin suoritus alkaa selaimen kannalta "nollatilanteesta", kaikki edellisten testien selaimen tilaan tekemät muutokset nollaantuvat.

Tietokannan tilan kontrollointi

Jos testatessa on tarvetta muokata palvelimen tietokantaa, muuttuu tilanne heti haastavammaksi. Ideaalitilanteessa testauksen tulee aina lähteä liikkeelle palvelimen tietokannan suhteen samasta alkutilanteesta, jotta testeistä saadaan luotettavia ja helposti toistettavia.

Kuten yksikkö- integraatiotesteissä, on myös E2E-testeissä paras ratkaisu nollata tietokanta ja mahdollisesti alustaa se sopivasti aina ennen testien suorittamista. E2E-testauksessa lisähaasteen tuo se, että testeistä ei ole mahdollista päästä suoraan käsiksi tietokantaan.

Ratkaistaan ongelma luomalla backendiin testejä varten API-endpoint, jonka avulla testit voivat tarvittaessa nollata kannan. Tehdään testejä varten oma router

const router = require('express').Router()
const Note = require('../models/note')
const User = require('../models/user')

router.post('/reset', async (request, response) => {
  await Note.deleteMany({})
  await User.deleteMany({})

  response.status(204).end()
})

module.exports = router

ja lisätään se backendiin ainoastaan jos sovellusta suoritetaan test-moodissa:

// ...

app.use('/api/login', loginRouter)
app.use('/api/users', usersRouter)
app.use('/api/notes', notesRouter)

if (process.env.NODE_ENV === 'test') {  const testingRouter = require('./controllers/testing')  app.use('/api/testing', testingRouter)}
app.use(middleware.unknownEndpoint)
app.use(middleware.errorHandler)

module.exports = app

eli lisäyksen jälkeen HTTP POST -operaatio backendin endpointiin /api/testing/reset tyhjentää tietokannan.

Backendin testejä varten muokattu koodi on kokonaisuudessaan GitHubissa, branchissä part5-1.

Muutetaan nyt testien beforeEach-alustuslohkoa siten, että se nollaa palvelimen tietokannan aina ennen testien suorittamista.

Tällä hetkellä sovelluksen käyttöliittymän kautta ei ole mahdollista luoda käyttäjiä, luodaankin testien alustuksessa testikäyttäjä suoraan backendiin.

describe('Note app', function() {
   beforeEach(function() {
    cy.request('POST', 'http://localhost:3001/api/testing/reset')    const user = {      name: 'Matti Luukkainen',      username: 'mluukkai',      password: 'salainen'    }    cy.request('POST', 'http://localhost:3001/api/users/', user)     cy.visit('http://localhost:3000')
  })
  
  it('front page can be opened', function() {
    // ...
  })

  it('user can login', function() {
    // ...
  })

  describe('when logged in', function() {
    // ...
  })
})

Testi tekee alustuksen aikana HTTP-pyyntöjä backendiin komennolla cy.request.

Toisin kuin aiemmin, nyt testaus alkaa nyt myös backendin suhteen aina hallitusti samasta tilanteesta, eli tietokannassa on yksi käyttäjä ja ei yhtään muistiinpanoa.

Tehdään vielä testi, joka tarkastaa että muistiinpanojen tärkeyttä voi muuttaa. Muutimme sovellusta hieman aiemmin jo siten, että important saa aluksi arvon true:

const NoteForm = ({ createNote }) => {
  // ...

  const addNote = (event) => {
    event.preventDefault()
    createNote({
      content: newNote,
      important: true    })

    setNewNote('')
  }
  // ...
} 

On useita eri tapoja testata asia. Seuraavassa etsitään ensin muistiinpano ja klikataan sen nappia make important. Tämän jälkeen tarkistetaan että muistiinpano sisältää napin make not important.

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    // ...

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.contains('new note').click()
        cy.get('input').type('another note cypress')
        cy.contains('save').click()
      })

      it('it can be made important', function () {
        cy.contains('another note cypress')
          .contains('make not important')
          .click()

        cy.contains('another note cypress')
          .contains('make important')
      })
    })
  })
})

Ensimmäinen komento etsii ensin komponentin, missä on teksti another note cypress ja sen sisältä painikkeen make not important ja klikkaa sitä.

Toinen komento varmistaa, että saman napin teksti on vaihtunut muotoon make important.

Testit ja frontendin tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part5-9.

Epäonnistuneen kirjautumisen testi

Tehdään nyt testi joka varmistaa, että kirjautumisyritys epäonnistuu jos salasana on väärä.

Cypress suorittaa oletusarvoisesti aina kaikki testit, ja testien määrän kasvaessa se alkaa olla aikaavievää. Uutta testiä kehitellessä tai rikkinäistä testiä debugatessa voidaan määritellä testi komennon it sijaan komennolla it.only, jolloin Cypress suorittaa ainoastaan sen testin. Kun testi on valmiina, voidaan only poistaa.

Testin ensimmäinen versio näyttää seuraavalta:

describe('Note app', function() {
  // ...

  it.only('login fails with wrong password', function() {
    cy.contains('log in').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('wrong')
    cy.get('#login-button').click()

    cy.contains('wrong credentials')
  })

  // ...
)}

Testi siis varmistaa komennon cy.contains avulla, että sovellus tulostaa virheilmoituksen.

Sovellus renderöi virheilmoituksen CSS-luokan error sisältävään elementtiin:

const Notification = ({ message }) => {
  if (message === null) {
    return null
  }

  return (
    <div className="error">      {message}
    </div>
  )
}

Voisimmekin tarkentaa testiä varmistamaan, että virheilmoitus tulostuu nimenomaan oikeaan paikkaan, eli CSS-luokan error sisältävään elementtiin:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').contains('wrong credentials')})

Eli ensin etsitään komennolla cy.get CSS-luokan error sisältävä komponentti ja sen jälkeen varmistetaan että virheilmoitus löytyy sen sisältä. Huomaa, että luokan CSS-selektori alkaa pisteellä, eli luokan error selektori on .error.

Voisimme tehdä saman myös käyttäen should-syntaksia:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').should('contain', 'wrong credentials')})

Shouldin käyttö on jonkin verran "hankalampaa" kuin komennon contains, mutta se mahdollistaa huomattavasti monipuolisemmat testit kuin pelkän tekstisisällön perusteella toimiva contains.

Lista yleisimmistä shouldin kanssa käytettävistä assertioista on täällä.

Voimme esim. varmistaa, että virheilmoituksen väri on punainen, ja että sen ympärillä on border:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').should('contain', 'wrong credentials') 
  cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)')
  cy.get('.error').should('have.css', 'border-style', 'solid')
})

Värit on määriteltävä Cypressille rgb-koodeina.

Koska kaikki tarkastukset kohdistuvat samaan komennolla cy.get haettuun elementtiin, ne voidaan ketjuttaa komennon and avulla:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error')
    .should('contain', 'wrong credentials')
    .and('have.css', 'color', 'rgb(255, 0, 0)')
    .and('have.css', 'border-style', 'solid')
})

Viimeistellään testi vielä siten, että se varmistaa myös, että sovellus ei renderöi onnistuneesta kirjautumista kuvaavaa tekstiä 'Matti Luukkainen logged in':

it('login fails with wrong password', function() {
  cy.contains('log in').click()
  cy.get('#username').type('mluukkai')
  cy.get('#password').type('wrong')
  cy.get('#login-button').click()

  cy.get('.error')
    .should('contain', 'wrong credentials')
    .and('have.css', 'color', 'rgb(255, 0, 0)')
    .and('have.css', 'border-style', 'solid')

  cy.get('html').should('not.contain', 'Matti Luukkainen logged in')})

Komentoa should käytetään useimmiten ketjutettuna komennon get (tai muun vastaavan ketjutettavissa olevan komennon) perään. Testissä käytetty cy.get('html') tarkoittaa käytännössä koko sovelluksen näkyvillä olevaa sisältöä.

Saman asian olisi myös voinut tarkastaa ketjuttamalla komennon contains perään komento should hieman toisenlaisella parametrilla:

cy.contains('Matti Luukkainen logged in').should('not.exist')

Operaatioiden tekeminen käyttöliittymän "ohi"

Sovelluksemme testit näyttävät tällä hetkellä seuraavalta:

describe('Note app', function() {
  it('user can login', function() {
    cy.contains('log in').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()

    cy.contains('Matti Luukkainen logged in')
  })

  it('login fails with wrong password', function() {
    // ...
  })

  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('log in').click()
      cy.get('#username').type('mluukkai')
      cy.get('#password').type('salainen')
      cy.get('#login-button').click()
    })

    it('a new note can be created', function() {
      // ... 
    })
   
  })
})

Ensin siis testataan kirjautumistoimintoa. Tämän jälkeen omassa describe-lohkossa on joukko testejä, jotka olettavat että käyttäjä on kirjaantuneena, kirjaantuminen hoidetaan alustuksen tekevän beforeEach-lohkon sisällä.

Kuten aiemmin jo todettiin, jokainen testi suoritetaan alkutilasta, eli vaikka testi on koodissa alempana, se ei aloita samasta tilasta mihin ylempänä koodissa olevat testit ovat jääneet!

Cypressin dokumentaatio neuvoo meitä seuraavasti: Fully test the login flow – but only once!. Eli sen sijaan että tekisimme beforeEach-lohkossa kirjaantumisen lomaketta käyttäen, suosittelee Cypress että kirjaantuminen tehdään UI:n ohi, tekemällä suoraan backendiin kirjaantumista vastaava HTTP-operaatio. Syynä tälle on se, että suoraan backendiin tehtynä kirjautuminen on huomattavasti nopeampi kuin lomakkeen täyttämällä.

Tilanteemme on hieman monimutkaisempi kuin Cypressin dokumentaation esimerkissä, sillä kirjautumisen yhteydessä sovelluksemme tallettaa kirjautuneen käyttäjän tiedot localStorageen. Sekin toki onnistuu. Koodi on seuraavassa

describe('when logged in', function() {
  beforeEach(function() {
    cy.request('POST', 'http://localhost:3001/api/login', {      username: 'mluukkai', password: 'salainen'    }).then(response => {      localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body))      cy.visit('http://localhost:3000')    })  })

  it('a new note can be created', function() {
    // ...
  })

  // ...
})

Komennon cy.request tulokseen päästään käsiksi then-metodin avulla sillä sisäiseltä toteutukseltaan cy.request kuten muutkin Cypressin komennot ovat eräänlaisia promiseja. Käsittelijäfunktio tallettaa kirjautuneen käyttäjän tiedot localStorageen ja lataa sivun uudelleen. Tämän jälkeen käyttäjä on kirjautuneena sovellukseen samalla tavalla kuin jos kirjautuminen olisi tapahtunut kirjautumislomakkeen täyttämällä.

Jos ja kun sovellukselle kirjoitetaan lisää testejä, joudutaan kirjautumisen hoitavaa koodia soveltamaan useassa paikassa. Koodi kannattaakin eristää itse määritellyksi komennoksi.

Komennot määritellään tiedostoon cypress/support/commands.js. Kirjautumisen tekevä komento näyttää seuraavalta:

Cypress.Commands.add('login', ({ username, password }) => {
  cy.request('POST', 'http://localhost:3001/api/login', {
    username, password
  }).then(({ body }) => {
    localStorage.setItem('loggedNoteappUser', JSON.stringify(body))
    cy.visit('http://localhost:3000')
  })
})

Komennon käyttö on helppoa, testi yksinkertaistuu ja selkeytyy:

describe('when logged in', function() {
  beforeEach(function() {
    cy.login({ username: 'mluukkai', password: 'salainen' })  })

  it('a new note can be created', function() {
    // ...
  })

  // ...
})

Sama koskee oikeastaan myös uuden muistiinpanon luomista. Sitä varten on olemassa testi, joka luo muistiinpanon lomakkeen avulla. Myös muistiinpanon tärkeyden muuttamista testaavan testin beforeEach-alustuslohkossa luodaan muistiinpano lomakkeen avulla:

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    it('a new note can be created', function() {
      cy.contains('new note').click()
      cy.get('input').type('a note created by cypress')
      cy.contains('save').click()

      cy.contains('a note created by cypress')
    })

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.contains('new note').click()
        cy.get('input').type('another note cypress')
        cy.contains('save').click()
      })

      it('it can be made important', function () {
        // ...
      })
    })
  })
})

Eristetään myös muistiinpanon lisääminen omaksi komennoksi, joka tekee lisäämisen suoraan HTTP POST:lla:

Cypress.Commands.add('createNote', ({ content, important }) => {
  cy.request({
    url: 'http://localhost:3001/api/notes',
    method: 'POST',
    body: { content, important },
    headers: {
      'Authorization': `Bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}`
    }
  })

  cy.visit('http://localhost:3000')
})

Komennon suoritus edellyttää, että käyttäjä on kirjaantuneena sovelluksessa ja käyttäjän tiedot talletettuna sovelluksen localStorageen.

Testin alustuslohko yksinkertaistuu seuraavasti:

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    it('a new note can be created', function() {
      // ...
    })

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.createNote({          content: 'another note cypress',          important: true        })      })

      it('it can be made important', function () {
        // ...
      })
    })
  })
})

Testeissämme on vielä eräs ikävä piirre. Sovelluksen osoite http:localhost:3000 on kovakoodattuna moneen kohtaan.

Määritellään sovellukselle baseUrl Cypressin valmiiksi generoimaan konfiguraatiotiedostoon cypress.config.js:

const { defineConfig } = require("cypress")

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
    },
    baseUrl: 'http://localhost:3000'  },
})

Kaikki testeissä olevat sovelluksen osoitetta käyttävät komennot

cy.visit('http://localhost:3000' )

voidaan muuttaa muotoon

cy.visit('')

Testeihin jää edelleen backendin kovakoodattu osoite http://localhost:3001. Muut testien käyttämät osoitteet Cypressin dokumentaatio kehoittaa määrittelemään ympäristömuutujina.

Laajennetaan konfiguraatiotiedostoa cypress.config.js seuraavasti:

const { defineConfig } = require("cypress")

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
    },
    baseUrl: 'http://localhost:3000',
    BACKEND: 'http://localhost:3001/api'  },
})

Korvataan testeistä kaikki backendin osoitteet seuraavaan tapaan

describe('Note ', function() {
  beforeEach(function() {
    cy.visit('')
    cy.request('POST', `${Cypress.env('EXTERNAL_API')}/testing/reset`)    const user = {
      name: 'Matti Luukkainen',
      username: 'mluukkai',
      password: 'salainen'
    }
    cy.request('POST', `${Cypress.env('EXTERNAL_API')}/users`, user)  })
  // ...
})

Testit ja frontendin koodi on kokonaisuudessaan GitHubissa, branchissa part5-10.

Muistiinpanon tärkeyden muutos

Tarkastellaan vielä aiemmin tekemäämme testiä, joka varmistaa että muistiinpanon tärkeyttä on mahdollista muuttaa. Muutetaan testin alustuslohkoa siten, että se luo yhden sijaan kolme muistiinpanoa:

describe('when logged in', function() {
  describe('and several notes exist', function () {
    beforeEach(function () {
      cy.createNote({ content: 'first note', important: false })      cy.createNote({ content: 'second note', important: false })      cy.createNote({ content: 'third note', important: false })    })

    it('one of those can be made important', function () {
      cy.contains('second note')
        .contains('make important')
        .click()

      cy.contains('second note')
        .contains('make not important')
    })
  })
})

Miten komento cy.contains tarkalleen ottaen toimii?

Kun klikkaamme komentoa cy.contains('second note') Cypressin test runnerista nähdään, että komento löytää elementin, jonka sisällä on teksti second note:

fullstack content

Klikkaamalla seuraavaa riviä .contains('make important'), nähdään että löydetään nimenomaan second note:a vastaava tärkeyden muutoksen tekevä nappi:

fullstack content

Peräkkäin ketjutettuna toisena oleva contains-komento siis jatkaa hakua ensimmäisen komennon löytämän komponentin sisältä.

Jos emme ketjuttaisi komentoja, eli olisimme kirjoittaneet

cy.contains('second note')
cy.contains('make important').click()

tulos olisi ollut aivan erilainen, toinen rivi painaisi väärän muistiinpanon nappia:

fullstack content

Testejä tehdessä kannattaa siis ehdottomasti varmistaa test runnerista, että testit etsivät niitä elementtejä, joita niiden on tarkoitus tutkia!

Muutetaan komponenttia Note siten, että muistiinpanon teksti renderöitään span-komponentin sisälle

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important' : 'make important'

  return (
    <li className='note'>
      <span>{note.content}</span>      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

Testit hajoavat! Kuten test runner paljastaa, komento cy.contains('second note') palauttaakin nyt ainoastaan tekstin sisältävän komponentin, ja nappi on sen ulkopuolella:

fullstack content

Eräs tapa korjata ongelma on seuraavassa:

it('other of those can be made important', function () {
  cy.contains('second note').parent().find('button').click()
  cy.contains('second note').parent().find('button')
    .should('contain', 'make not important')
})

Ensimmäisellä rivillä etsitään komennon parent tekstin second note sisältävän elementin vanhemman alla oleva nappi ja painetaan sitä. Toinen rivi varmistaa, että napin teksti muuttuu.

Huomaa, että napin etsimiseen käytetään komentoa find. Komento cy.get ei sovellu tähän tilanteeseen, sillä se etsii elementtejä aina koko sivulta ja palauttaisi nyt kaikki sovelluksen viisi nappia.

Testissä on ikävästi copypastea, rivien alku eli napin etsivä koodi on sama. Tälläisissä tilanteissa on mahdollista hyödyntää komentoa as:

it('other of those can be made important', function () {
  cy.contains('second note').parent().find('button').as('theButton')
  cy.get('@theButton').click()
  cy.get('@theButton').should('contain', 'make not important')
})

Nyt ensimmäinen rivi etsii oikean napin, ja tallentaa sen komennon as avulla nimellä theButton. Seuraavat rivit pääsevät nimettyyn elementtiin käsiksi komennolla cy.get('@theButton').

Testien suoritus ja debuggaaminen

Vielä osan lopuksi muutamia huomioita Cypressin toimintaperiaatteesta sekä testien debuggaamisesta.

Cypressissä testien kirjoitusasu antaa vaikutelman, että testit ovat normaalia JavaScript-koodia, ja että voisimme esim. yrittää seuraavaa:

const button = cy.contains('log in')
button.click()
debugger
cy.contains('logout').click()

Näin kirjoitettu koodi ei kuitenkaan toimi. Kun Cypress suorittaa testin, se lisää jokaisen cy-komennon suoritusjonoon. Kun testimetodin koodi on suoritettu loppuun, suorittaa Cypress yksi kerrallaan suoritusjonoon lisätyt cy-komennot.

Cypressin komennot palauttavat aina undefined, eli yllä olevassa koodissa komento button.click() aiheuttaisi virheen ja yritys käynnistää debuggeri ei pysäyttäisi koodia Cypress-komentojen suorituksen välissä, vaan jo ennen kuin yhtään Cypress-komentoa olisi suoritettu.

Cypress-komennot ovat promisen kaltaisia, joten jos niiden palauttamia arvoja halutaan käsitellä, se tulee tehdä komennon then avulla. Esim. seuraava testi tulostaisi sovelluksen kaikkien nappien lukumäärän ja klikkaisi napeista ensimmäistä:

it('then example', function() {
  cy.get('button').then( buttons => {
    console.log('number of buttons', buttons.length)
    cy.wrap(buttons[0]).click()
  })
})

Myös testien suorituksen pysäyttäminen debuggeriin on mahdollista. Debuggeri käynnistyy vain jos Cypress test runnerin developer-konsoli on auki.

Developer-konsoli on monin tavoin hyödyllinen testejä debugatessa. Network-tabilla näkyvät testattavan sovelluksen tekemät HTTP-pyynnöt, ja console-välilehti kertoo testin komentoihin liittyviä tietoja:

fullstack content

Olemme toistaiseksi suorittaneet Cypress-testejä ainoastaan graafisen test runnerin kautta. Testit on luonnollisesti mahdollista suorittaa myös komentoriviltä. Lisätään vielä sovellukselle npm-skripti tätä tarkoitusta varten

  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "eslint": "eslint .",
    "cypress:open": "cypress open",
    "test:e2e": "cypress run"  },

Nyt siis voimme suorittaa Cypress-testit komentoriviltä komennolla npm run test:e2e

fullstack content

Huomaa, että testien suorituksesta tallentuu video hakemistoon cypress/videos/, hakemisto lienee syytä gitignoroida. Videoiden teko on myös mahdollista ottaa pois päältä.

Testien ja frontendin koodin lopullinen versio on kokonaisuudessaan GitHubissa, branchissa part5-11.