dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

@octokit/rest.jsのテスト用モックサーバー(@octokit/fixtures-server)のしくみ

@octokit/rest.jsGitHub 社が提供する公式の REST API クライアントライブラリです。
以前この@octokit/rest.js のアーキテクチャがどうなっているか調査した 際、テストに @octokit/fixtures-server という別のパッケージが使われていることまではわかったものの、こいつが何をやっているのかまでは調べきれなかったので、今回はそのあたりを調査したメモです。

なお、 @octokit/rest.js は JavaScript 向けのクライアントライブラリですが、今回調べたモックサーバーについては特定の言語に依存せず利用できます。

調査時のパッケージバージョン

今回の調査では @octokit/rest.js を含む3つのパッケージが登場しますが、調査時点での各パッケージのバージョンは以下です。

この後リポジトリ内のソースコードやドキュメントへリンクしている箇所は基本的に該当バージョンへのリンクになっています。

先にまとめ

今回登場するパッケージとその役割、依存関係を書きます。

登場するパッケージとその役割

@octokit/fixtures

HTTP リクエストのモック機能の提供と、モックレスポンスとなる fixture ファイルを生成・管理する役割を担っています。

前者の HTTP リクエストのモック機能とは、

const https = require("https");
const fixtures = require("@octokit/fixtures");

// `api.github.com'/get-repository` という scenario に対応する URL をモックする
const mock = fixtures.mock("api.github.com/get-repository");

// このリクエストに対してはモックレスポンスが返る
const req = https.request(
  {
    method: "GET",
    hostname: "api.github.com",
    path: "/repos/octokit-fixture-org/hello-world",
    headers: {
      authorization: "token 0000000000000000000000000000000000000001",
      accept: "application/vnd.github.v3+json",
    },
  },
  (response) => {
    console.log("headers:", response.headers);
    response.on("data", (data) => console.log(data.toString()));
    // logs response from fixture
  }
);
req.on("error", (e) => {
  console.log(mock.explain(e));
});
req.end();

というように、 fixtures.mock(scenario: string) という関数を呼び出すと該当 scenario に対応する URL への HTTP リクエストをインターセプトし、レスポンスをモックします。
内部的には nock というライブラリを使用しています。

scenario は <host name>/<scenario name> 形式の文字列で、このパッケージの scenarios/ 下にあるディレクトリを指定します。
指定した scenario ディレクトリの中にある normalized-fixture.json の内容がモックレスポンスとして使われます。

たとえば、上のサンプルコードで指定した "api.github.com/get-repository" という scenario に対応する normalized-fixture.json
https://github.com/octokit/fixtures/blob/v21.2.4/scenarios/api.github.com/get-repository/normalized-fixture.json

ですが、中身を見ると

[
  {
    "scope": "https://api.github.com:443",
    "method": "get",
    "path": "/repos/octokit-fixture-org/hello-world",
    "body": "",
    "status": 200,
    "response": {
      "id": 1000,
      "node_id": "MDA6RW50aXR5MQ==",
      "name": "hello-world",
      "full_name": "octokit-fixture-org/hello-world",
      "private": false,
      "owner": {
        "login": "octokit-fixture-org",
        "id": 1000,
        "node_id": "MDA6RW50aXR5MQ==",
         ...

となっており、この場合

となります。

この fixture ファイルを生成・管理するしくみについては後述します。

@octokit/fixtures-server

前述の @octokit/fixtures を使ったモックサーバーです。
CLI から起動するスタンドアロンなサーバー、または Express のミドルウェアとして使用できます。

スタンドアロンなサーバーとして起動する場合、 npm install でインストールした後

$ npx octokit-fixtures-server

で起動します。

このモックサーバーは

$ curl -XPOST \
  -H'Content-Type: application/json' \
  http://localhost:3000/fixtures \
  -d '{"scenario": "get-repository"}'

のように、scenario をリクエストボディに含めた POST リクエストを /fixtures エンドポイントに送ると、該当 scenario に対応する URL をモックします。

このとき、レスポンスとして

{
  "id":"bb1ux4ueuw8",
  "url":"http://localhost:3000/api.github.com/bb1ux4ueuw8"
}

のように、一意な ID および ID を含む URL が返されるので、この URL に対して通常の GitHub REST API を叩くのと同じようにリクエストすると、該当 scenario のモックレスポンスが返されます。

$ curl \
  -H'Authorization: token 0000000000000000000000000000000000000001' \
  -H'Accept: application/vnd.github.v3+json' \
  http://localhost:3000/api.github.com/itydkquw34/repos/octokit-fixture-org

@octokit/rest.js

REST API クライアント本体です。 CONTRIBUTING.md#Tests に記載がありますが、このパッケージのテストを実行する際は

$ npm run start-fixtures-server

というスクリプトを叩いてローカルでモックサーバーを起動する必要があります。

このスクリプトの中身は

// package.json (抜粋)
{
  "scripts": {
    "start-fixtures-server": "octokit-fixtures-server",
  }
}

となっており、 @octokit/fixtures-server が提供しているCLIをただ実行しているだけになっています。

また、 test/scenarios/ 以下のテストを見ると、octokit インスタンスを生成するテスト用のユーティリティ関数 getInstance(scenario: string, options?: OctokitOptions) の中で

export function loadFixture(scenario: string) {
  return request("POST http://localhost:3000/fixtures", {
    data: JSON.stringify({ scenario }),
  })
    .then((response) => response.data)

    .catch((error) => {
      if (error.status === "ECONNREFUSED") {
        throw new Error(
          'Fixtures server could not be reached. Make sure to start it with "npm run start-fixtures-server"'
        );
      }

      throw error;
    });
}

export function fixtureToInstance(
  { url }: OptionsWithUrl,
  options?: OctokitOptions
) {
  return new Octokit(
    Object.assign(options || {}, {
      baseUrl: url,
    })
  );
}

export function getInstance(scenario: string, options?: OctokitOptions) {
  return loadFixture(scenario).then((fixture) =>
    fixtureToInstance(fixture, options)
  );
}

ref. https://github.com/octokit/rest.js/blob/v18.0.9/test/util.ts#L20-L22

というように、 @octokit/fixtures-server のところで説明したエンドポイントを叩いて該当 scenario に対応する HTTP リクエストをモックし、かつレスポンスの URL を octokit インスタンスの baseUrl に指定しています。

fixture ファイルを生成・管理するしくみ

テスト時のモックレスポンスとなるデータは @octokit/fixtures の scenarios/ ディレクトリ内に置かれていることがわかりました。
この fixture ファイルは実際に GitHubREST API を叩いたレスポンスから生成されています。

この、fixture ファイルの自動生成という役割を担っているのが bin/record.js および各 scenario ディレクトリ下の record.js ファイルです。

bin/record.js は以下の処理を行います。

  1. 各 scenario ディレクトリ下の record.js に従い、GitHubREST API を実行する
  2. レスポンスデータの正規化(normalization)を行う
  3. 2の結果と、すでに保存済みの normalized-fixture.json と比較し、差分があれば出力する
    • スクリプト--update オプションつきで実行された場合、上書き保存する

1. 各 scenario ディレクトリ下の record.js に従い、GitHubREST API を実行する

bin/record.jsscenarios/ ディレクトリ内を走査し、各 scenario の record.js の内容に従い GitHubREST API を叩きます。
各 scenario の record.js に定義するのは

  • Axios の reqyest config オブジェクト、またはその配列
  • Promise を返す関数

のいずれかとされています( HOT_IT_WORKS.md の冒頭を参照)。

前者については、たとえば何度か登場している get-repository という scenario であれば

const env = require("../../../lib/env");

// https://developer.github.com/v3/repos/#get
module.exports = {
  method: "get",
  url: "/repos/octokit-fixture-org/hello-world",
  headers: {
    Accept: "application/vnd.github.v3+json",
    Authorization: `token ${env.FIXTURES_USER_A_TOKEN_FULL_ACCESS}`,
  },
};

ref. https://github.com/octokit/fixtures/blob/v21.2.4/scenarios/api.github.com/get-repository/record.js

というように、HTTP メソッド、API のパス、および認証情報を含めたヘッダー情報とシンプルです。

一方後者については、search-issues という scenario では
https://github.com/octokit/fixtures/blob/v21.2.4/scenarios/api.github.com/search-issues/record.js
一時的なリポジトリを作り、issue をいくつか登録し、検索を実行する、というようにやや複雑な処理を手続き的に記述しています。

ここで得られたレスポンスは raw-fixture.json というファイル名で各 scenario ディレクトリ下に保存されます。

※余談ですが、この fixture 生成のために https://github.com/octokit-fixture-org という専用の org を用意してるみたいですね

2. レスポンスデータの正規化(normalization)を行う

生の REST API レスポンスには、タイムスタンプや ID など、fixture として使い回すには都合の悪い値が含まれます。
それらを正規化するのがこの処理です。
処理の詳細は HOT_IT_WORKS.md#Normalizations に詳しく書かれています。

3. 2の結果と、すでに保存済みの normalized-fixture.json と比較し、差分があれば出力する

特筆すべきことはないですが、 --update オプションを使うと差分の有無によらず結果をファイルに保存します。
これを利用すると、新たな scenario を作りたいときもディレクトリに record.js さえ用意すれば、初回の fixture 生成もスクリプトにより自動実行できます。

保存済みの fixture ファイルと実際の REST API レスポンスに乖離がないか、定期的にチェックする

fixture ファイル生成の流れは上で説明した通りですが、加えて、API 自体のアップデートにより保存した fixture ファイルが 古くなってしまう、というのを防止するために、「bin/record.js を日次で自動実行し、かつ差分があれば自動的に Pull Request を送る」というしくみも備わっています。

これは GitHub Actions で実現されています。
https://github.com/octokit/fixtures/blob/v21.2.4/.github/workflows/update.yml

また、以下は自動的に作られた PR の例です。

HOW_IT_WORKS.md#Automated pull requests when API change でも言及されています)

おまけ: bin/record.js を自分で実行してみる

bin/record.js はアクセストークンなどを差し替えれば手元でも動かしてみることができます。
調査する過程で自分でも動作を確認することがあったので手順をメモしておきます。

調査に使ったリポジトリ: https://github.com/zaki-yama-labs/fixtures/pull/1/files

主な手順は以下の通りです。

  1. リポジトリを fork する(任意。clone したリポジトリでやってもいい)
  2. アクセストークンを発行し、環境変数にセットする
  3. 新しい scenario ディレクトリを作成し、record.js を定義する
  4. bin/record.js を実行する
  5. bin/record.js--update オプションつきで実行する
  6. リポジトリの内容を編集し、再度 bin/record.js を実行する

3 以降の手順は HOW_IT_WORKS.md#Creating fixtures にも記載されています。

2. アクセストークンを発行し、環境変数にセットする

https://github.com/settings/tokens
で自分用のトークンを作成しておきます。

次に、lib/env.js を開きます。
元の環境変数コメントアウトし、かわりに自分のアクセストークンをセットするための適当な環境変数を定義します。

const envalid = require("envalid");

module.exports = envalid.cleanEnv(process.env, {
//  FIXTURES_USER_A_TOKEN_FULL_ACCESS: envalid.str(),
//  FIXTURES_USER_B_TOKEN_FULL_ACCESS: envalid.str(),
    MY_TOKEN: envalid.str(),
});

最後に、ターミナル等で環境変数をセットしておきます。

$ export MY_TOKEN=<作成したトークン>

3. 新しい scenario ディレクトリを作成し、record.js を定義する

scenarios/api.github.com/ 下に新しいディレクトリを作成します。ディレクトリ名は他と被らなければ自由です。 ここでは octokit-fixtures-playground とします。

作成したディレクトリの下に record.js を追加します。
今回は get-contentという scenario をコピーして以下のようにしました。

// get-content ディレクトリのコピー
const env = require("../../../lib/env");

// https://developer.github.com/v3/repos/contents/#get-contents
// empty path returns README file if present
module.exports = [
  {
    method: "get",
    url: "/repos/zaki-yama-labs/octokit-fixtures-playground/contents/",
    headers: {
      accept: "application/vnd.github.v3+json",
      Authorization: `token ${env.MY_TOKEN}`,
    },
  },
  {
    method: "get",
    url: "/repos/zaki-yama-labs/octokit-fixtures-playground/contents/README.md",
    headers: {
      accept: "application/vnd.github.v3.raw",
      Authorization: `token ${env.MY_TOKEN}`,
    },
  },
];

リクエスト先に指定しているリポジトリ https://github.com/zaki-yama-labs/octokit-fixtures-playground はこの検証のために新たに作成したリポジトリで、README.md 以外は何もありません。
(scenario ディレクトリ名とリポジトリ名が被ってますが、揃える必要はないです)

4. bin/record.js を実行する

この状態で一度スクリプトを実行します。bin/record.js は引数に <host name>/<scenario name> を渡すと指定した scenario のみ対象となります。

$ bin/record.js api.github.com/octokit-fixtures-playground 
⏯️  api.github.com: Octokit fixtures playground ...
❌  "api.github.com/octokit-fixtures-playground" looks like a new fixture
1 fixtures are out of date. Exit 1

まだ既存の fixture ファイルが存在しないのでエラーになりました。

5. bin/record.js--update オプションつきで実行する

先ほどのスクリプト--update オプションをつけて、fixture ファイルが保存されるようにします。

$ bin/record.js --update api.github.com/octokit-fixtures-playground
⏯️  api.github.com: Octokit fixtures playground ...
📼  New fixtures recorded

$ ls scenarios/api.github.com/octokit-fixtures-playground
normalized-fixture.json  raw-fixture.json         record.js

raw-fixture.json および normalized-fixture.json が生成されました。

6. リポジトリの内容を編集し、再度 bin/record.js を実行する

最後に、差分検知が期待通り動作するか試してみます。
リポジトリで README を適当に編集し、

  # octokit-fixtures-playground
+  
+ hello

bin/record.js を再度実行します。

$ bin/record.js api.github.com/octokit-fixtures-playground 
⏯️  api.github.com: Octokit fixtures playground ...
❌  Fixtures are not up-to-date
 [
   {
     response: [
       {
-        sha: "3668bc486195649c30d836c3f93be6c38e57e9c6"
+        sha: "f9a7485adca995fa82884eded6d912aef3e5e3ae"
-        size: 30
+        size: 37
-        git_url: "https://api.github.com/repos/zaki-yama-labs/octokit-fixtures-playground/git/blobs/3668bc486195649c30d836c3f93be6c38e57e9c6"
+        git_url: "https://api.github.com/repos/zaki-yama-labs/octokit-fixtures-playground/git/blobs/f9a7485adca995fa82884eded6d912aef3e5e3ae"
         _links: {
-          git: "https://api.github.com/repos/zaki-yama-labs/octokit-fixtures-playground/git/blobs/3668bc486195649c30d836c3f93be6c38e57e9c6"
+          git: "https://api.github.com/repos/zaki-yama-labs/octokit-fixtures-playground/git/blobs/f9a7485adca995fa82884eded6d912aef3e5e3ae"
         }
       }
     ]
   }
   {
-    response: "# octokit-fixtures-playground\n"
+    response: "# octokit-fixtures-playground\n\nhello\n"
     headers: {
-      content-length: "30"
+      content-length: "37"
     }
   }
 ]

💁  Update fixtures with `bin/record.js --update`
1 fixtures are out of date. Exit 1

期待通りの結果ですね。ここで差分に問題がないことを確認した後 --update を実行すれば fixture が更新されます。