11
11

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 1 year has passed since last update.

mockを使ったNode.js(Express)のAPIテスト

Last updated at Posted at 2021-06-06

はじめに

Jest・SupertestでAPIテストを作成した際に躓いたので、その実装の備忘録を残しておく。
この記事では時に、より実践的なテストにするためにはmockを用いるべきであるがどのようにmock化してWeb APIがcallされないようにするか?という部分について書き残す。
※mockを用いず、ただ単にnode.js(Express)のAPIテスト(End Pointテスト)を実行できるようするための方法は、Jest・Supertestを使用したnode.js(Express)のAPIテスト app.listen()はエラーになるを参照。

以下で実装したテストの実行結果(GitHub Actionsの結果)は以下。

なぜmock化する必要があるか?

Node.js(Express)でServer Sideを構築する場合、外部のWeb APIを実行しデータを取得することも多くある。その際に、UnitTestやAPIテストで実際に通信を行ってしまうと、テストとして動作が不安定になるなどの問題が発生してしまうため。
詳細はこちらを参照。

外部のWeb APIの実行をmock化してAPIテストする

ここで言うAPIテストはEnd Pointテストで、実際に期待されるEnd Pointがありそこから結果が返ってくるか?をテストする。
(実際には以下で取り上げるテスト内容もテストしているとも言えるが・・・。)
この場合、Node.jsのmoduleをmock化し外部のWeb APIを実行させないようにするsupertestを用いてテストを実装できる。

正常系のテスト

テスト対象のコードは以下。

app.js
const express = require('express');
const app = express();

/* Middleware */
app.use(express.urlencoded({
    extended: false
}));
app.use(express.json());

// Cors for cross origin allowance
const cors = require('cors');
app.use(cors());

// Initialize the main project folder
app.use(express.static('dist'))

// dotenv
const dotenv = require('dotenv')
dotenv.config();

const axios = require('axios').default;

// config for get countries and cities
const axiosConfig = {
    baseURL: 'https://api.countrystatecity.in/v1/',
    timeout: 2500,
    headers: { 'X-CSCAPI-KEY': `${process.env.COUNTRYSTATECITY_API_KRY}` }
}

app.get('/allCountries', async (req, res) => {
    try {
        const countries = await axios.get('countries', axiosConfig)
        res.status(200).send({ countries: countries.data })
    } catch (error) {
        errorHandler(res, error)
    }
})

const errorHandler = (res, error) => {
    if (error.response) {
        res.status(500).send(error.response.data)
    } else {
        res.status(500).send({ error: error.message })
    }
}

module.exports = app;

テストコードは以下。

test.js
const request = require('supertest')
const app = require('../../../src/server/app')

const axios = require('axios')
jest.mock('axios') // ここでNode.jsのmodule(今回はaxios)をmock化している

describe('axiosをmock化&supertestでrequestを飛ばしてテスト', () => {

    it('/allCountries', async () => {
        const resp = { data: [{ name: 'test' }] };
        axios.get.mockResolvedValue(resp); // mock化したmoduleの実行結果を定義している https://jestjs.io/docs/mock-function-api#mockfnmockresolvedvaluevalue

        const res = await request(app).get('/allCountries')
        expect(res.status).toEqual(200)
        expect(res.body.countries[0].name).toEqual('test')
    })
})

※テストコードの解説・注意事項
 ・jest.mock(module)はrequire/importのスコープに記載する
  jest公式にも以下のように書いてある通り、jest.mock(module)はrequire/importのスコープに記載しないとダメ。

Note: In order to mock properly, Jest needs jest.mock('moduleName') to be in the same scope as the require/import statement.
注: 適切にモックするために、Jest は jest.mock('moduleName') が require/import ステートメントと同じスコープにある必要があります。

route-mock.test.js
// 省略

const axios = require('axios')

describe('axiosをmock化&supertestでrequestを飛ばしてテスト Get Endpoints (mocking)', () => {

    it('/allCountries', async () => {
        jest.mock('axios') // ←このような実装はエラーになる

        // 省略
    })
})

異常系のテスト

テスト対象のコードは上記と同じ。
テストコードは以下。

test.js
const request = require('supertest')
const app = require('../../../src/server/app')

const axios = require('axios')
jest.mock('axios')

describe('axiosをmock化&supertestでrequestを飛ばしてテスト', () => {
    it('Abnormal pattern test of API /allCountries', async () => {
        const resp = new Error('error test');
        axios.get.mockRejectedValueOnce(resp);

        const res = await request(app).get('/allCountries')
        expect(res.status).toEqual(500)
        expect(res.body.errorMsg).toEqual('error test')
    })
})

APIのエンドポイントがcallされた時に実行される処理をテスト

ここで言うAPIテストはAPIのcall時に実行される処理のテストの事。
End Pintが存在するか?よりはそのEnd Pointをcallした時に実行される処理が期待される挙動であるか?を検証する。
この場合、単純にエイリアス(関数)を呼び出してテストしてあげればよいが、そのテストを実行できるようにNode.js(Express)のソースコードの構成もテストできる形にする必要がある。

プロジェクトの構成のリファクタリング

まず、Node.js(Express)の構成だがMVCモデルに則り以下のようにする。

src
・・・
└── server
    ├── app.js
    ├── controllers
    │   └── controller.js
    ├── routes
    │   └── router.js
    └── server
        └── server.js

すると、単純にエイリアス(関数)であるコードを書く事ができるので、テストを実行する際にはこの関数を呼び出すだけでいい。

controller.js
const axios = require('axios').default;

// dotenv
const dotenv = require('dotenv')
dotenv.config();

// config for get countries and cities
const axiosConfig = {
    baseURL: 'https://api.countrystatecity.in/v1/',
    timeout: 2500,
    headers: { 'X-CSCAPI-KEY': `${process.env.COUNTRYSTATECITY_API_KRY}` }
}

const allCountries = async (req, res) => {
    try {
        const countries = await axios.get('countries', axiosConfig)
        res.status(200).send({ countries: countries.data })
    } catch (error) {
        errorHandler(res, error)
    }
}

const errorHandler = (res, error) => {
    if (error.response) {
        res.status(error.response.status).send({
            error: error.response.data,
            errorMsg: error.message
        })
    } else {
        res.status(500).send({ errorMsg: error.message })
    }
}

module.exports = {
    allCountries,
};

実際のテスト対象のソースコード全体は以下を参照。

テストコードの実装

上記で関数化できたのでテストコードは以下のようになる。
(テスト対象のコードは上記allCountries関数。)

正常系のテスト

test.js
const { allCountries } = require('../../../src/server/controllers/controller')

const axios = require('axios')
jest.mock('axios')

describe('axiosはmock化&関数のテストとしてテスト', () => {
    it('/allCountries', async () => {
        const resp = { data: [{ name: 'not use superttest' }] };
        axios.get.mockResolvedValue(resp);

        const req = {}
        const res = {
            status: jest.fn().mockReturnThis(),
            send: jest.fn().mockReturnThis()
        }

        await allCountries(req, res)
        expect(res.status.mock.calls[0][0]).toBe(200)
        expect(res.send.mock.calls[0][0].countries[0].name).toEqual('not use superttest')
    })
})

※テストコードの解説

  • jest.fn().mockReturnThis()
    axiosのresponseres.status().send()というようなメソッドチェーンで使われるのでそれ自身(this)を返してあげる必要があるので.mockReturnThis()thisを返すようにしている1
  • mock.calls[0][0]
    呼び出されたモックに対してmock.calls[n][m]で、n番目の引数に対してm回目の呼び出しで指定された引数を取り出すという意味で、今回は引数1つ&呼び出した回数も1回なので、mock.calls[0][0]に結果が可能されている
    上記のコードで言えば、res.status(200).send({ countries: countries.data })というテスト対象のコードが、mock化したそれぞれの関数(status: jest.fn()…send: jest.fn()…)に対し、
    .status(200)で1回呼び出されその時の引数は200なのでres.status.mock.calls[0][0]で200が取り出せる
    .send({ countries: countries.data })で1回呼び出されその時の引数は{ countries: countries.data }なのでres.send.mock.calls[0][0]{ countries: [{ name: 'not use superttest' }] }が取り出せる
    という事。2

異常系のテスト

test.js
const { allCountries } = require('../../../src/server/controllers/controller')

const axios = require('axios')
jest.mock('axios')

describe('axiosはmock化&関数のテストとしてテスト', () => {
    it('Abnormal pattern test of API /allCountries', async () => {
        const resp = new Error('error test');
        axios.get.mockRejectedValue(resp);

        const req = {}
        const res = {
            status: jest.fn().mockReturnThis(),
            send: jest.fn().mockReturnThis()
        }

        await allCountries(req, res)
        expect(res.status.mock.calls[0][0]).toBe(500)
        expect(res.send.mock.calls[0][0].errorMsg).toEqual('error test')
    })
})

参考文献

  1. https://jestjs.io/ja/docs/mock-functions#%E3%83%A2%E3%83%83%E3%82%AF%E3%81%AE%E5%AE%9F%E8%A3%85

  2. https://jestjs.io/ja/docs/mock-functions#mock-%E3%83%97%E3%83%AD%E3%83%91%E3%83%86%E3%82%A3

11
11
0

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
11
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?