nockでHTTP Request (axis)をモックしてjestでテストみた

Webアプリケーションのフロントエンドのテストを書く場合など、APIへのHTTPリクエストをモックしてテストしたい時 nock を使うとスッキリ書けて便利です。nock を読み込むと http.ClientRequestをオーバーライドしてくれて、リクエストに割り込み、特定のリクエストの場合モックしてくれるようになります。(ドキュメント)

axis を使ってHTTPリクエストを送るサンプルコードを紹介してみたいと思います。

サンプルコード

プロジェクトを0から作ってテストを書いて理解を深められるように書いていますが、Githubにサンプルのテストコードを上げているのでgit cloneして動かしてみても良いかもしれません。 github.com

git cloneしてテストを実行できます。(2つ目のテスト(index_sample2.test.js)はFAILします)

$ git clone git@github.com:moritamori/nock-sample.git
$ npm i
$ npm run test

プロジェクトのセットアップ

プロジェクトを作り、必要なライブラリをインストールします。

$ mkdir nock-sample && cd nock-sample && npm init -y
$ npm install nock axios jest --save-dev

ディレクトリ直下に index.js を追加します。

// index.js
const axios = require('axios')
module.exports.getData = function() {
  return axios.get("https://qiita.com/api/v2/items")
}

package.json にtestをjestを実行するように変えておきます。

{
  〜〜〜
  "scripts": {
    "test": "jest"
  },
  〜〜〜
}

nockで出てくる概念

まず nock には、スコープ(scope)インターセプター(intercepter)という2種類の概念があるのですが、モックするリクエストの情報を設定するためにどちらのオブジェクトの関数を呼ぶかを意識すると、うまくテストコードを書けると感じています。 nock v13の実装ベースで解説していこうと思います。

nock は1つのスコープオブジェクトとインターセプターのリストを作るコードを書くことで、テスト実行時にインターセプターのリストにマッチするリクエストがあると、実際にHTTP リクエストを送る代わりにモックオブジェクトを返してくれます。

例えば、以下のようなスコープ・インターセプターがある場合では、

f:id:moritamorie:20200719233352p:plain

  • インターセプター1
    • 2回までhttps://qiita.com/api/v2/items へのHTTPリクエストをモックし、レスポンス { items: [] }をステータス200で返す
  • インターセプター2
    • 3回までhttps://qiita.com/api/v2/items/1 へのHTTPリクエストをモックし、レスポンス { id: 1 }をステータス200で返す

という動きをします。

スコープ(scope)

スコープとは、ベースパス(プロトコル+ホスト名+ポート)やヘッダー情報といったリクエストの共通となる情報を持つオブジェクトです。

以下のように書くと、スコープオブジェクトが作られます。(参照した実装箇所)

// index.test.js
const scope = nock("https://qiita.com")

インターセプター(intercepter)

インターセプターをスコープに追加

スコープオブジェクトのGet, Post関数等を呼ぶことで、インターセプターのオブジェクトが作られます。また、インターセプターの reply 関数を呼ぶことで、スコープオブジェクトのインターセプターのリストに追加され、テスト実行時にリクエストにマッチするインターセプターを探します。

プロジェクトのディレクトリ直下にindex.test.js を追加して、テスト実行してみます。

/**
  * @jest-environment node
  */
const nock = require('nock')
const index = require('./index')
 
test('Quiita APIをモックしitems: []が返ってくること', async () => {
  // スコープオブジェクトを作成
  const scope = nock('https://qiita.com')
 
  // パス`api/v2/items`にマッチするリクエストがあれば、代わりに `item: []` を返す
  const intercepter = scope.get('/api/v2/items')
  intercepter.reply(200, { items: [] })
 
  // `/api/v2/items`にアクセスしてデータを取得
  const res = await index.getData()
 
  // `item: []` が返ってくることを確認
  expect(res.data).toEqual({ items: [] })
});
$ npm run test

 PASS  ./index.test.js
  ✓ Quiita APIをモックしitems: []が返ってくること (21 ms)

モックするとインターセプターが削除されることを確認

インターセプターによって該当のHTTPリクエストがモックされると、スコープオブジェクトのリストから削除されます。(参照した実装箇所)

なので、テストコードを以下のように変えて、同じリクエストを送ると2回目のリクエストはモックされず、テストはFAILします。

/**
  * @jest-environment node
  */
const nock = require('nock')
const index = require('./index')

test('Quiita APIの2回目のリクエストがエラーになること', async () => {
  // スコープオブジェクトを作成
  const scope = nock('https://qiita.com')
 
  // パス`api/v2/items`にマッチするリクエストがあれば、代わりに `item: []` を返す
  const intercepter = scope.get('/api/v2/items')
  intercepter.reply(200, { items: [] })
 
  // `item: []` が返ってくることを確認
  const res = await index.getData()
  expect(res.data).toEqual({ items: [] })
 
  // 2回目はインターセプターが動かないため、items: []が返らない
  const res2 = await index.getData()
  expect(res2.data).toEqual({ items: [] })
});
$ npm run test

 FAIL  ./index.test.js
  ✕ Quiita APIをモック (22 ms)

  ● Quiita APIをモック

    Nock: No match for request {
      "method": "GET",
      "url": "https://qiita.com/api/v2/items",
      "headers": {
        "accept": "application/json, text/plain, */*",
        "user-agent": "axios/0.19.2"
      }
    }

同じAPIに複数回リクエストを送るテストを実行する

同じAPIに複数回リクエストを送れるようにするには、以下のテストコードのように

  • スコープのpersist関数を呼ぶ(何回でもインターセプターが動くように)
  • インターセプターにモック回数(once, twice, thrice, times)(指定した回数インターセプターが動くように)

のどちらかを指定します。 両方指定した場合は、スコープの persist が優先され、何度でもインターセプターが動くようになります。(参照した実装箇所)

/**
  * @jest-environment node
  */
const nock = require('nock')
const index = require('./index')

test('Quiita APIをモックしitems: []が返ってくること', async () => {
  // スコープオブジェクトを作成
  const scope = nock('https://qiita.com')
    .persist() // 何度でもインタセープタが呼ばれるように

  // パス`api/v2/items`にマッチするリクエストがあれば、代わりに `item: []` を返す
  const intercepter = scope.get('/api/v2/items')
    .twice() // 2回までインタセプターを呼べるように (戻り値は、インターセプター)
    .thrice() // 3回までインタセプターを呼べるように (戻り値は、インターセプター)
    .times(5) // 5回までインタセプターを呼べるように (戻り値は、インターセプター)
  intercepter.reply(200, { 'items': [] });

  // `item: []` が返ってくることを確認
  const res = await index.getData();
  expect(res.data).toEqual({ 'items': [] });

  // 2回目もインターセプターが動くようになる
  const res2 = await index.getData();
  expect(res2.data).toEqual({ 'items': [] });
});

また、インターセプターonce, twice, thrice は内部的に times 関数を呼んでます。(参照した実装箇所)

インターセプターが動く回数毎に返すオブジェクトを変えたり、他のAPIをモックするには

インターセプターのreply 関数を呼ぶと、nock()で最初に作ったスコープオブジェクトが返ります。

なので、続けてGet, Post関数を呼ぶことでチェインして書けます。

/**
  * @jest-environment node
  */
const nock = require('nock')
const index = require('./index')

test('Quiita APIをモックすること', async () => {
  nock('https://qiita.com')
    .get('/api/v2/items')
    .once() // 1回目の`/api/v2/items`の呼び出しのみ `items: []`を返すように
    .reply(200, { 'items': [] }) // ここで、スコープオブジェクトが返る
    .get('/api/v2/items')
    .twice() //2,3回目の`/api/v2/items`の呼び出しのみ `items: [ { name: 'アイテム1' } ]`を返すように
    .reply(200, { 'items': [  { name: 'アイテム1' }  ] })  // ここで、スコープオブジェクトが返る
    .get('/api/v2/items/1') //`/api/v2/items/1`の呼び出しは `{ id: 1 }`を返すように
    .reply(200, { id: 1 })

  // `items: []` が返ってくることを確認
  const res = await index.getData();
  expect(res.data).toEqual({ 'items': [] });

  // 2回目は `items: [ { name: 'アイテム1' } ]`が返ってくることを確認
  const res2 = await index.getData();
  expect(res2.data).toEqual({ 'items': [  { name: 'アイテム1' }  ] })

  // 3回目は `items: [ { name: 'アイテム1' } ]`が返ってくることを確認
  const res3 = await index.getData();
  expect(res3.data).toEqual({ 'items': [  { name: 'アイテム1' }  ] })
});

他のリクエストの情報(ヘッダやクエリパラメータ、リクエストボディ、ディレイ)指定の仕方

ヘッダやクエリパラメータ、リクエストボディ、ディレイの指定に関しては、nockのREADMEを参考にしてみてください。

github.com

上記の記載したスコープ・インターセプターどちらのオブジェクトが返ってくるかを意識することで、複雑なテストケースに関してもうまく対応できるようになるかと思います。

実際のコードで、スコープインターセプターどちらにある関数なのか調べるのが確実です。

f:id:moritamorie:20200720021757p:plain