Siirry sisältöön

a

Sovelluksen rakenne ja testauksen alkeet

Jatketaan osassa 3 tehdyn muistiinpanosovelluksen backendin kehittämistä.

Sovelluksen rakenne

HUOM: Kurssimateriaalia tehtäessä on ollut käytössä Node.js:n versio v20.11.0. Suosittelen, että omasi on vähintään yhtä tuore (ks. komentoriviltä node -v).

Ennen osan ensimmäistä isoa teemaa eli testaamista muutetaan sovelluksen rakennetta noudattamaan paremmin Noden yleisiä konventioita.

Seuraavassa läpikäytävien muutosten jälkeen sovelluksemme hakemistorakenne näyttää seuraavalta:

├── index.js
├── app.js
├── dist
│   ├── ...
├── controllers
│   └── notes.js
├── models
│   └── note.js
├── package-lock.json
├── package.json
├── utils
│   ├── logger.js
│   ├── config.js
│   └── middleware.js  

Olemme toistaiseksi tulostelleet koodista erilaista loggaustietoa komennoilla console.log ja console.error, mutta tämä ei ole kovin järkevä käytäntö. Eristetään kaikki konsoliin tulostelu omaan moduliinsa utils/logger.js:

const info = (...params) => {
  console.log(...params)
}

const error = (...params) => {
  console.error(...params)
}

module.exports = {
  info, error
}

Loggeri tarjoaa kaksi funktiota: normaaleihin logiviesteihin tarkoitetun funktion info sekä virhetilanteisiin tarkoitetun funktion error.

Loggauksen eristäminen omaan moduuliinsa on monellakin tapaa järkevää. Jos esim. päätämme ruveta kirjoittamaan logeja tiedostoon tai keräämään niitä johonkin ulkoiseen palveluun kuten Graylog tai Papertrail, on muutos helppo tehdä yhteen paikkaan.

Sovelluksen käynnistystiedosto index.js pelkistyy seuraavasti:

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}`)
})

index.js ainoastaan importtaa tiedostossa app.js olevan varsinaisen sovelluksen ja käynnistää sen. Käynnistymisestä kertova konsolitulostus tehdään logger-moduulin funktion info avulla.

Nyt Express-sovellus sekä sen käynnistymisestä ja verkkoasetuksista huolehtiva koodi on eriytetty toisistaan parhaita käytänteitä noudattaen. Eräs tämän tavan eduista on se, että sovelluksen toimintaa voi nyt testata API-tasolle tehtävien HTTP-kutsujen tasolla kuitenkaan tekemättä kutsuja varsinaisesti HTTP:llä verkon yli. Tämä tekee testien suorittamisesta nopeampaa.

Ympäristömuuttujien käsittely on eriytetty moduulin utils/config.js vastuulle:

require('dotenv').config()

let PORT = process.env.PORT
let MONGODB_URI = process.env.MONGODB_URI

module.exports = {
  MONGODB_URI,
  PORT
}

Sovelluksen muut osat pääsevät ympäristömuuttujiin käsiksi importtaamalla konfiguraatiomoduulin:

const config = require('./utils/config')

logger.info(`Server running on port ${config.PORT}`)

Routejen määrittely siirretään omaan tiedostoonsa, eli myös siitä tehdään moduuli. Routejen tapahtumankäsittelijöitä kutsutaan usein kontrollereiksi. Sovellukselle onkin luotu hakemisto controllers ja sinne tiedosto notes.js, johon kaikki muistiinpanoihin liittyvien reittien määrittelyt on siirretty:

const notesRouter = require('express').Router()
const Note = require('../models/note')

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

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

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.json(savedNote)
    })
    .catch(error => next(error))
})

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

notesRouter.put('/:id', (request, response, next) => {
  const body = request.body

  const note = {
    content: body.content,
    important: body.important,
  }

  Note.findByIdAndUpdate(request.params.id, note, { new: true })
    .then(updatedNote => {
      response.json(updatedNote)
    })
    .catch(error => next(error))
})

module.exports = notesRouter

Kyseessä on käytännössä melkein suora copy-paste edellisen osan materiaalin tiedostosta index.js.

Muutoksia on muutama. Tiedoston alussa luodaan router-olio:

const notesRouter = require('express').Router()

//...

module.exports = notesRouter

Tiedosto eksporttaa moduulin käyttäjille määritellyn routerin.

Kaikki määriteltävät routet liitetään router-olioon, samaan tapaan kuin aiemmassa versiossa routet liitettiin sovellusta edustavaan olioon.

Huomionarvoinen seikka routejen määrittelyssä on se, että polut ovat typistyneet. Aiemmin määrittelimme esim.

app.delete('/api/notes/:id', (request, response) => {

Nyt riittää määritellä

notesRouter.delete('/:id', (request, response) => {

Mistä routereissa oikeastaan on kyse? Expressin manuaalin sanoin:

A router object is an isolated instance of middleware and routes. You can think of it as a “mini-application,” capable only of performing middleware and routing functions. Every Express application has a built-in app router.

Router on siis middleware, jonka avulla on mahdollista määritellä joukko "toisiinsa liittyviä" routeja yhdessä paikassa, yleensä omassa moduulissaan.

Varsinaisen sovelluslogiikan määrittelevä tiedosto app.js ottaa määrittelemämme routerin käyttöön seuraavasti:

const notesRouter = require('./controllers/notes')
app.use('/api/notes', notesRouter)

Näin määrittelemäämme routeria käytetään jos polun alkuosa on /api/notes. notesRouter-olion sisällä täytyy tämän takia käyttää ainoastaan polun loppuosia eli tyhjää polkua / tai pelkkää parametria /:id.

Sovelluksen määrittelevä app.js näyttää muutosten jälkeen seuraavalta:

const config = require('./utils/config')
const express = require('express')
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')

mongoose.set('strictQuery', false)

logger.info('connecting to', config.MONGODB_URI)

mongoose.connect(config.MONGODB_URI)
  .then(() => {
    logger.info('connected to MongoDB')
  })
  .catch((error) => {
    logger.error('error connection to MongoDB:', error.message)
  })

app.use(cors())
app.use(express.static('dist'))
app.use(express.json())
app.use(middleware.requestLogger)

app.use('/api/notes', notesRouter)

app.use(middleware.unknownEndpoint)
app.use(middleware.errorHandler)

module.exports = app

Tiedostossa siis otetaan käyttöön joukko middlewareja, joista yksi on polkuun /api/notes kiinnitettävä notesRouter (tai notes-kontrolleri niin kuin jotkut sitä kutsuisivat).

Itse toteutettujen middlewarejen määrittely on siirretty tiedostoon utils/middleware.js:

const logger = require('./logger')

const requestLogger = (request, response, next) => {
  logger.info('Method:', request.method)
  logger.info('Path:  ', request.path)
  logger.info('Body:  ', request.body)
  logger.info('---')
  next()
}

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

const errorHandler = (error, request, response, next) => {
  logger.error(error.message)

  if (error.name === 'CastError') {
    return response.status(400).send({ error: 'malformatted id' })
  } else if (error.name === 'ValidationError') {
    return response.status(400).json({ error: error.message })
  }

  next(error)
}

module.exports = {
  requestLogger,
  unknownEndpoint,
  errorHandler
}

Tietokantayhteyden muodostaminen on siirretty tiedoston app.js:n vastuulle. Hakemistossa models oleva tiedosto note.js sisältää nyt ainoastaan muistiinpanojen skeeman määrittelyn.

const mongoose = require('mongoose')

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },
  important: Boolean,
})

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

module.exports = mongoose.model('Note', noteSchema)

Sovelluksen hakemistorakenne näyttää siis refaktoroinnin jälkeen seuraavalta:

├── index.js
├── app.js
├── dist
│   ├── ...
├── controllers
│   └── notes.js
├── models
│   └── note.js
├── package-lock.json
├── package.json
├── utils
│   ├── config.js
│   └── logger.js  
│   └── middleware.js  

Jos sovellus on pieni, ei rakenteella ole kovin suurta merkitystä. Sovelluksen kasvaessa sille kannattaa muodostaa jonkinlainen rakenne eli arkkitehtuuri ja jakaa erilaiset vastuut omiin moduuleihinsa. Tämä helpottaa huomattavasti ohjelman jatkokehitystä.

Express-sovelluksien rakenteelle eli hakemistojen ja tiedostojen nimeämiselle ei ole olemassa mitään yleismaailmallista standardia samaan tapaan kuin esim. Ruby on Railsissa. Tässä käyttämämme malli noudattaa eräitä Internetissä vastaan tulevia hyviä käytäntöjä.

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

Jos kloonaat projektin itsellesi, suorita komento npm install ennen käynnistämistä eli komentoa npm run dev.

Huomio eksporteista

Olemme käyttäneet tässä osassa kahta eri tapaa eksporttaukseen. Esimerkiksi tiedostossa utils/logger.js eksporttaus tapahtuu seuraavasti:

const info = (...params) => {
  console.log(...params)
}

const error = (...params) => {
  console.error(...params)
}

module.exports = {  info, error}

Tiedosto eksporttaa olion, joka sisältää kenttinään kaksi funktiota. Funktioihin päästään käsiksi kahdella vaihtoehtoisella tavalla. Voidaan joko ottaa käyttöön koko eksportoitava olio, jolloin funktioihin viitataan olion kautta:

const logger = require('./utils/logger')

logger.info('message')

logger.error('error message')

Toinen vaihtoehto on destrukturoida funktiot omiin muuttujiin require-kutsun yhteydessä:

const { info, error } = require('./utils/logger')

info('message')
error('error message')

Jälkimäinen tapa voi olla selkeämpi erityisesti jos ollaan kiinnostunut ainoastaan joistain eksportatuista funktioista.

Tiedostossa controller/notes.js eksporttaus taas tapahtuu seuraavasti

const notesRouter = require('express').Router()
const Note = require('../models/note')

// ...

module.exports = notesRouter

Tässä tapauksessa exportataan ainoastaan yksi "asia", joten mahdollisia käyttötapojakin on vain yksi:

const notesRouter = require('./controllers/notes')

// ...

app.use('/api/notes', notesRouter)

Eli eksportoitava asia (tässä tilanteessa router-olio) sijoitetaan muuttujaan ja käytetään sitä sellaisenaan.

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. Testikirjastojen vanha kuningas on Mocha, jolta kruunun muutamia vuosia sitten peri Jest. Uusi tulokas kirjastojen joukossa on uuden generaation testikirjastoksi itseään mainostava Vitest.

Nykyään myös Nodessa on sisäänrakennettu testikirjasto node:test, ja se sopii oikein mainiosti kurssin tarpeisiin.

Määritellään npm-skripti test testien suorittamiseen:

{
  //...
  "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": "node --test"  },
  //...
}

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

const { test } = require('node:test')
const assert = require('node:assert')

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

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

  assert.strictEqual(result, 'a')
})

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

  assert.strictEqual(result, 'tcaer')
})

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

  assert.strictEqual(result, 'saippuakauppias')
})

Testi ottaa käyttöönsä avainsanan test sekä kirjaston assert, jonka avulla testit suorittavat testattavien funktioiden tulosten tarkitamisen.

Seuraavaksi testi ottaa 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')

  assert.strictEqual(result, 'tcaer')
}

Ensin suoritetaan testattava koodi eli generoidaan merkkijonon react palindromi. Seuraavaksi varmistetaan tulos assert kirjaston metodin strictEqual avulla.

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')

  assert.strictEqual(result, '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 { test, describe } = require('node:test')

// ...

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

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

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

  test('of empty array is zero', () => {
    assert.strictEqual(average([]), 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 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', () => {
  assert.strictEqual(average([]), 0)
})