a

Sovelluksen rakenne ja testauksen alkeet

Jatketaan osassa 3 tehdyn muistiinpanosovelluksen backendin kehittämistä.

Sovelluksen rakenne

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
├── build
│   ├── ...
├── 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 ja sen käynnistysestä ja verkkoasetuksista huolehtiva on eriytetty toisistaan parhaita käytänteitä noudattaen. Eräs tämä 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,
    date: new Date(),
  })

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

notesRouter.delete('/:id', (request, response, next) => {
  Note.findByIdAndRemove(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('build'))
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
  },
  date: Date,
  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
├── build
│   ├── ...
├── 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 start.

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. 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:

fullstack content

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:

fullstack content

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

fullstack content

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ä:

fullstack content

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