jest + talkbackで外部APIリクエストをお手軽ユニットテスト

# TL; DR

- [talkback](https://github.com/ijpiantanida/talkback)を使えば、HTTPリクエスト/レスポンスを記録、再生可能！
  - Rubyの[vcr](https://github.com/vcr/vcr)のNode.js版

# はじめに

外部APIのリクエスト箇所は、ユニットテストに苦戦しがちです。テストのたびに本家のAPIを叩くわけにはいかないし、かといってモックまみれにすると本番との差分が怖いです...

そこで今回は、talkbackを使ったユニットテストの方法を紹介します。

# 仕組み

https://github.com/ijpiantanida/talkback

talkbackは、外部APIリクエストのHTTPプロキシとして動作します。初回テストでは実際にAPIリクエストを発生させ、リクエスト、レスポンスの組み合わせをjsonファイル（「テープ」）に保存します。
次回以降はtalkbackがテープをもとにレスポンスを返すため、実際のAPIにはリクエストが飛びません。

- **実際のAPIリクエスト、レスポンスでテストできる**
- **リクエストを発生させずにテストできる**

を両立することができます。

元祖はRubyの [vcr](https://github.com/vcr/vcr) のようで、Node.js以外にもいろいろな言語で類似ツールが提供されています。

# 使い方

talkbackのサーバーを起動し、

以下は、例として [httpbin](https://httpbin.org/) に対してのリクエストをテストしています。

```typescript:ユニットテスト
import fetch from 'node-fetch';
import talkback from 'talkback';
import TalkbackServer from 'talkback/server';

// talkbackプロキシサーバーを起動
async function recordTape(tapeName: string): Promise<TalkbackServer> {
  const opts = {
    host: 'http://httpbin.org', // リクエスト先
    record: talkback.Options.RecordMode.NEW, // 記録モード
    port: 5544, // プロキシサーバーのポート
    path: `${__dirname}/tapes/${tapeName}`, // テープの保存先パス
    // generate tape file name
    tapeNameGenerator: () => tapeName,  // テープの命名規則（ここでは指定した名前に決め打ち）
  };

  const server = talkback(opts);
  await server.start(() => console.log('Talkback Started'));
  return server;
}

// ユニットテスト例
test('dummy', async () => {
  // talkbackサーバー起動
  const recorder = await recordTape('httpbin');

  const res = await fetch('http://localhost:5544/ip'); // リクエスト先はtalkbackサーバー
  expect(res.status).toBe(200); // 後は普段通りテスト記述
  // 最後に閉じるのを忘れずに！（閉じないと次のテストと干渉してしまう）
  recorder.close();
});
```

テストが成功した後、以下のようにリクエスト/レスポンスがテープに保存されます[^1]。

```js:(テストのディレクトリ)/tapes/httpbin/httpbin.json5
{
    meta: {
        createdAt: '2022-07-23T01:54:22.892Z',
        host: 'http://httpbin.org',
        resHumanReadable: true,
    },
    req: {
        headers: {
            accept: '*/*',
            'user-agent': 'node-fetch/1.0 (+https://github.com/bitinn/node-fetch)',
            'accept-encoding': 'gzip,deflate',
            connection: 'close',
        },
        url: '/ip',
        method: 'GET',
        body: '',
    },
    res: {
        status: 200,
        headers: {
            date: [
                'Sat, 23 Jul 2022 01:54:22 GMT',
            ],
            'content-type': [
                'application/json',
            ],
            'content-length': [
                '32',
            ],
            connection: [
                'close',
            ],
            server: [
                'gunicorn/19.9.0',
            ],
            'access-control-allow-origin': [
                '*',
            ],
            'access-control-allow-credentials': [
                'true',
            ],
        },
        body: {
            origin: 'xxx.xxx.xxx.xxx(開発環境のIPアドレス)',
        },
    },
}
```

2回目以降は、記録したテープを再生することで実際のAPIを叩かずにテスト可能です。

```typescript:ユニットテスト
import fetch from 'node-fetch';
import talkback from 'talkback';
import TalkbackServer from 'talkback/server';

// 今度は再生モードでtalkback起動
async function replayTape(tapeName: string): Promise<TalkbackServer> {
  const opts = {
    host: 'http://httpbin.org',
    record: talkback.Options.RecordMode.DISABLED, // 再生モード
    fallbackMode: talkback.Options.FallbackMode.NOT_FOUND, // テープに記録されていないリクエストには404を返す
    port: 5544,
    path: `${__dirname}/tapes/${tapeName}`,
    tapeNameGenerator: () => tapeName,
  };

  const server = talkback(opts);
  await server.start(() => console.log('Talkback Started'));
  return server;
}

test('dummy', async () => {
  const recorder = await replayTape('httpbin'); // 再生モード

  const res = await fetch('http://localhost:5544/ip');
  expect(res.status).toBe(200);
  recorder.close();
});
```

# ハマったところ
## テストケース単体実行だと通るのに全体実行だと落ちる

別のテストケースのテープを同じディレクトリに保存していたのが原因でした。
テスト開始時にディレクトリ配下のテープをすべて読み込むため、別のテストケースのテープを誤って読み込んでしまったためだと考えられます。

https://github.com/ijpiantanida/talkback#tapes

> Tapes are where talkback stores requests and their response....They are loaded recursively from the path directory at startup.
> テープは、talkbackがリクエストと対応するレスポンスが保存する場所です。...テープは起動時にパスディレクトリから再帰的に読み込まれます。

前述のコードのように、テストケースごとにディレクトリを切るのがよさそうです。

## 落ちるはずのテストが通ってしまった

非同期処理の完了を待機していないのが原因でした。(これはtalkbackに限らず要注意です...)

Jestの公式リファレンスにも注意書きがあります。

https://jestjs.io/ja/docs/asynchronous

> (test() が) promiseを返す、またはawaitするようにしましょう。returnまたはawaitを省いた場合、fetchDataから返されるpromiseがresolveまたはrejectされる前に、テストが終了してしまいます。

# クレデンシャルがpushされるのを防ぐ

テープを使うことでテストを容易に実行できるようになりました。...が、**リクエストにクレデンシャルが含まれている場合、テープに記録されてしまいます**。そのままリモートにpushして世界中に公開... :fearful: となることは避けたいです。

そこでハックとして、テープ保存前に編集関数を仕込んでおきます[^2]。保存前にクレデンシャルをダミー文字列に置換することで、テープを公開しても問題ないようにします。

例は再びhttpbinで、レスポンスボディのIPアドレス(自分の開発環境のIPアドレスがばれてしまう！)を置換します[^3]。

```typescript:
async function recordTape(tapeName: string): Promise<TalkbackServer> {
  const opts = {
    host: 'http://httpbin.org',
    record: talkback.Options.RecordMode.NEW,
    port: 5544,
    path: `${__dirname}/tapes/${tapeName}`,
    tapeNameGenerator: () => tapeName,
    // テープ編集関数（取得したテープは、この関数で編集されてから保存される）
    tapeDecorator: replaceCredentialsInTape,
  };

  const server = talkback(opts);
  await server.start(() => console.log('Talkback Started'));
  return server;
}

const ipAddress = (() => {
  dotenv.config(); // .envファイルから環境変数読み込み
  const addr = process.env['IP_ADDRESS'];
  if (addr == null) {
    throw Error('enviroment variable IP_ADDRESS must be set');
  }
  return addr;
})();

// クレデンシャルを保存前にダミー文字列に置換
function replaceCredentialsInTape(tape: Tape, _context: MatchingContext) {
  if (tape.res != null) {
    // ボディの中のIPアドレス（環境変数で指定）を置換
    tape.res.body = Buffer.from(
      tape.res.body.toString().replace(ipAddress, '8.8.8.8'),
    );
  }

  return tape;
}
```

```js:(テストのディレクトリ)/tapes/httpbin/httpbin.json5
{
    // ...
    res: {
        // ...
        body: {
            origin: '8.8.8.8',
        },
    },
}
```

クレデンシャルは置換処理にべた書きしたら隠す意味が無いため、環境変数から読み込んでいます。[dotenv](https://www.npmjs.com/package/dotenv)を使って `.env` ファイル(gitignore済み)から読み込むことで、リポジトリからクレデンシャルを完全に消すことができます。

また、レスポンスボディは正規表現で一括置換しています。
JSONとしてパースしてから属性を上書きする方がきれいですが、APIの構造が変わった際に修正が漏れると置換が効かなくなってしまうのが怖いです...（安全第一）

## 実例

今回のハックは、SwitchBot温湿度計APIを叩く関数で認証トークンやデバイスIDを隠すために使用しました。ボディ以外にヘッダやURLも置き換えています。

https://github.com/Syuparn/meter-recorder/blob/main/src/infrastructure/environmentRepository.test.ts

（アプリ自体については以前の記事で紹介しています）

https://qiita.com/Syuparn/items/80c4f48c66ca53ef12b8

# おわりに

以上、talkbackの紹介でした。

実装側に手を加えず、また実際のリクエスト、レスポンスの形式でテストができたので取っつきやすかったです。一方、ネットに情報があまりなくハマると時間がかかってしまいました...(この記事がどなたかのお役に立てれば幸いです)

[^1]: レスポンスボディのIPアドレスを伏字にしていますが、実際にはローカル環境のIPアドレスが入ります。
[^2]: もちろん撮った後のテープを手動で書き換えても構いません。~~コミットの前に必ず一括置換をすることを片時も失念しなければよいだけです(私には無理です :innocent: )~~
[^3]: 先のテープ例では伏字にしているのでご安心を。