FullStackOpen Part4-b Testing the backend
現時点では非常にシンプルなのでユニットテストは必要ない
データベースをモックしてテストするためにはmongodb-memory-serverが便利
バックエンドが単純なのでREST APIを介してアプリケーション全体をテストできる。これを統合テストと呼ぶ
Test Environment
Nodeではテスト環境と実環境を区別するためにNODE_ENV環境変数を使用する
{
// ...
"scripts": {
"start": "cross-env NODE_ENV=production node index.js",
"dev": "cross-env 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": "cross-env NODE_ENV=test jest --verbose --runInBand"
},
// ...
}
testに追加されている--runInBandオプションはJestがテストを並列実行することを防ぐ
ただしスクリプトからdevelopmentモードなのかproductionモードなのかWindowsのデフォルト機能では区別できないため、cross-envを使う
npm install --save-dev cross-env (fly.ioでデプロイに失敗する場合は--save-devを取り除く)
Dev用とProd用のDBを分けることができる
config.js
const MONGODB_URI = process.env.NODE_ENV === 'test' ?
process.env.TEST_MONGODB_URI
: process.env.MONGODB_URI
supertest
APIをテストするのにsupertestを使用する
npm install --save-dev supertest
テストは以下のように記述
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()
})
appオブジェクトをsupertestに入れることでsuperagentオブジェクトを作成しapi変数に格納
expectはつなげることができる
expectで期待する値は正規表現(フォワードスラッシュ"/"でくくる)で記述
テストを以下のように記述
awaitを使うとそのまま結果を変数に格納して使えるので便利(Promiseだとthen()を使ってアクセスする必要があった)
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')
})
テスト時にはconsole.logで邪魔されたくないのでNODE_ENV=testを使用して以下のようにconfig.jsを変更
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
}
Initialize the database before tests
データベースを初期化してからテストを実施することで、より均一化されたテストを行うことができる
beforeEach関数を使用してテスト前に初期化
Note.deleteManyを使用して全削除してからinitialNoteを登録
const Note = require('../models/note')
const initialNote = [
{
content: 'HTML is easy',
important: false,
},
{
content: 'Browser can execute only JavaScript',
important: true,
},
]
beforeEach(async () => {
await Note.deleteMany({})
let noteObject = new Note(initialNote[0])
await noteObject.save()
noteObject = new Note(initialNote[1])
await noteObject.save()
})
テスト文も併せて変更
toHaveLengthやtoContainは覚えておくと便利
test('all notes are returned', async () => {
const response = await api.get('/api/notes')
expect(response.body).toHaveLength(initialNote.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')
})
Running tests one by one
特定のテストのみ走らせる方法:
ファイル単位: npm test -- tests/note_api.test.js
テスト単位; npm test -- -t "a specific note is within the returned notes"
テスト単位条件マッチ: npm test -- -t "note"
async / await
非同期処理を同期処理のように扱える機能。
ES7から導入された。
以下のように使える
awaitを含む関数にはasyncを付ける必要があるので注意
const main = async () => {
const notes = await Note.find({})
console.log('operation returned the following notes', notes)
const response = await notes[0].deleteOne()
console.log('the first note is removed')
}
main()
async/await in the backend
async/awaitを使うとこんな感じになる
notesRouter.get('/', async (request, response) => {
const notes = await Note.find({})
response.json(notes)
})
More tests and refactoring the backend
notesRouterをasync/awaitに書き換える前に、テストを先に書く
リファクタリングした後も正常に動作することを確認しながら変更する
テスト中でよく使う関数はテストヘルパーとしてまとめておくとよい
test_helper.js
const Note = require('../models/note')
const initialNote = [
{
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 = {
initialNote, nonExistingId, notesInDb
}
Error handling and async/await
async/awaitのエラー処理はtry/catch推奨。
catch中でexceptionをnextに渡す
try {
const savedNote = await note.save()
response.status(201).json(savedNote)
} catch (exception) {
next(exception)
}
Eliminating the try-catch
try-catch文でcatch中でnext(error)をするのは煩雑になる
そこでexpress-async-errorsが使える
npm install express-async-errors
app.jsにrequire('express-async-errors')を追加
notesRouter.delete('/:id', async (request, response, next) => {
try {
await Note.findByIdAndRemove(request.params.id)
response.status(204).end()
} catch (exception) {
next(exception)
}
})
↑これがこうなる↓
notesRouter.delete('/:id', async (request, response) => {
await Note.findByIdAndRemove(request.params.id)
response.status(204).end()
})
async中で発生したエラーは自動的にnextに渡される
Optimizing the beforeEach function
beforeEachでDB中のNoteを初期化している方法をより拡張性の高いものにする
ダメな例:
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')
})
これだとforEach関数の実行完了前にテストに進んでしまう(beforeEachの直下でasyncが実行されていないため)!
そこで使えるのがPromise.allメソッド
一度noteオブジェクトからPromiseにすべてマップし、それをPromise.allに渡すことで、await Promise.allの処理が実行完了するのを待ってくれる
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)
})
ただしPromise.allは実行順が守られるわけではない。
実行順を守るためにはfor…ofブロックで記述
beforeEach(async () => {
await Note.deleteMany({})
for (let note of helper.initialNotes) {
let noteObject = new Note(note)
await noteObject.save()
}
})
A true full stack developer's oath
テストをパスするまで先に進まない