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 logaustietoa komennoilla console.log ja console.error, 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, normaalien logiviesteihin tarkoitetun funktion info sekä virhetilanteisiin tarkoitetun funktion error.

Loggauksen eristäminen oman moduulinsa vastuulle on monellakin tapaa järkevää. Jos esim. päätämme ruveta kirjoittamaan logeja tiedostoon tai keräämään ne johonkin ulkoiseen palveluun kuten graylog tai papertrail, on muutos helppo tehdä yhteen paikkaan.

Sovelluksen käynnistystiedosto index.js pelkistyy seuraavaan muotoon:

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

const server = http.createServer(app)

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

index.js ainoastaan importaa tiedostossa app.js olevan varsinaisen sovelluksen ja käynnistää sen. Sovelluksen käynnistäminen tapahtuu nyt server-muuttujassa olevan olion kautta. Käynnistymisestä kertova konsolitulostus tehtään logger-moduulin funktion info avulla.

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

console.log(`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.

Tiedoston sisältö on seuraava:

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

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

notesRouter.get('/:id', (request, response, next) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note.toJSON())
      } 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.toJSON())
    })
    .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.toJSON())
    })
    .catch(error => next(error))
})

module.exports = notesRouter

Kyseessä on käytännössä melkein suora copypaste 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.

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

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

mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true })
  .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, näistä yksi on polkuun /api/notes kiinnitettävä notesRouter (tai notes-kontrolleri niin kuin jotkut sitä kutsuisivat).

Itse toteutettujen middlewarejen määritelty 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
}

Koska 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 siis näyttää 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 kannattaa sille muodostaa jonkinlainen rakenne eli arkkitehtuuri, ja jakaa erilaisten vastuut omiin moduuleihin. Tämä helpottaa huomattavasti ohjelman jatkokehitystä.

Express-sovelluksien rakenteelle, eli hakemistojen ja tiedostojen nimennälle 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.

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 palindrome = (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 = {
  palindrome,
  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. Muitakin mahdollisuuksia olisi, esim. eräissä piireissä suosiota nopeasti saavuttanut ava.

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 ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend",
    "deploy": "git push heroku master",
    "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy",
    "logs:prod": "heroku logs --tail",
    "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:

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

Tai vaihtoehtoisesti Jest löytää myös oletuksena asetustiedoston nimellä jest.config.js, jonne suoritusympäristön määrittely tapahtuu seuraavasti:

module.exports = {
  testEnvironment: 'node',
};

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

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

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

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

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

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

test('palindrome of saippuakauppias', () => {
  const result = palindrome('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 
    "es6": true,
    "node": true,
    "jest": true,  },
  "extends": "eslint:recommended",
  "rules": {
    // ...
  },
};

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

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

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 = palindrome('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:

1

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

Jestin antamat virheilmoitukset ovat hyviä, rikotaan testi

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

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

seurauksena on seuraava virheilmoitus

2e

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

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ä nollallajaon tulos on Javascriptissä NaN):

3

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

4

Kuten myöhemmin tulemme näkemään, describe-lohkot ovat tarpeellisia siinä vaiheessa, jos haluamme osalle yksittäisen testitiedoston testitapauksista jotain 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)
})