Tämä on 3. periodilla pidetyn, jo päättyneen kurssin sisältö.
Jos haluat suorittaa kurssin nyt, mene osoitteeseen https://fullstackopen-2019.github.io/
a
Jatketaan osassa 3 tehdyn muistiinpanosovelluksen backendin kehittämistä.
Ennen osan ensimmäistä isoa teemaa eli testaamista, muutetaan sovelluksen rakennetta noudattamaan paremmin Noden parhaita käytänteitä.
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
│ ├── config.js
│ └── middleware.js
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 server = http.createServer(app)
server.listen(config.PORT, () => {
console.log(`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.
Ympäristömuuttujien käsittely on eriytetty moduulin utils/config.js vastuulle:
if (process.env.NODE_ENV !== 'production') {
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 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 bodyParser = require('body-parser')
const app = express()
const notesRouter = require('./controllers/notes')
const middleware = require('./utils/middleware')
const mongoose = require('mongoose')
console.log('commecting to', config.MONGODB_URI)
mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true })
.then(() => {
console.log('connected to MongoDB')
})
.catch((error) => {
console.log('error connection to MongoDB:', error.message)
})
app.use(express.static('build'))
app.use(bodyParser.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 requestLogger = (request, response, next) => {
console.log('Method:', request.method)
console.log('Path: ', request.path)
console.log('Body: ', request.body)
console.log('---')
next()
}
const unknownEndpoint = (request, response) => {
response.status(404).send({ error: 'unknown endpoint' })
}
const errorHandler = (error, request, response, next) => {
console.error(error.message)
if (error.name === 'CastError' && error.kind === 'ObjectId') {
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
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
│ └── 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.
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 palindrom = 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 = {
palindrom,
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",
"watch": "nodemon index.js",
"lint": "eslint .",
"test": "jest --verbose"
},
//...
}
Jestin uudemmissa versioissa näyttäisi olevan tarve 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 palindrom.test.js, jonka sisältö on seuraava
const palindrom = require('../utils/for_testing').palindrom
test('palindrom of a', () => {
const result = palindrom('a')
expect(result).toBe('a')
})
test('palindrom of react', () => {
const result = palindrom('react')
expect(result).toBe('tcaer')
})
test('palindrom of saippuakauppias', () => {
const result = palindrom('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": {
"es6": true,
"node": true,
"jest": true,
},
"extends": "eslint:recommended",
"rules": {
// ...
},
};
Testi ottaa ensimmäisellä rivillä käyttöön testattavan funktion sijoittaen sen muuttujaan palindrom:
const palindrom = require('../utils/for_testing').palindrom
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 = palindrom('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 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('palindrom of react', () => {
const result = palindrom('react')
expect(result).toBe('tkaer')
})
seurauksena on seuraava virheilmotus
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):
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', () => {
// testit
})
Describejen avulla yksittäisessä tiedostossa olevat testit voidaan jaotella loogisiin kokonaisuuksiin. Testituloste hyödyntää myös describe-lohkon nimeä:
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)
})