Siirry sisältöön

e

Legacy: testaaminen Jestiä käyttäen

Node-sovellusten testaaminen

Olemme laiminlyöneet ikävästi yhtä oleellista ohjelmistokehityksen osa-aluetta, automatisoitua testaamista.

Aloitamme yksikkötestauksesta. Sovelluksemme logiikka on sen verran yksinkertaista, että siinä ei ole juurikaan mielekästä yksikkötestattavaa. Luodaan tiedosto utils/for_testing.js ja määritellään sinne pari yksinkertaista funktiota testattavaksi:

const reverse = (string) => {
  return string
    .split('')
    .reverse()
    .join('')
}

const average = (array) => {
  const reducer = (sum, item) => {
    return sum + item
  }

  return array.reduce(reducer, 0) / array.length
}

module.exports = {
  reverse,
  average,
}

Metodi average käyttää taulukoiden metodia reduce. Jos metodi ei ole vieläkään tuttu, on korkea aika katsoa YouTubesta Functional JavaScript ‑sarjasta ainakin kolme ensimmäistä videoa.

JavaScriptiin on tarjolla runsaasti erilaisia testikirjastoja eli test runnereita. Käytämme tällä kurssilla Facebookin kehittämää ja sisäisesti käyttämää Jest:iä, joka on toiminnaltaan ja syntaksiltaankin hyvin samankaltainen kuin testikirjastojen entinen kuningas Mocha.

Jest on tälle kurssille luonteva valinta, sillä se sopii hyvin backendien testaamiseen, mutta suorastaan loistaa Reactilla tehtyjen frontendien testauksessa.

Huomio Windows-käyttäjille: Jest ei välttämättä toimi, jos projektin hakemistopolulla on hakemisto, jonka nimessä on välilyöntejä.

Koska testejä on tarkoitus suorittaa ainoastaan sovellusta kehitettäessä, asennetaan Jest kehitysaikaiseksi riippuvuudeksi komennolla

npm install --save-dev jest

Määritellään npm-skripti test suorittamaan testaus Jestillä ja raportoimaan testien suorituksesta verbose-tyylillä:

{
  //...
  "scripts": {
    "start": "node index.js",
    "dev": "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"  },
  //...
}

Jestille pitää vielä kertoa, että suoritusympäristönä on käytössä Node. Tämä tapahtuu esim. lisäämällä package.json tiedoston loppuun seuraavaa:

{
 //...
 "jest": {
   "testEnvironment": "node"
 }
}

Tehdään testejä varten hakemisto tests ja sinne tiedosto reverse.test.js, jonka sisältö on seuraava:

const reverse = require('../utils/for_testing').reverse

test('reverse of a', () => {
  const result = reverse('a')

  expect(result).toBe('a')
})

test('reverse of react', () => {
  const result = reverse('react')

  expect(result).toBe('tcaer')
})

test('reverse of saippuakauppias', () => {
  const result = reverse('saippuakauppias')

  expect(result).toBe('saippuakauppias')
})

Edellisessä osassa käyttöön ottamamme ESLint valittaa testien käyttämistä komennoista test ja expect sillä käyttämämme konfiguraatio kieltää globaalina määriteltyjen asioiden käytön. Poistetaan valitus lisäämällä .eslintrc.js-tiedoston kenttään env arvo "jest": true. Näin kerromme ESLintille, että käytämme projektissamme Jestiä ja sen globaaleja muuttujia.

module.exports = {
  'env': {
    'commonjs': true,
    'es2021': true,
    'node': true,
    'jest': true,  },
  // ...
}

Testi ottaa ensimmäisellä rivillä käyttöön testattavan funktion sijoittaen sen muuttujaan reverse:

const reverse = require('../utils/for_testing').reverse

Yksittäiset testitapaukset määritellään funktion test avulla. Ensimmäisenä parametrina on merkkijonomuotoinen testin kuvaus. Toisena parametrina on funktio, joka määrittelee testitapauksen toiminnallisuuden. Esim. toisen testitapauksen toiminnallisuus näyttää seuraavalta:

() => {
  const result = reverse('react')

  expect(result).toBe('tcaer')
}

Ensin suoritetaan testattava koodi eli generoidaan merkkijonon react palindromi. Seuraavaksi varmistetaan tulos metodin expect avulla. Expect käärii tuloksena olevan arvon olioon, joka tarjoaa joukon matcher-funktioita, joiden avulla tuloksen oikeellisuutta voidaan tarkastella. Koska kyse on kahden merkkijonon samuuden vertailusta, sopii tilanteeseen matcheri toBe.

Kuten odotettua, testit menevät läpi:

Jest kertoo että 3 testiä kolmesta meni läpi

Jest olettaa oletusarvoisesti, että testitiedoston nimessä on merkkijono .test. Käytetään kurssilla konventiota, jossa testitiedostojen nimen loppu on .test.js.

Jestin antamat virheilmoitukset ovat hyviä. Rikotaan testi:

test('reverse of react', () => {
  const result = reverse('react')

  expect(result).toBe('tkaer')
})

Seurauksena on seuraava virheilmoitus:

Jest kertoo että testin odottama merkkijono poikkesi tuloksena olevasta merkkijonosta

Lisätään tiedostoon tests/average.test.js muutama testi metodille average:

const average = require('../utils/for_testing').average

describe('average', () => {
  test('of one value is the value itself', () => {
    expect(average([1])).toBe(1)
  })

  test('of many is calculated right', () => {
    expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5)
  })

  test('of empty array is zero', () => {
    expect(average([])).toBe(0)
  })
})

Testi paljastaa, että metodi toimii väärin tyhjällä taulukolla (sillä nollalla jaon tulos on JavaScriptissä NaN):

Jest kertoo että odoteutun arvon 0 sijaan tuloksena on NaN

Metodi on helppo korjata:

const average = array => {
  const reducer = (sum, item) => {
    return sum + item
  }
  return array.length === 0
    ? 0 
    : array.reduce(reducer, 0) / array.length
}

Eli jos taulukon pituus on 0, palautetaan 0 ja muussa tapauksessa palautetaan metodin reduce avulla laskettu keskiarvo.

Pari huomiota keskiarvon testeistä. Määrittelimme testien ympärille nimellä average varustetun describe-lohkon:

describe('average', () => {
  // tests
})

Describejen avulla yksittäisessä tiedostossa olevat testit voidaan jaotella loogisiin kokonaisuuksiin. Testituloste hyödyntää myös describe-lohkon nimeä:

Testitapausten tulokset on Jestin näkymässä ryhmitelty describe-lohkojen mukaan

Kuten myöhemmin tulemme näkemään, describe-lohkot ovat tarpeellisia, jos haluamme osalle yksittäisen testitiedoston testitapauksista joitain yhteisiä alustus- tai lopetustoimenpiteitä.

Toisena huomiona se, että kirjoitimme testit aavistuksen tiiviimmässä muodossa, ottamatta testattavan metodin tulosta erikseen apumuuttujaan:

test('of empty array is zero', () => {
  expect(average([])).toBe(0)
})

Ruvetaan nyt tekemään testejä backendille. Koska backend ei sisällä monimutkaista laskentaa, ei yksittäisiä funktioita testaavia yksikkötestejä oikeastaan kannata tehdä. Ainoa potentiaalinen yksikkötestattava asia olisi muistiinpanojen metodi toJSON.

Joissain tilanteissa voisi olla mielekästä suorittaa ainakin osa backendin testauksesta siten, että oikea tietokanta eristettäisiin testeistä ja korvattaisiin "valekomponentilla" eli mockilla. Eräs tähän sopiva ratkaisu olisi mongodb-memory-server.

Koska sovelluksemme backend on koodiltaan kuitenkin suhteellisen yksinkertainen, päätämme testata sitä kokonaisuudessaan sen tarjoaman REST API:n tasolta ja siten, että myös testeissä käytetään tietokantaa. Tämän kaltaisia, useita sovelluksen komponentteja yhtä aikaa käyttäviä testejä voi luonnehtia integraatiotesteiksi.

test-ympäristö

Edellisen osan luvussa Tietokantaa käyttävän version vieminen tuotantoon mainitsimme, että kun sovellusta suoritetaan tuotantopalvelimella eli esim. Fly.io:ssa tai Renderissä, on se production-moodissa.

Noden konventiona on määritellä projektin suoritusmoodi ympäristömuuttujan NODE_ENV avulla. Yleinen käytäntö on määritellä sovelluksille omat moodinsa tuotantokäyttöön, sovelluskehitykseen ja testaukseen.

Määritellään nyt tiedostossa package.json, että testejä suoritettaessa sovelluksen NODE_ENV saa arvokseen 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": "NODE_ENV=test jest --verbose --runInBand"  },
  // ...
}

Lisäsimme testit suorittavaan npm-skriptiin myös määreen runInBand, joka estää testien rinnakkaisen suorituksen. Tämä tarkennus on viisainta tehdä sitten, kun testimme tulevat käyttämään tietokantaa.

Samalla määriteltiin, että suoritettaessa sovellusta komennolla npm run dev eli nodemonin avulla, on sovelluksen moodi development. Jos sovellusta suoritetaan normaalisti Nodella, on moodiksi määritelty production.

Määrittelyssämme on kuitenkin pieni ongelma: se ei toimi Windowsilla. Tilanne korjautuu asentamalla kirjasto cross-env kehitysaikaiseksi riippuvuudeksi komennolla

npm install --save-dev cross-env

ja muuttamalla package.json kaikilla käyttöjärjestelmillä toimivaan muotoon:

{
  // ...
  "scripts": {
    "start": "cross-env NODE_ENV=production node index.js",
    "dev": "cross-env NODE_ENV=development nodemon index.js",
    // ...
    "test": "cross-env NODE_ENV=test jest --verbose --runInBand",
  },
  // ...
}

Nyt sovelluksen toimintaa on mahdollista muokata sen suoritusmoodiin perustuen. Eli voimme määritellä vaikkapa, että testejä suoritettaessa ohjelma käyttää erillistä, testejä varten luotua tietokantaa.

Sovelluksen testikanta voidaan luoda tuotantokäytön ja sovelluskehityksen tapaan Mongo DB Atlasiin. Ratkaisu ei ole optimaalinen, erityisesti jos sovellusta on tekemässä yhtä aikaa useita henkilöitä. Testien suoritus nimittäin yleensä edellyttää, että samaa tietokantainstanssia ei ole yhtä aikaa käyttämässä useampia testiajoja.

Testaukseen kannattaisikin käyttää verkossa olevan jaetun tietokannan sijaan mieluummin sovelluskehittäjän paikallisella koneella olevaa tietokantaa. Optimiratkaisu olisi tietysti se, että jokaista testiajoa varten olisi käytettävissä oma tietokanta, sekin periaatteessa onnistuu "suhteellisen helposti" mm. keskusmuistissa toimivan Mongon ja Docker-kontainereiden avulla. Etenemme kuitenkin nyt lyhyemmän kaavan mukaan ja käytämme testikantana normaalia Mongoa.

Muutetaan konfiguraatiot suorittavaa moduulia seuraavasti:

require('dotenv').config()

const PORT = process.env.PORT

const MONGODB_URI = process.env.NODE_ENV === 'test'   ? process.env.TEST_MONGODB_URI  : process.env.MONGODB_URI
module.exports = {
  MONGODB_URI,
  PORT
}

Tiedostossa .env on nyt määritelty erikseen sekä sovelluskehitysympäristön että testausympäristön tietokannan osoite:

MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/noteApp?retryWrites=true&w=majority
PORT=3001

TEST_MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.o1opl.mongodb.net/testNoteApp?retryWrites=true&w=majority

Itse tekemämme eri ympäristöjen konfiguroinnista huolehtiva config-moduuli toimii hieman samassa hengessä kuin node-config-kirjasto. Itse tekemämme konfigurointiympäristö sopii tarkoitukseemme, sillä sovellus on yksinkertainen ja oman konfiguraatiomoduulin tekeminen on myös jossain määrin opettavaista. Isommissa sovelluksissa kannattaa harkita valmiiden kirjastojen, kuten node-config:in käyttöä.

Muualle koodiin ei muutoksia tarvita.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissä part4-2.

SuperTest

Käytetään API:n testaamiseen Jestin apuna SuperTest-kirjastoa.

Kirjasto asennetaan kehitysaikaiseksi riippuvuudeksi komennolla

npm install --save-dev supertest

Luodaan heti ensimmäinen testi tiedostoon tests/note_api.test.js:

const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')

const api = supertest(app)

test('notes are returned as json', async () => {
  await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)
})

afterAll(async () => {
  await mongoose.connection.close()
})

Testi importtaa tiedostoon app.js määritellyn Express-sovelluksen ja käärii sen funktion supertest avulla ns. superagent-olioksi. Tämä olio sijoitetaan muuttujaan api ja sen kautta testit voivat tehdä HTTP-pyyntöjä backendiin.

Testimetodi tekee HTTP GET ‑pyynnön osoitteeseen api/notes ja varmistaa, että pyyntöön vastataan statuskoodilla 200 ja että data palautetaan oikeassa muodossa, eli että Content-Type:n arvo on application/json.

Headerin arvon tarkastaminen näyttää syntaksiltaan hieman kummalliselta:

.expect('Content-Type', /application\/json/)

Haluttu arvo on nyt määritelty regexinä eli suomeksi säännöllisenä lausekkeena. Regex alkaa ja loppuu vinoviivaan /, koska haluttu merkkijono application/json myös sisältää saman vinoviivan, on sen eteen laitettu \ jotta sitä ei tulkita regexin lopetusmerkiksi.

Periaatteessa testi olisi voitu määritellä myös normaalina merkkijonona

.expect('Content-Type', 'application/json')

Tässä ongelmana on kuitenkin se, että käytettäessä merkkijonoa, tulee headerin arvon olla täsmälleen sama. Määrittelemällemme regexille kelpaa että header sisältää kyseisen merkkijonon. Headerin todellinen arvo on application/json; charset=utf-8, eli se sisältää myös tiedon merkistökoodauksesta. Testimme ei kuitenkaan ole tästä kiinnostunut ja siksi testi on parempi määritellä tarkan merkkijonon sijaan regexinä.

Testissä on muutama detalji joihin tutustumme vasta hieman myöhemmin tässä osassa. Testikoodin määrittelevä nuolifunktio alkaa sanalla async, ja api-oliolle tehtyä metodikutsua edeltää sana await. Teemme ensin muutamia testejä ja tutustumme sen jälkeen async/await-magiaan. Tällä hetkellä niistä ei tarvitse välittää, sillä kaikki toimii kunhan kirjoitat testimetodit esimerkin mukaan. Async/await-syntaksin käyttö liittyy siihen, että palvelimelle tehtävät pyynnöt ovat asynkronisia operaatioita. Async/await-kikalla saamme pyynnön näyttämään koodin tasolla synkronisesti toimivalta.

Kaikkien testien (joita siis tällä kertaa on vain yksi) päätteeksi on vielä lopputoimenpiteenä katkaistava Mongoosen käyttämä tietokantayhteys. Tämä onnistuu helposti metodissa afterAll:

afterAll(async () => {
  await mongoose.connection.close()
})

Testejä suorittaessa tulee seuraava ilmoitus:

Virheilmoitus Jest did not exit one second after the test run has completed

Kyse lienee Mongoosen version 6.x aiheuttamasta ongelmasta, versiossa 5.x ei samaa virhettä esiinny. Itseasiassa Mongoosen dokumentaatio ei välttämättä suosittele Mongoosea käyttävien sovellusten testaamista Jestillä.

Virheilmoituksesta pääsee eroon muutamallakin tavalla. Esimerkiksi lisäämällä hakemistoon tests tiedosto teardown.js jolla on seuraava sisältö

module.exports = () => {
  process.exit(0)
}

ja lajentamalla tiedoston package.json Jestiä koskevaa määrittelyä seuraavasti

{
 //...
 "jest": {
   "testEnvironment": "node"
   "globalTeardown": "./tests/teardown.js" }
}

Pieni mutta tärkeä huomio: eristimme tämän osan alussa Express-sovelluksen tiedostoon app.js, ja tiedoston index.js rooliksi jäi sovelluksen käynnistäminen määriteltyyn porttiin http-olion avulla:

const app = require('./app') // varsinainen Express-sovellus
const config = require('./utils/config')
const logger = require('./utils/logger')



app.listen(config.PORT, () => {
  logger.info(`Server running on port ${config.PORT}`)
})

Testit käyttävät ainoastaan tiedostossa app.js määriteltyä Express-sovellusta:

const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
// ...

SuperTestin dokumentaatio toteaa seuraavaa:

if the server is not already listening for connections then it is bound to an ephemeral port for you so there is no need to keep track of ports.

SuperTest siis huolehtii testattavan sovelluksen käynnistämisestä sisäisesti käyttämäänsä porttiin.

Lisätään tiedoston mongo.js ohjelmaa käyttämällä testitietokantaan kaksi muistiinpanoa (tässä kohtaa on muistettava vaihtaa käyttöön oikea tietokantaurl).

Tehdään pari testiä lisää:

test('there are two notes', async () => {
  const response = await api.get('/api/notes')

  expect(response.body).toHaveLength(2)
})

test('the first note is about HTTP methods', async () => {
  const response = await api.get('/api/notes')

  expect(response.body[0].content).toBe('HTML is easy')
})

Molemmat testit sijoittavat pyynnön vastauksen muuttujaan response. Toisin kuin edellisessä testissä (joka käytti SuperTestin mekanismeja statuskoodin ja vastauksen headereiden oikeellisuuden varmistamiseen), tällä kertaa tutkitaan vastauksessa olevan datan eli response.body:n oikeellisuutta Jestin expect:in avulla.

Async/await-kikan hyödyt tulevat nyt selkeästi esiin. Normaalisti tarvitsisimme asynkronisten pyyntöjen vastauksiin käsille pääsemiseen promiseja ja takaisinkutsuja, mutta nyt kaikki menee mukavasti:

const response = await api.get('/api/notes')

// tänne tullaan vasta kun edellinen komento eli HTTP-pyyntö on suoritettu
// muuttujassa response on nyt HTTP-pyynnön tulos
expect(response.body).toHaveLength(2)

HTTP-pyyntöjen tiedot konsoliin kirjoittava middleware häiritsee hiukan testien tulostusta. Muutetaan loggeria siten, että testausmoodissa lokiviestit eivät tulostu konsoliin:

const info = (...params) => {
  if (process.env.NODE_ENV !== 'test') {     console.log(...params)  }}

const error = (...params) => {
  if (process.env.NODE_ENV !== 'test') {     console.error(...params)  }}

module.exports = {
  info, error
}

Tietokannan alustaminen ennen testejä

Testaus vaikuttaa helpolta ja testit menevät läpi. Testimme ovat kuitenkin huonoja, sillä niiden läpimeno riippuu tietokannan tilasta, jossa nyt sattuu olemaan kaksi muistiinpanoa. Jotta saisimme robustimmat testit, tulee tietokannan tila nollata testien alussa ja sen jälkeen laittaa kantaan hallitusti testien tarvitsema data.

Testimme käyttää jo nyt Jestin metodia afterAll sulkemaan tietokannan testien suoritusten jälkeen. Jest tarjoaa joukon muitakin funktioita, joiden avulla voidaan suorittaa operaatioita ennen yhdenkään testin suorittamista tai ennen jokaisen testin suoritusta.

Päätetään alustaa tietokanta ennen jokaisen testin suoritusta, eli funktiossa beforeEach:

const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
const Note = require('../models/note')
const initialNotes = [  {    content: 'HTML is easy',    important: false,  },  {    content: 'Browser can execute only JavaScript',    important: true,  },]
beforeEach(async () => {  await Note.deleteMany({})  let noteObject = new Note(initialNotes[0])  await noteObject.save()  noteObject = new Note(initialNotes[1])  await noteObject.save()})// ...

Tietokanta siis tyhjennetään aluksi, ja sen jälkeen kantaan lisätään kaksi taulukkoon initialNotes talletettua muistiinpanoa. Näin testien suoritus aloitetaan aina hallitusti samasta tilasta.

Muutetaan kahta jälkimmäistä testiä vielä seuraavasti:

test('all notes are returned', async () => {
  const response = await api.get('/api/notes')

  expect(response.body).toHaveLength(initialNotes.length)})

test('a specific note is within the returned notes', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(r => r.content)
  expect(contents).toContain(
    'Browser can execute only JavaScript'  )
})

Huomaa jälkimmäisen testin ekspektaatio. Komennolla response.body.map(r => r.content) muodostetaan taulukko API:n palauttamien muistiinpanojen sisällöistä. Jestin toContain-ekspektaatiometodilla tarkistetaan, että parametrina oleva muistiinpano on kaikkien API:n palauttamien muistiinpanojen joukossa.

Testien suorittaminen yksitellen

Komento npm test suorittaa projektin kaikki testit. Kun olemme vasta tekemässä testejä, on useimmiten järkevämpää suorittaa kerrallaan ainoastaan yhtä tai muutamaa testiä. Jest tarjoaa tähän muutamia vaihtoehtoja. Eräs näistä on komennon only käyttö. Jos testit on kirjoitettu useaan tiedostoon, ei menetelmä ole kovin hyvä.

Parempi vaihtoehto on määritellä komennon npm test yhteydessä minkä tiedoston testit halutaan suorittaa. Seuraava komento suorittaa ainoastaan tiedostossa tests/note_api.test.js olevat testit:

npm test -- tests/note_api.test.js

Parametrin -t avulla voidaan suorittaa testejä nimen perusteella:

npm test -- -t 'a specific note is within the returned notes'

Parametri voi viitata testin tai describe-lohkon nimeen. Parametrina voidaan antaa myös nimen osa. Seuraava komento suorittaisi kaikki testit, joiden nimessä on sana notes:

npm test -- -t 'notes'

HUOM: yksittäisiä testejä suoritettaessa saattaa Mongoose-yhteys jäädä auki, mikäli yhtään yhteyttä hyödyntävää testiä ei ajeta. Ongelma seurannee siitä, että SuperTest alustaa yhteyden, mutta Jest ei suorita afterAll-osiota.

async/await

Ennen kuin teemme lisää testejä, tarkastellaan tarkemmin mitä async ja await tarkoittavat.

Async- ja await ovat ES7:n mukanaan tuoma uusi syntaksi, joka mahdollistaa promisen palauttavien asynkronisten funktioiden kutsumisen siten, että kirjoitettava koodi näyttää synkroniselta.

Esim. muistiinpanojen hakeminen tietokannasta hoidetaan promisejen avulla seuraavasti:

Note.find({}).then(notes => {
  console.log('operation returned the following notes', notes)
})

Metodikutsu Note.find() palauttaa promisen, ja saamme itse operaation tuloksen rekisteröimällä promiselle tapahtumankäsittelijän metodilla then.

Kaikki operaation suorituksen jälkeinen koodi kirjoitetaan tapahtumankäsittelijään. Jos haluaisimme tehdä peräkkäin useita asynkronisia funktiokutsuja, menisi tilanne ikävämmäksi. Joutuisimme tekemään kutsut tapahtumankäsittelijästä. Näin syntyisi potentiaalisesti monimutkaista koodia, pahimmassa tapauksessa jopa niin sanottu callback-helvetti.

Ketjuttamalla promiseja tilanne pysyy jollain tavalla hallinnassa. Callback-helvetin eli monien sisäkkäisten callbackien sijaan saadaan aikaan siistihkö then-kutsujen ketju. Olemmekin nähneet jo kurssin aikana muutaman sellaisen. Seuraavassa vielä erittäin keinotekoinen esimerkki, joka hakee ensin kaikki muistiinpanot ja sitten tuhoaa niistä ensimmäisen:

Note.find({})
  .then(notes => {
    return notes[0].deleteOne()
  })
  .then(response => {
    console.log('the first note is removed')
    // more code here
  })

Then-ketju on ok, mutta parempaankin pystytään. Jo ES6:ssa esitellyt generaattorifunktiot mahdollistivat ovelan tavan määritellä asynkronista koodia siten että se "näyttää synkroniselta". Syntaksi ei kuitenkaan ole täysin luonteva ja sitä ei käytetä kovin yleisesti.

ES7:ssa async ja await tuovat generaattoreiden tarjoaman toiminnallisuuden ymmärrettävästi ja syntaksin puolesta selkeällä tavalla koko JavaScript-kansan ulottuville.

Voisimme hakea tietokannasta kaikki muistiinpanot await-operaattoria hyödyntäen seuraavasti:

const notes = await Note.find({})

console.log('operation returned the following notes', notes)

Koodi siis näyttää täsmälleen synkroniselta koodilta. Suoritettavan koodinpätkän suhteen tilanne on se, että suoritus pysähtyy komentoon const notes = await Note.find({}) ja jatkuu kyselyä vastaavan promisen fulfillmentin eli onnistuneen suorituksen jälkeen seuraavalta riviltä. Kun suoritus jatkuu, promisea vastaavan operaation tulos on muuttujassa notes.

Ylempänä oleva monimutkaisempi esimerkki suoritettaisiin awaitin avulla seuraavasti:

const notes = await Note.find({})
const response = await notes[0].deleteOne()

console.log('the first note is removed')

Koodi siis yksinkertaistuu huomattavasti verrattuna promiseja käyttävään then-ketjuun.

Awaitin käyttöön liittyy parikin tärkeää seikkaa. Jotta asynkronisia operaatioita voi kutsua awaitin avulla, niiden täytyy palauttaa promiseja. Tämä ei sinänsä ole ongelma, sillä myös "normaaleja" callbackeja käyttävä asynkroninen koodi on helppo kääriä promiseksi.

Mistä tahansa kohtaa JavaScript-koodia ei awaitia kuitenkaan pysty käyttämään. Awaitin käyttö onnistuu ainoastaan jos ollaan async-funktiossa.

Eli jotta edelliset esimerkit toimisivat, on ne suoritettava async-funktioiden sisällä (huomaa funktion määrittelevä rivi):

const main = async () => {  const notes = await Note.find({})
  console.log('operaatio palautti seuraavat muistiinpanot', notes)

  const response = await notes[0].deleteOne()
  console.log('the first note is removed')
}

main()

Koodi määrittelee ensin asynkronisen funktion, joka sijoitetaan muuttujaan main. Määrittelyn jälkeen koodi kutsuu metodia komennolla main().

async/await backendissä

Muutetaan seuraavaksi backend käyttämään asyncia ja awaitia. Koska kaikki asynkroniset operaatiot tehdään joka tapauksessa funktioiden sisällä, awaitin käyttämiseen riittää, että muutamme routejen käsittelijät async-funktioiksi.

Kaikkien muistiinpanojen hakemisesta vastaava route muuttuu seuraavasti:

notesRouter.get('/', async (request, response) => { 
  const notes = await Note.find({})
  response.json(notes)
})

Voimme varmistaa refaktoroinnin onnistumisen selaimella sekä suorittamalla juuri määrittelemämme testit.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part4-3.

Lisää testejä ja backendin refaktorointia

Koodia refaktoroidessa vaanii aina regression vaara. On olemassa riski, että jo toimineet ominaisuudet hajoavat. Tehdäänkin muiden operaatioiden refaktorointi siten, että ennen koodin muutosta tehdään jokaiselle API:n routelle sen toiminnallisuuden varmistavat testit.

Aloitetaan lisäysoperaatiosta. Tehdään testi, joka lisää uuden muistiinpanon ja tarkastaa, että API:n palauttamien muistiinpanojen määrä kasvaa, ja että lisätty muistiinpano on palautettujen joukossa:

test('a valid note can be added ', async () => {
  const newNote = {
    content: 'async/await simplifies making async calls',
    important: true,
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(201)
    .expect('Content-Type', /application\/json/)

  const response = await api.get('/api/notes')

  const contents = response.body.map(r => r.content)

  expect(response.body).toHaveLength(initialNotes.length + 1)
  expect(contents).toContain(
    'async/await simplifies making async calls'
  )
})

Testi ei itse asiassa mene läpi, sillä olemme vahingossa palauttaneet statuskoodin 200 OK uuden muistiinpanon luomisen yhteydessä, parempi statuskoodi on 201 CREATED. Muutetaan koodia siten että testi menee läpi:

notesRouter.post('/', (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })

  note.save()
    .then(savedNote => {
      response.status(201).json(savedNote)    })
    .catch(error => next(error))
})

Tehdään myös testi, joka varmistaa, että muistiinpanoa, jolle ei ole asetettu sisältöä, ei talleteta:

test('note without content is not added', async () => {
  const newNote = {
    important: true
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(400)

  const response = await api.get('/api/notes')

  expect(response.body).toHaveLength(initialNotes.length)
})

Molemmat testit tarkastavat lisäyksen jälkeen mihin tilaan tietokanta on päätynyt hakemalla kaikki sovelluksen muistiinpanot:

const response = await api.get('/api/notes')

Sama tulee toistumaan myöhemminkin monissa testeissä ja operaatio kannattaakin eristää apufunktioon. Sijoitetaan se testien yhteyteen tiedostoon tests/test_helper.js:

const Note = require('../models/note')

const initialNotes = [
  {
    content: 'HTML is easy',
    important: false
  },
  {
    content: 'Browser can execute only JavaScript',
    important: true
  }
]

const nonExistingId = async () => {
  const note = new Note({ content: 'willremovethissoon' })
  await note.save()
  await note.deleteOne()

  return note._id.toString()
}

const notesInDb = async () => {
  const notes = await Note.find({})
  return notes.map(note => note.toJSON())
}

module.exports = {
  initialNotes, nonExistingId, notesInDb
}

Moduuli määrittelee funktion notesInDb, jonka avulla voidaan tarkastaa sovelluksen tietokannassa olevat muistiinpanot. Tietokantaan alustettava sisältö initialNotes on siirretty samaan tiedostoon. Määrittelimme myös tulevan varalta funktion nonExistingId, jonka avulla on mahdollista luoda tietokanta-id, joka ei kuulu millekään kannassa olevalle oliolle.

Testit muuttuvat muotoon

const supertest = require('supertest')
const mongoose = require('mongoose')
const helper = require('./test_helper')const app = require('../app')
const api = supertest(app)

const Note = require('../models/note')

beforeEach(async () => {
  await Note.deleteMany({})

  let noteObject = new Note(helper.initialNotes[0])  await noteObject.save()

  noteObject = new Note(helper.initialNotes[1])  await noteObject.save()
})

test('notes are returned as json', async () => {
  await api
    .get('/api/notes')
    .expect(200)
    .expect('Content-Type', /application\/json/)
})

test('all notes are returned', async () => {
  const response = await api.get('/api/notes')

  expect(response.body).toHaveLength(helper.initialNotes.length)})

test('a specific note is within the returned notes', async () => {
  const response = await api.get('/api/notes')

  const contents = response.body.map(r => r.content)
  expect(contents).toContain(
    'Browser can execute only JavaScript'
  )
})

test('a valid note can be added ', async () => {
  const newNote = {
    content: 'async/await simplifies making async calls',
    important: true,
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(201)
    .expect('Content-Type', /application\/json/)


  const notesAtEnd = await helper.notesInDb()  expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1)
  const contents = notesAtEnd.map(n => n.content)  expect(contents).toContain(
    'async/await simplifies making async calls'
  )
})

test('note without content is not added', async () => {
  const newNote = {
    important: true
  }

  await api
    .post('/api/notes')
    .send(newNote)
    .expect(400)

  const notesAtEnd = await helper.notesInDb()
  expect(notesAtEnd).toHaveLength(helper.initialNotes.length)})

afterAll(async () => {
  await mongoose.connection.close()
})

Promiseja käyttävä koodi toimii nyt ja testitkin menevät läpi. Olemme valmiit muuttamaan koodin käyttämään async/await-syntaksia.

Uuden muistiinpanon lisäämisestä huolehtiva koodi muuttuu seuraavasti (huomaa, että käsittelijän alkuun on laitettava määre async):

notesRouter.post('/', async (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })

  const savedNote = await note.save()
  response.status(201).json(savedNote)
})

Koodiin jää kuitenkin pieni ongelma: virhetilanteita ei nyt käsitellä ollenkaan. Miten niiden suhteen tulisi toimia?

Virheiden käsittely ja async/await

Jos sovellus POST-pyyntöä käsitellessään aiheuttaa jonkinlaisen ajonaikaisen virheen, syntyy jälleen tuttu tilanne:

Konsolissa näkyy virheilmoitus ValidationError joka johtuu siitä että content puuttuu vastaanotetusta datasta

Kyseessä on siis käsittelemätön promisen rejektoituminen. Pyyntöön ei vastata tilanteessa mitenkään.

Async/awaitia käyttäessä kannattaa käyttää vanhaa kunnon try/catch-mekanismia virheiden käsittelyyn:

notesRouter.post('/', async (request, response, next) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })
  try {    const savedNote = await note.save()    response.status(201).json(savedNote)  } catch(exception) {    next(exception)  }})

Catch-lohkossa siis ainoastaan kutsutaan funktiota next, joka siirtää poikkeuksen käsittelyn virheidenkäsittelymiddlewarelle.

Muutoksen jälkeen testit menevät läpi.

Tehdään sitten testit yksittäisen muistiinpanon tietojen katsomiselle ja muistiinpanon poistolle. Koodissa on korostettu varsinainen API:in suoritettava operaatio:

test('a specific note can be viewed', async () => {
  const notesAtStart = await helper.notesInDb()

  const noteToView = notesAtStart[0]

  const resultNote = await api    .get(`/api/notes/${noteToView.id}`)    .expect(200)    .expect('Content-Type', /application\/json/)
  expect(resultNote.body).toEqual(noteToView)
})

test('a note can be deleted', async () => {
  const notesAtStart = await helper.notesInDb()
  const noteToDelete = notesAtStart[0]

  await api    .delete(`/api/notes/${noteToDelete.id}`)    .expect(204)
  const notesAtEnd = await helper.notesInDb()

  expect(notesAtEnd).toHaveLength(
    helper.initialNotes.length - 1
  )

  const contents = notesAtEnd.map(r => r.content)

  expect(contents).not.toContain(noteToDelete.content)
})

Molemmat testit ovat rakenteeltaan samankaltaisia. Alustusvaiheessa ne hakevat kannasta yksittäisen muistiinpanon. Tämän jälkeen on itse testattava operaatio, joka on koodissa korostettuna. Lopussa tarkastetaan, että operaation tulos on haluttu.

Testit menevät läpi, joten voimme turvallisesti refaktoroida testatut routet käyttämään async/awaitia:

notesRouter.get('/:id', async (request, response, next) => {
  try {
    const note = await Note.findById(request.params.id)
    if (note) {
      response.json(note)
    } else {
      response.status(404).end()
    }
  } catch(exception) {
    next(exception)
  }
})

notesRouter.delete('/:id', async (request, response, next) => {
  try {
    await Note.findByIdAndDelete(request.params.id)
    response.status(204).end()
  } catch (exception) {
    next(exception)
  }
})

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, haarassa part4-4.

Try-catchin eliminointi

Async/await selkeyttää koodia jossain määrin, mutta sen "hinta" on poikkeusten käsittelyn edellyttämä try/catch-rakenne. Kaikki routejen käsittelijät noudattavat samaa kaavaa:

try {
  // do the async operations here
} catch(exception) {
  next(exception)
}

Mieleen herää kysymys, olisiko koodia mahdollista refaktoroida siten, että catch saataisiin refaktoroitua ulos metodeista?

Kirjasto express-async-errors tuo tilanteeseen helpotuksen.

Asennetaan kirjasto:

npm install express-async-errors

Kirjaston käyttö on todella helppoa. Kirjaston koodi otetaan käyttöön tiedostossa app.js:

const config = require('./utils/config')
const express = require('express')
require('express-async-errors')const app = express()
const cors = require('cors')
const notesRouter = require('./controllers/notes')
const middleware = require('./utils/middleware')
const logger = require('./utils/logger')
const mongoose = require('mongoose')

// ...

module.exports = app

Kirjaston koodiin sisällyttämän "magian" ansiosta pääsemme kokonaan eroon try-catch-lauseista. Muistiinpanon poistamisesta huolehtiva route

notesRouter.delete('/:id', async (request, response, next) => {
  try {
    await Note.findByIdAndDelete(request.params.id)
    response.status(204).end()
  } catch (exception) {
    next(exception)
  }
})

muuttuu muotoon

notesRouter.delete('/:id', async (request, response) => {
  await Note.findByIdAndDelete(request.params.id)
  response.status(204).end()
})

Kirjaston ansiosta kutsua next(exception) ei siis enää tarvita. Kirjasto hoitaa asian konepellin alla, eli jos async-funktiona määritellyn routen sisällä syntyy poikkeus, siirtyy suoritus automaattisesti virheenkäsittelijämiddlewareen.

Muut routet yksinkertaistuvat seuraavasti:

notesRouter.post('/', async (request, response) => {
  const body = request.body

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })

  const savedNote = await note.save()
  response.status(201).json(savedNote)
})

notesRouter.get('/:id', async (request, response) => {
  const note = await Note.findById(request.params.id)
  if (note) {
    response.json(note)
  } else {
    response.status(404).end()
  }
})

Testin beforeEach-metodin optimointi

Palataan takaisin testien pariin ja tarkastellaan määrittelemäämme testit alustavaa funktiota beforeEach:

beforeEach(async () => {
  await Note.deleteMany({})

  let noteObject = new Note(helper.initialNotes[0])
  await noteObject.save()

  noteObject = new Note(helper.initialNotes[1])
  await noteObject.save()
})

Funktio tallettaa tietokantaan taulukon helper.initialNotes nollannen ja ensimmäisen alkion, kummankin erikseen taulukon alkioita indeksoiden. Ratkaisu on ok, mutta jos haluaisimme tallettaa alustuksen yhteydessä kantaan useampia alkioita, olisi toisto parempi ratkaisu:

beforeEach(async () => {
  await Note.deleteMany({})
  console.log('cleared')

  helper.initialNotes.forEach(async (note) => {
    let noteObject = new Note(note)
    await noteObject.save()
    console.log('saved')
  })
  console.log('done')
})

test('notes are returned as json', async () => {
  console.log('entered test')
  // ...
}

Talletamme siis taulukossa olevat muistiinpanot tietokantaan forEach-loopissa. Testeissä kuitenkin ilmenee jotain häikkää, ja sitä varten koodin sisään on lisätty aputulosteita.

Konsoliin tulostuu:


cleared
done
entered test
saved
saved

Yllättäen ratkaisu ei async/awaitista huolimatta toimi niin kuin oletamme, vaan testin suoritus aloitetaan ennen kuin tietokannan tila on saatu alustettua!

Ongelma on siinä, että jokainen forEach-loopin läpikäynti generoi oman asynkronisen operaation ja beforeEach ei odota näiden suoritusta. Eli forEach:in sisällä olevat await-komennot eivät ole funktiossa beforeEach vaan erillisissä funktioissa, joiden päättymistä beforeEach ei odota.

Koska testien suoritus alkaa heti beforeEach metodin suorituksen jälkeen, testien suoritus ehditään aloittamaan ennen kuin tietokanta on alustettu toivottuun alkutilaan.

Toimiva ratkaisu tilanteessa on odottaa asynkronisten talletusoperaatioiden valmistumista beforeEach-funktiossa esim. metodin Promise.all avulla:

beforeEach(async () => {
  await Note.deleteMany({})

  const noteObjects = helper.initialNotes
    .map(note => new Note(note))
  const promiseArray = noteObjects.map(note => note.save())
  await Promise.all(promiseArray)
})

Ratkaisu on varmasti aloittelijalle tiiviydestään huolimatta hieman haastava. Taulukkoon noteObjects talletetaan taulukossa helper.initialNotes olevia JavaScript-olioita vastaavat Note-konstruktorifunktiolla generoidut Mongoose-oliot. Seuraavalla rivillä luodaan uusi taulukko, joka muodostuu promiseista, jotka saadaan kun jokaiselle noteObjects-taulukon alkiolle kutsutaan metodia save, eli kun ne talletetaan kantaan.

Metodin Promise.all avulla saadaan koostettua taulukollinen promiseja yhdeksi promiseksi, joka valmistuu, eli menee tilaan fulfilled kun kaikki sen parametrina olevan taulukon promiset ovat valmistuneet. Siispä viimeinen rivi, await Promise.all(promiseArray) odottaa, että kaikki tietokantaan talletusta vastaavat promiset ovat valmiina, eli alkiot on talletettu tietokantaan.

Promise.all-metodia käyttäessä päästään tarvittaessa käsiksi sen parametrina olevien yksittäisten promisejen arvoihin eli promiseja vastaavien operaatioiden tuloksiin. Jos odotetaan promisejen valmistumista await-syntaksilla const results = await Promise.all(promiseArray) palauttaa operaatio taulukon, jonka alkioina on promiseArray:n promiseja vastaavat arvot samassa järjestyksessä kuin promiset ovat taulukossa.

Promise.all suorittaa kaikkia syötteenä saamiaan promiseja rinnakkain. Jos operaatioiden suoritusjärjestyksellä on merkitystä, voi tämä aiheuttaa ongelmia. Tällöin asynkroniset operaatiot on mahdollista määrittää for...of-lohkon sisällä, jolloin suoritusjärjestys on taattu.

beforeEach(async () => {
  await Note.deleteMany({})

  for (let note of initialNotes) {
    let noteObject = new Note(note)
    await noteObject.save()
  }
})

JavaScriptin asynkroninen suoritusmalli aiheuttaakin siis helposti yllätyksiä, ja myös async/await-syntaksin kanssa pitää olla koko ajan tarkkana. Vaikka async/await peittää monia promisejen käsittelyyn liittyviä seikkoja, promisejen toiminta on syytä tuntea mahdollisimman hyvin!

Kaikkein helpoimmalla tilanteesta selvitään hyödyntämällä Mongoosen valmista metodia insertMany:

beforeEach(async () => {
  await Note.deleteMany({})
  await Note.insertMany(helper.initialNotes)})

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, haarassa part4-5.

Testejä tekevän full stack ‑sovelluskehittäjän vala

Testien tekeminen tuo ohjelmointiin jälleen uuden kerroksen haasteellisuutta. Joudumme päivittämään full stack ‑kehittäjän valaamme muistuttamaan siitä että systemaattisuus on myös testejä kehitettäessä avainasemassa.

Full stack ‑ohjelmointi on todella hankalaa, ja sen takia lupaan hyödyntää kaikkia ohjelmointia helpottavia keinoja:

  • pidän selaimen konsolin koko ajan auki
  • tarkkailen säännöllisesti selaimen network-välilehdeltä, että frontendin ja backendin välinen kommunikaatio tapahtuu oletusteni mukaan
  • tarkkailen säännöllisesti palvelimella olevan datan tilaa, ja varmistan että frontendin lähettämä data siirtyy sinne kuten oletin
  • pidän silmällä tietokannan tilaa: varmistan että backend tallentaa datan sinne oikeaan muotoon
  • etenen pienin askelin
  • käytän koodissa ja testeissä runsaasti console.log-komentoja varmistamaan sen, että varmasti ymmärrän jokaisen kirjoittamani rivin, sekä etsiessäni koodista tai testeistä mahdollisia ongelman aiheuttajia
  • jos koodini ei toimi, en kirjoita enää yhtään lisää koodia, vaan alan poistamaan toiminnan rikkoneita rivejä tai palaan suosiolla tilanteeseen, missä koodi vielä toimi
  • jos testit eivät mene läpi, varmistan että testien testaama toiminnallisuus varmasti toimii sovelluksessa
  • kun kysyn apua kurssin Discord- tai Telegram-kanavalla, tai muualla internetissä, muotoilen kysymyksen järkevästi, esim. täällä esiteltyyn tapaan

Testien refaktorointia

Testit ovat tällä hetkellä osittain epätäydelliset, sillä esim. reittejä GET /api/notes/:id ja DELETE /api/notes/:id ei tällä hetkellä testata epävalidien id:iden osalta. Myös testien organisoinnissa on hieman toivomisen varaa, sillä kaikki on kirjoitettu suoraan testifunktion "päätasolle". Parempaan luettavuuteen pääsisimme eritellessä loogisesti toisiinsa liittyvät testit describe-lohkoihin.

Jossain määrin parannellut testit ovat seuraavassa:

const supertest = require('supertest')
const mongoose = require('mongoose')
const helper = require('./test_helper')
const app = require('../app')
const api = supertest(app)

const Note = require('../models/note')

describe('when there is initially some notes saved', () => {
  beforeEach(async () => {
    await Note.deleteMany({})
    await Note.insertMany(helper.initialNotes)
  })

  test('notes are returned as json', async () => {
    await api
      .get('/api/notes')
      .expect(200)
      .expect('Content-Type', /application\/json/)
  })

  test('all notes are returned', async () => {
    const response = await api.get('/api/notes')

    expect(response.body).toHaveLength(helper.initialNotes.length)
  })

  test('a specific note is within the returned notes', async () => {
    const response = await api.get('/api/notes')

    const contents = response.body.map(r => r.content)
    expect(contents).toContain(
      'Browser can execute only JavaScript'
    )
  })

  describe('viewing a specific note', () => {

    test('succeeds with a valid id', async () => {
      const notesAtStart = await helper.notesInDb()

      const noteToView = notesAtStart[0]

      const resultNote = await api
        .get(`/api/notes/${noteToView.id}`)
        .expect(200)
        .expect('Content-Type', /application\/json/)
      
      expect(resultNote.body).toEqual(noteToView)
    })

    test('fails with statuscode 404 if note does not exist', async () => {
      const validNonexistingId = await helper.nonExistingId()

      await api
        .get(`/api/notes/${validNonexistingId}`)
        .expect(404)
    })

    test('fails with statuscode 400 id is invalid', async () => {
      const invalidId = '5a3d5da59070081a82a3445'

      await api
        .get(`/api/notes/${invalidId}`)
        .expect(400)
    })
  })

  describe('addition of a new note', () => {
    test('succeeds with valid data', async () => {
      const newNote = {
        content: 'async/await simplifies making async calls',
        important: true,
      }

      await api
        .post('/api/notes')
        .send(newNote)
        .expect(201)
        .expect('Content-Type', /application\/json/)

      const notesAtEnd = await helper.notesInDb()
      expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1)

      const contents = notesAtEnd.map(n => n.content)
      expect(contents).toContain(
        'async/await simplifies making async calls'
      )
    })

    test('fails with status code 400 if data invalid', async () => {
      const newNote = {
        important: true
      }

      await api
        .post('/api/notes')
        .send(newNote)
        .expect(400)

      const notesAtEnd = await helper.notesInDb()

      expect(notesAtEnd).toHaveLength(helper.initialNotes.length)
    })
  })

  describe('deletion of a note', () => {
    test('succeeds with status code 204 if id is valid', async () => {
      const notesAtStart = await helper.notesInDb()
      const noteToDelete = notesAtStart[0]

      await api
        .delete(`/api/notes/${noteToDelete.id}`)
        .expect(204)

      const notesAtEnd = await helper.notesInDb()

      expect(notesAtEnd).toHaveLength(
        helper.initialNotes.length - 1
      )

      const contents = notesAtEnd.map(r => r.content)

      expect(contents).not.toContain(noteToDelete.content)
    })
  })
})

afterAll(async () => {
  await mongoose.connection.close()
})

Testien raportointi tapahtuu describe-lohkojen ryhmittelyn mukaan:

Jest ryhmittelee testitulokset describe-lohkoittain

Testeihin jää vielä parannettavaa, mutta on jo aika siirtyä eteenpäin.

Käytetty tapa API:n testaamiseen, eli HTTP-pyyntöinä tehtävät operaatiot ja tietokannan tilan tarkastelu Mongoosen kautta ei ole suinkaan ainoa tai välttämättä edes paras tapa tehdä API-tason integraatiotestausta. Universaalisti parasta tapaa testien tekoon ei ole, vaan kaikki on aina suhteessa käytettäviin resursseihin ja testattavaan ohjelmistoon.

Sovelluksen tämänhetkinen koodi on kokonaisuudessaan GitHubissa, branchissa part4-6