23
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Express + MongoDBで作成したAPIサーバーをJestでテストする

Last updated at Posted at 2020-08-10

はじめに

テストを書くのは好きですか?

アプリケーションを作成するうえで、テストは必ず書かなきゃいけないものですが、テストの書き方まで丁寧に記載されている書籍やサイトって少ないように感じます。

今回はExpress + MongoDBで作成したAPIサーバーをJestでテストします。

まずはモジュールごとに依存関係を切り離した単体テストを作成します。
Jestの強力なモック機能を活用することができます。

その後supertestを用いた結合テストまで作成します。
supertestは擬似的なHTTPリクエストを送ることができます。

テスト対象のアプリケーション

予め作成しておいた以下のコードをテストします。
機能としては簡単に、/api/users/:usernameGETで叩くと指定したユーザーネームのユーザーを取得でき、/api/usersPOSTで叩くと、ユーザーを作成できるという最小限のものです。

すべてのコードはここから確認することができます。
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

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

src/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

src/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

src/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

src/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を使用するように書き換えます。

package.json
{
  "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の型定義を追加します。

tsconfig.json
{
  "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を追加します。

babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {targets: {node: 'current'}}],
    '@babel/preset-typescript',
  ],
};

jest.config.js

Jestではデフォルトのテスト環境はブラウザになるので、Node.jsの環境でテストするように設定を追加します。

プロジェクトルートにjest.config.jsを追加します。

jest.config.js
module.exports = {
  testEnvironment: 'node'
}

テストのテスト

テストのテストを書いて確認してみましょう。testフォルダを作成し、その中にindex.spec.tsファイルを作成し、簡単なテストを記述します。

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で呼び出されていることをテストしています。

test/controllers/userController.spec.ts
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()でモジュールを指定すると、モジュールの依存をモック関数で上書きすることができます。

test/controllers/userController.spec.ts
jest.mock('../../src/Models/user', () => ({
  create: jest.fn((user: User) => {
    const _id = '12345'
    return Promise.resolve({ _id, ...user })
  })
}))

モデルをモック化し、本来エクスポートされたUserモデルが持っているcreateメソッドはモック関数として受け取ったオブジェクトに_idプロジェクトを足して返すという単純なものになっています。

これで、他のモジュールとの依存を切り離し、テストを書くことができます。

test/controllers/userController.spec.ts
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のときにはモック関数がエラーを返すようにします。

test/controllers/userController.spec.ts
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()関数が呼ばれているかどうかでエラーハンドリング処理が行われているかどうかテストしましょう。

test/controllers/userController.spec.ts
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に以下を追記します。

jest.config.js
module.exports = {
++ preset: '@shelf/jest-mongodb',
// testEnvironmentは競合するので削除
-- testEnvironment: 'node'
}
jest-mongodb-config.js

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に追記しておくと良いでしょう。

.gitignore
+ globalConfig.json

これで設定は完了です。test/Models/user.spec.tsにテストを記述していきます。

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

ポイントは、beforeAllbeforeEachafterAllでデータベースに関する準備を行っているところです。順番に見ていきましょう。

beforeAll

beforeAllは、describeブロックの中でテストを実施する前に一度だけ呼び出されます。後述のbeforeEachよりも先に呼び出されます。

ここではメモリサーバーへの接続を行っています。
通常と異なる点として、mongooseの接続先に(global as any).__MONGO_URI__を指定しています。

__MONGO_URI__はメモリサーバーの接続先を表しています。

beforeEach

beforeEachdescribeブロックの中ですべてのテストごとに実施されます。
ここで一度すべてのデータを削除してからテストデータを挿入することによって、テスト間の依存が発生しないようにしています。

collection.insertMany()create()よりも早く一括でデータを挿入することができますが、バリデーションは実施されないので注意が必要です。

afterAll

afterAlldescribeブロックの中で最後に一度だけ呼び出されます。

ここでデータベースとの接続を切っておかないとテストが正常に終了しないので忘れずにここの処理を書いておきましょう。

バリデーションテスト

バリデーションエラーが発生した場合、例外をスローするので、そのことをテストで確認します。

例外がスローされたかどうかはexpect().rejects.toThrow()で確認します。

test/Models/user.spec.ts
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()
      })
    })

バーチャルフィールドテスト

このテストは特に難しいところもないでしょう。

test/Models/user.spec.ts
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に記述します。

test/routes/userRoutes.ts
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に接続するようにしましょう。

src/index.ts
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のときにはサーバーを起動する必要がないので、ここも修正します。

src/index.ts
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に記述していきます。

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にするために改良できる点がまだあるかと思います。
ぜひ試してみてください。

23
18
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?