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":
Valitaan sopiva selain (esim. Chrome) ja "Create new spec":
Annetaan testille nimeksi note_app.cy.js ja sijoitetaan se ehdotettuun hakemistoon cypress/e2e:
Voisimme tehdä testejä Cypressin kautta, mutta käytetään kuitenkin VS Codea testien editointiin:
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ä:
Testin suoritus näyttää, miten sovellus käyttäytyy testin edetessä:
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
Poistetaan virheeseen johtanut testi koodista.
Testeissä käytetty muuttuja cy aiheuttaa ikävän ESlint-virheen
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
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:
Klikkaamalla seuraavaa riviä .contains('make important'), nähdään että löydetään nimenomaan second note:a vastaava tärkeyden muutoksen tekevä nappi:
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:
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:
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:
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
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.