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

テスト文も併せて変更
toHaveLengthtoContainは覚えておくと便利

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

テストをパスするまで先に進まない

いいなと思ったら応援しよう!