はじめに
テストを書くのは好きですか?
アプリケーションを作成するうえで、テストは必ず書かなきゃいけないものですが、テストの書き方まで丁寧に記載されている書籍やサイトって少ないように感じます。
今回はExpress + MongoDBで作成したAPIサーバーをJestでテストします。
まずはモジュールごとに依存関係を切り離した単体テストを作成します。
Jestの強力なモック機能を活用することができます。
その後supertestを用いた結合テストまで作成します。
supertestは擬似的なHTTPリクエストを送ることができます。
テスト対象のアプリケーション
予め作成しておいた以下のコードをテストします。
機能としては簡単に、/api/users/:username
をGET
で叩くと指定したユーザーネームのユーザーを取得でき、/api/users
をPOST
で叩くと、ユーザーを作成できるという最小限のものです。
すべてのコードはここから確認することができます。
https://github.com/azukiazusa1/express-test
MVCモデルにのっとり、大きくわけてルーティング
、コントローラー
、モデル
で構成されています。
src
├ controllers
├ userController.ts
├ middleware
├ error.ts
├ models
├ userModel.ts
├ routes
├ index.ts
├ userRoutes.ts
├ index.ts
package.json
package-lock.json
tsconfid.json
以下、src
フォルダの中身です、
index.ts
import Express from 'express'
import bodyParser from 'body-parser'
import mongoose from 'mongoose'
import router from './routes'
import errorMiddleware from './middleware/error'
const app = Express()
const port = 3000
// dbに接続
mongoose.connect('mongodb://localhost:27017/express-test', {
useNewUrlParser: true,
useUnifiedTopology: true,
useCreateIndex: true
})
mongoose.Promise = global.Promise
// postリクエストを受け取るための設定
app.use(
bodyParser.urlencoded({
extended: true
})
)
app.use(bodyParser.json())
// /api以下にルートを作成
app.use('/api', router)
// エラーハンドリング
app.use('/', errorMiddleware.notFound)
app.use(errorMiddleware.errorHandler)
// サーバースタート
app.listen(port, () => {
console.log('server start')
})
export default app
routes/index.ts
import Express from 'express'
import userRoutes from './userRoutes'
// すべてのルーティングをここにまとめます。
const router = Express.Router()
router.use('/users', userRoutes)
export default router
routes/userRoute.ts
import Express from 'express'
import usersController from '../controllers/usersController'
const router = Express.Router()
router.get('/:username', userController.show)
router.post('/', usersController.create)
export default router
controllers/userController.ts
// controllerはModelとリクエストを仲介します。
import Express from 'express'
import User from '../Models/user'
export default {
// ユーザーを一人返す
show: async (
req: Express.Request,
res: Express.Response,
next: Express.NextFunction
) => {
try {
const username: string = req.params.username
const user = await User.findOne().findByUserName(username)
res.status(200).json({ user })
} catch (e) {
next(e)
}
},
// ユーザーを作成する
create: async (
req: Express.Request,
res: Express.Response,
next: Express.NextFunction
) => {
try {
const user = await User.create(req.body)
res.status(201).json({ user })
} catch (e) {
next(e)
}
}
}
models/user.ts
import mongoose, { Schema, Document, Model, DocumentQuery } from 'mongoose'
import { User } from '../types/user'
export interface UserDoc extends Document, User {
fullName?: string
}
// スキーマを定義
const userSchema: Schema = new Schema(
{
username: {
type: String,
required: true,
unique: true
},
fristName: {
type: String
},
lastName: {
type: String
},
gender: {
type: String,
required: true,
enum: ['male', 'female']
},
age: {
type: Number,
min: 0,
max: 100
}
},
{
timestamps: true
}
)
// バーチャルフィールド
userSchema.virtual('fullName').get(function(this: User) {
return `${this.firstName} ${this.lastName}`
})
// クエリヘルパー
const queryHelpers = {
findByUserName(this: DocumentQuery<any, User>, username: string) {
return this.findOne({ username })
}
}
userSchema.query = queryHelpers
interface UserModel extends Model<User, typeof queryHelpers> {}
export default mongoose.model<User, UserModel>('User', userSchema)
テストを記述する
それでは、これからテストを記述していきましょう。
幸いなことに、今回テストするアプリケーションは、ルーティング
、コントローラー
、モデル
に切り離されて構築されているので、それぞれに単体レベルでテストを書くことができます。
それぞれのモジュールが依存するモジュールをモック化することで、影響範囲を小さくしてテストをすることができます。
そして、最終的にはモジュールを結合して擬似的なHTTPリクエストを送る、結合テストまで記述していきます。
Jestのテスト環境の構築
今回は、Jestというテストフレームワークを使ってテストを記述していきます。
まずはJestをインストールしましょう。
npm i -D jest @types/jest
package.json
package.json
のscriptsをJestを使用するように書き換えます。
{
"name": "express-test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
-- "test": "echo \"Error: no test specified\" && exit 1",
++ "test": "jest",
"serve": "ts-node-dev src/index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/mongoose": "^5.7.34",
"express": "^4.17.1",
"mongoose": "^5.9.27"
},
"devDependencies": {
"@types/express": "^4.17.7",
"@types/node": "^14.0.27",
"ts-node-dev": "^1.0.0-pre.56",
"typescript": "^3.9.7"
}
}
tsconfig.json
Visual Studio Codeに怒られないように、tsconfig.json
にJestの型定義を追加します。
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"strict": true,
"moduleResolu``tion": "node",
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
+ "types": ["@types/jest"]
}
}
babel.config.js
Babelを使用するための設定を追加します。Babelを使用することで、テストファイル中でもimport/export
構文が使えたり、ECMAScriptの新しい記法を使うことができます。
まずは必要なモジュールをインストールします。
npm i -D babel-jest @babel/core @babel/preset-env @babel/preset-typescript
プロジェクトルートに、babel.config.json
を追加します。
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};
jest.config.js
Jestではデフォルトのテスト環境はブラウザになるので、Node.jsの環境でテストするように設定を追加します。
プロジェクトルートにjest.config.js
を追加します。
module.exports = {
testEnvironment: 'node'
}
テストのテスト
テストのテストを書いて確認してみましょう。test
フォルダを作成し、その中にindex.spec.ts
ファイルを作成し、簡単なテストを記述します。
describe('simple test', () => {
test('1 === 1', () => {
expect(1).toBe(1)
})
test('1 === 2', () => {
expect(1).toBe(2)
})
})
テストはnpm test
で実行します。うまくいけば、テストは一つは成功し、もう一つは失敗します。
npm test
> express-test@1.0.0 test /express-test
> jest
FAIL test/index.spec.ts
simple test
✓ 1 === 1 (3 ms)
✕ 1 === 2 (6 ms)
● simple test › 1 === 2
expect(received).toBe(expected) // Object.is equality
Expected: 2
Received: 1
5 |
6 | test('1 === 2', () => {
> 7 | expect(1).toBe(2)
| ^
8 | })
9 | })
10 |
at Object.<anonymous> (test/index.spec.ts:7:15)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 passed, 2 total
Snapshots: 0 total
Time: 2.669 s
Ran all test suites.
npm ERR! Test failed. See above for more details.
コントローラーのテスト
まずは、コントローラーからテストを記述していきます。
コントローラーの責務から、次の観点に基づいてテストを記述します。
- リクエストを受け取った結果正しいレスポンスを返すか
- 正しいステータスコードを返すか
- エラーハンドリングが正しく行われているか
テスト対象をコントローラーに絞ってテストを書きたいですが、コントローラーはExpress
(レスポンスとリクエストの処理)とモデル
(データベースの処理)に依存しています。
この2つのモジュールをモック化しましょう。
test/controller/userController.spec.ts
に記述していきます。
Expressをモック化する
Expressをモック化するために、sinon-express-mockというモジュールをインストールします。
これは、Expressのリクエストとレスポンスメソッドをを簡単にスパイ化してくれます。
npm i -D sinon-express-mock @types/sinon-express-mock sinon
使い方は、以下のとおりです。
mockReq
にオブジェクトを渡して生成すると、モックリクエストを渡すことができます。
mockReq()
、mockRes()
で生成されたのは、スパイメソッドです。スパイ化されたメソッドは引数、戻り値、this
の値、およびすべての呼び出しに対してスローされた例外(存在する場合)を記録します。
よって、res
オブジェクトが何回呼び出されたか、どのような引数で呼び出されたかなどをテストすることができます。この例では、res.status()
がステータスコード201で呼び出されていることをテストしています。
import { mockReq, mockRes } from 'sinon-express-mock'
import { User } from '../../src/types/user'
import usersController from '../../src/controllers/usersController'
interface Request {
body: User
}
describe('src/cotrollers/userController', () => {
test('create', async () => {
const request: Request = {
body: {
username: 'username',
gender: 'male',
age: 22
}
}
const req = mockReq(request)
const res = mockRes()
const next = jest.fn()
await usersController.create(req, res, next)
expect(res.status.calledWith(201)).toBeTruthy()
})
})
モデルをモック化する
次に、モデルをモック化します。
モデルをうまくモック化するためには、Statics
やクエリヘルパーなどを活用して、詳細なクエリはモデルに閉じ込めるのがよいです。
Jestのモック関数を利用する
モデルをモック化するために、Jestのモック関数を利用します。jest.mock()
でモジュールを指定すると、モジュールの依存をモック関数で上書きすることができます。
jest.mock('../../src/Models/user', () => ({
create: jest.fn((user: User) => {
const _id = '12345'
return Promise.resolve({ _id, ...user })
})
}))
モデルをモック化し、本来エクスポートされたUser
モデルが持っているcreate
メソッドはモック関数として受け取ったオブジェクトに_id
プロジェクトを足して返すという単純なものになっています。
これで、他のモジュールとの依存を切り離し、テストを書くことができます。
import { mockReq, mockRes } from 'sinon-express-mock'
import { User } from '../../src/types/user'
import usersController from '../../src/controllers/usersController'
interface Request {
body: User
}
jest.mock('../../src/Models/user', () => ({
create: jest.fn((user: User) => {
const _id = '12345'
return Promise.resolve({ _id, ...user })
})
}))
describe('src/cotrollers/userController', () => {
test('create', async () => {
const testUser: User = {
username: 'username',
gender: 'male',
age: 22
}
const request: Request = {
body: testUser
}
const req = mockReq(request)
const res = mockRes()
const next = jest.fn()
await usersController.create(req, res, next)
expect(res.status.calledWith(201)).toBeTruthy()
const { user } = res.json.getCall(0).args[0]
expect(user.username).toEqual(testUser.username)
expect(user.gender).toEqual(testUser.gender)
expect(user.age).toEqual(testUser.age)
})
})
エラーハンドリングをテストする
モデルをモック化したので、意図的にデータベースのエラーを発生してエラーハンドリング処理をテストすることができます。
変数mockError
を宣言し、この値がtrue
のときにはモック関数がエラーを返すようにします。
let mockError = false
jest.mock('../../src/Models/user', () => ({
create: jest.fn((user: User) => {
if (mockError) {
return Promise.reject('Mock Error!')
}
const _id = '12345'
return Promise.resolve({ _id, ...user })
})
}))
Expressのエラーハンドリングでは、エラーが発生した場合next()
関数にエラーオブジェクトを渡してエラーハンドラー関数に処理を委任することによって行われています。
next()
関数が呼ばれているかどうかでエラーハンドリング処理が行われているかどうかテストしましょう。
describe('異常系', () => {
test('エラーが発生したらnext()が呼ばれる', async () => {
mockError = true
const req = mockReq(request)
const res = mockRes()
const next = jest.fn()
await usersController.create(req, res, next)
expect(next).toBeCalledWith('Mock Error!')
})
})
モデルのテスト
続いてモデルをテストします。以下の観点でテストを行います。
- データの保存、削除、更新などが正しく行われているか
- スキーマに対してバリデーションが正しく働いているか
- クエリヘルパーやバーチャルフィールドなど、自作したメソッドが正しく動作するか
テスト用のデータベースを用意する
モデルのテストをするためには、データベースと接続する必要があります。
とはいえ実際の環境のデータベースをテスト用のデータで汚したくありませんし、テストの実行に時間がかかるのも嫌でしょう。
そこで、今回は@shelf/jest-mongodbを利用して、メモリサーバーのMongoDBを使用します。
メモリサーバーのセットアップ
インストール
まずはモジュールをインストールします。
npm i -D @shelf/jest-mongodb
jest.config.js
次に、jest.config.js
に以下を追記します。
module.exports = {
++ preset: '@shelf/jest-mongodb',
// testEnvironmentは競合するので削除
-- testEnvironment: 'node'
}
jest-mongodb-config.js
jest-mongodb-config.js
を作成して、以下の設定を記述します。
設定可能なすべてのオプションは、こちらを参照してください。
module.exports = {
mongodbMemoryServerOptions: {
instance: {
dbName: 'jest'
},
binary: {
version: '5.9.25', // Version of MongoDB
skipMD5: true
},
autoStart: false
}
}
.gitignore
テストを実行するたびに、globalConfig.json
というファイルが吐き出されるので、.gigignoreに追記しておくと良いでしょう。
+ globalConfig.json
これで設定は完了です。test/Models/user.spec.ts
にテストを記述していきます。
import mongoose from 'mongoose'
import User from '../../src/Models/user'
import { User as UserType } from '../../src/types/user'
// テストデータ
const users: UserType[] = [
{
username: 'user1',
firstName: 'aaa',
lastName: 'bbb',
gender: 'male',
age: 22
},
{
username: 'user2',
firstName: 'ccc',
lastName: 'ddd',
gender: 'male',
age: 30
},
{
username: 'user3',
firstName: 'eee',
lastName: 'fff',
gender: 'female',
age: 34
}
]
describe('src/models/user', () => {
// データベースに接続
beforeAll(async () => {
mongoose.Promise = global.Promise
await mongoose.connect((global as any).__MONGO_URI__, {
useNewUrlParser: true,
useCreateIndex: true,
useUnifiedTopology: true
})
})
// テストデータをテスト毎に挿入
beforeEach(async () => {
await User.deleteMany({})
await User.collection.insertMany(users)
})
// 接続を閉じる
afterAll(() => {
mongoose.connection.close()
})
describe('クエリヘルパー', () => {
describe('findOrCreate',() => {
test('指定したusernameのユーザーが取得できる', async () => {
const result = await User.findOne().findByUserName('user1')
expect(result?.username).toEqual('user1')
})
})
})
})
ポイントは、beforeAll
、beforeEach
、afterAll
でデータベースに関する準備を行っているところです。順番に見ていきましょう。
beforeAll
beforeAll
は、describe
ブロックの中でテストを実施する前に一度だけ呼び出されます。後述のbeforeEach
よりも先に呼び出されます。
ここではメモリサーバーへの接続を行っています。
通常と異なる点として、mongoose
の接続先に(global as any).__MONGO_URI__
を指定しています。
__MONGO_URI__
はメモリサーバーの接続先を表しています。
beforeEach
beforeEach
はdescribe
ブロックの中ですべてのテストごとに実施されます。
ここで一度すべてのデータを削除してからテストデータを挿入することによって、テスト間の依存が発生しないようにしています。
collection.insertMany()
はcreate()
よりも早く一括でデータを挿入することができますが、バリデーションは実施されないので注意が必要です。
afterAll
afterAll
はdescribe
ブロックの中で最後に一度だけ呼び出されます。
ここでデータベースとの接続を切っておかないとテストが正常に終了しないので忘れずにここの処理を書いておきましょう。
バリデーションテスト
バリデーションエラーが発生した場合、例外をスローするので、そのことをテストで確認します。
例外がスローされたかどうかはexpect().rejects.toThrow()
で確認します。
import mongoose, {Error} from 'mongoose'
import User from '../../src/Models/user'
import { User as UserType } from '../../src/types/user'
const { ValidationError } = Error
// 中略
describe('バリデーション', () => {
describe('username', () => {
test('usernameはuniqueでなけれなばらない', async () => {
const invalidUser: UserType = {
username: 'user1',
firstName: 'firstName',
lastName: 'lastName',
gender: 'female',
age: 18
}
await expect(User.create(invalidUser)).rejects.toThrow()
})
test('usernameは必須項目でなけれなばらない', async () => {
const invalidUser: UserType = {
username: '',
firstName: 'firstName',
lastName: 'lastName',
gender: 'female',
age: 18
}
await expect(User.create(invalidUser)).rejects.toThrow()
})
})
バーチャルフィールドテスト
このテストは特に難しいところもないでしょう。
describe('バーチャルフィールド', () => {
describe('fullName', () => {
test('firstNameとLastNameを足して返す', async () => {
const result = await User.findOne().findByUserName('user1')
expect(result!.fullName).toEqual('aaa bbb')
})
})
})
ルーティングテスト
最後に、ルーティングのテストを記述します。
このテストは比較的簡単です。
Express.Router()
とuserController
をモック化して、各ルートに対応するコントローラーが割り当てられているか確認します。
test/routes/userRoutes
に記述します。
import userRoutes from '../../src/routes/userRoutes'
import usersController from '../../src/controllers/usersController'
jest.mock('Express', () => ({
Router: () => ({
get: jest.fn(),
post: jest.fn()
})
}))
jest.mock('../../src/controllers/usersController')
describe('src/routes/userRoutes', () => {
test('get /api/users/:usernameには、showアクションが呼ばれる', () => {
expect(userRoutes.get).toHaveBeenCalledWith(
'/:username',
usersController.show
)
})
test('post /api/usersにはcreateアクションが呼ばれる', () => {
expect(userRoutes.post).toHaveBeenCalledWith('/', usersController.create)
})
})
ここまでで単体テストを書き終えました。
結合テスト
結合テストは、supertestを用いて行います。
supertestは、サーバーを立てずとも擬似的なHTTPリクエストを送ることでテストすることができます。
テストの準備
結合テストを行う前の準備として、src/index.ts
のDBに接続している箇所を修正します。
実行環境がtest
だった場合には、テスト用のDBに接続するようにしましょう。
if (process.env.NODE_ENV === 'development') {
mongoose.connect('mongodb://localhost:27017/express-test', {
useNewUrlParser: true,
useUnifiedTopology: true,
useCreateIndex: true
})
} else if (process.env.NODE_ENV === 'test') {
mongoose.connect((global as any).__MONGO_URI__, {
useNewUrlParser: true,
useCreateIndex: true,
useUnifiedTopology: true
})
}
さらに、実行環境がtest
のときにはサーバーを起動する必要がないので、ここも修正します。
if (process.env.NODE_ENV !== 'test') {
app.listen(port, () => {
console.log('server start')
})
}
次に、テストで使用するsupertestをインストールします。
npm i -D supertest @types/supertest
テストを記述する
準備ができたら、テストを記述していきましょう。
test/integration/user.spec.ts
に記述していきます。
import request from 'supertest'
import mongoose from 'mongoose'
import app from '../../src/index'
import User from '../../src/Models/user'
import { User as UserType } from '../../src/types/user'
// テストデータ
const users: UserType[] = [
{
username: 'user1',
firstName: 'aaa',
lastName: 'bbb',
gender: 'male',
age: 22
},
{
username: 'user2',
firstName: 'ccc',
lastName: 'ddd',
gender: 'male',
age: 30
},
{
username: 'user3',
firstName: 'eee',
lastName: 'fff',
gender: 'female',
age: 34
}
]
describe('intergration user', () => {
beforeEach(async () => {
await User.deleteMany({})
await User.collection.insertMany(users)
})
afterAll(() => {
mongoose.connection.close()
})
describe('GET /api/users/:username', () => {
test('responds with json', async () => {
const response = await request(app)
.get('/api/users/user1') // GETリクエスト
.set('Accept', 'application/json') // リクエストヘッダー
.expect('Content-Type', /json/) // レスポンスのContent-Typeが正しいか
.expect(200) // レスポンスのステータスコードが正しいか
// レスポンスボディが正しいか
expect(response.body.user.username).toEqual(users[0].username)
})
})
describe('POST /api/users', () => {
test('responds with json', async () => {
const user: UserType = {
username: 'user4',
firstName: 'ggg',
lastName: 'hhh',
gender: 'female',
age: 48
}
const response = await request(app)
.post('/api/users') // POSTリクエスト
.send(user) // POSTデータ
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(201)
expect(response.body.user._id).toBeDefined()
})
})
})
モデル
のテストを記述した際と同様に、テスト毎にテストデータの投入とテスト後のDBの接続の切断を行っています。
テスト対象とするモジュールは/src/index.ts
からエクスポートされるモジュールです。
これを、supertest
(request
という名前でインポートしています。)の引数として渡すことでメソッドチェーンをする形でリクエストを送ることができます。
テスト内容は以下の点です。
- 正しいパスにリクエストを送れているか
- レスポンスの
Content-Type
が正しいか - レスポンスのステータスコードが正しいか
- レスポンスのボディが正しいか
おわりに
Expressのアプリケーションのテストについて書いてきました。
モジュールに分解することによりテストが書きやすくなるという利点と、Jest
の強力さを感じ取ることができました。
また、テストデータの投入の部分など、テストコードをDRYにするために改良できる点がまだあるかと思います。
ぜひ試してみてください。