💯

Vitest + MSW による TypeScript での API 呼び出しのユニットテスト

2024/04/26に公開

これは以下の記事の日本語翻訳 & 加筆版です:

https://dev.to/seratch/easier-typescript-api-testing-with-vitest-msw-4k3a

経緯

最近、自作の Slack Web API クライアントライブラリが期待通りに動作しているか確認するためにユニットテストを書いていました。

長年ウェブサービスを開発してきましたが、HTTP リクエストをモックしたテストを書くことは面倒なことが多いと感じてきました。コミュニティ界隈で広く使われているテスト用ライブラリであっても、今一つ柔軟性に欠けていて複雑なシナリオの表現が難しかったり、セットアップが煩雑になることが多いように思います。

しかし、TypeScript で API 呼び出しのテストを書くためのツールを調べていて、VitestMock Service Worker (MSW) の組み合わせは本当に素晴らしいなと思いました。利用者目線で使いやすく設計されており、テストの実装体験は非常に良かったです。

完全なテストコード例

「説明はよいので実際に書いた完全なコードをすぐチェックしたい」という方はこちらをどうぞ:

https://github.com/seratch/slack-web-api-client/blob/main/test/retry-handler.test.ts

ゼロからの手順

この記事では、新しいプロジェクトを作ってから、このテストコードを書くまでに必要な手順を順序立てて紹介していきます。

新しいプロジェクトを作成:

はじめに新しいプロジェクトを作成し、必要な依存ライブラリをインストールしましょう。

mkdir my-test-app
cd my-test-app
npm init -y
npm i slack-web-api-client
npm i --save-dev typescript vitest msw

TypeScript の設定:

TypeScript でコードを書くので、最低限の tsconfig.json を追加します。これは私が今回の実装で使っているものですが、ただの一例ですので、同じものを使用する必要は全くありません。

{
  "compilerOptions": {
    "outDir": "./dist",
    "target": "es2021",
    "noImplicitAny": true,
    "module": "commonjs",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "esModuleInterop": true,
    "allowJs": false
  },
  "include": ["src/**/*"]
}

テストコードを書く:

いよいよテストを書き始めましょう。Vitest では test ディレクトリを作って、そこに test/**.test.ts のようなファイル名でテストコードを配置していくだけで自動的に実行対象とみなされます。

sample.test.ts というファイルを作って、まずは MSW の設定を行う基本部分を貼り付けてください。

import { setupServer } from "msw/node";
import { HttpResponse, http } from "msw";
import { afterAll, afterEach, beforeAll, describe, test, expect } from "vitest";

const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

これらのコードを冒頭に含めておくだけで、コードから fetch 関数を介して送信されるすべての HTTP リクエストをキャプチャし、シナリオに応じて応答内容を自由に設定することができるようになります。

では、一つ目のシンプルなテストを追加してみましょう。api.test というエンドポイントにリクエストを送ったら正常な応答が返ってくるだけの最も基本的な例です。

import { SlackAPIClient } from "slack-web-api-client";

describe("Slack API client", async () => {
  test("can perform api.test API call", async () => {
    server.use(
      http.post("https://slack.com/api/api.test", () => {
        return HttpResponse.json({ ok: true });
      }),
    );
    const client = new SlackAPIClient();
    the response = await client.api.test();
    expect(response.ok).true;
  });
});

このテストを npx vitest で実行し、標準出力を確認してみてください。以下のような出力が表示されていれば OK です!

$ npx vitest

 DEV  v1.5.1 /new-app

 ✓ test/sample.test.ts (1)
   ✓ Slack API client (1)
     ✓ can perform api.test API call

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:18:37
   Duration  653ms (transform 87ms, setup 0ms, collect 256ms, tests 39ms, environment 0ms, prepare 101ms)

 PASS  Waiting for file changes...
       press h to show help, press q to quit

次は、上記のテストコード内の server.use(...) 部分を少し書き換えて、HTTP ステータス 429 のエラー応答になるようにしてみましょう。

    server.use(
      http.post("https://slack.com/api/api.test", () => {
        return HttpResponse.text("ratelimited", { status: 429, headers: { "Retry-After": "1" } });
      }),
    );

すると、同じテストが以下の通り、失敗するようになります。

 ❯ test/sample.test.ts (1) 1048ms
   ❯ Slack API client (1) 1047ms
     × can perform api.test API call 1045ms

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  test/sample.test.ts > Slack API client > can perform api.test API call
SlackAPIConnectionError: Failed to call api.test (cause: SlackAPIConnectionError: Failed to call api.test (status: 429, body: "ratelimited"))
 ❯ SlackAPIClient.call node_modules/slack-web-api-client/src/client/api-client.ts:581:21
 ❯ test/sample.test.ts:24:22
     22|     );
     23|     const client = new SlackAPIClient();
     24|     const response = await client.api.test();
       |                      ^
     25|     expect(response.ok).true;
     26|   });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { apiName: 'api.test', status: -1, body: '', headers: undefined }
Caused by: SlackAPIConnectionError: Failed to call api.test (status: 429, body: "ratelimited")
 ❯ SlackAPIClient.call node_modules/slack-web-api-client/src/client/api-client.ts:602:13
 ❯ SlackAPIClient.call node_modules/slack-web-api-client/src/client/api-client.ts:577:18
 ❯ test/sample.test.ts:24:22

しかし、このライブラリはこのような呼び出し過ぎによるレートリミットエラーに対しては一度だけ自動的にリトライをするようになっています。

上の例では何度リトライしても 429 エラーになりますが、今度は以下のように「一度目の呼び出しはエラーだが、二回目は正常の応答を返す」というもう少し現実的なものにしてみましょう。

    const responses = [
      HttpResponse.text("ratelimited", { status: 429, headers: { "Retry-After": "1" } }),
      HttpResponse.json({ ok: true }),
    ];
    server.use(
      http.post("https://slack.com/api/api.test", () => {
        return responses.shift();
      }),
    );

あるいは、one-time handler を使った以下のコードでも OK です。

    server.use(
      http.post("https://slack.com/api/api.test", () => HttpResponse.text("ratelimited", { status: 429, headers: { "Retry-After": "1" } }), { once: true }),
      http.post("https://slack.com/api/api.test", () => HttpResponse.json({ ok: true })),
    );

この変更を保存すると、テストは再び成功するはずです。

一度目のエラーになったリクエストの応答に含まれていた Retry-After ヘッダーで「1 秒後にリトライ」するようサーバーから指示されていますので、その待ち時間によって、このテストの実行時間が 1,041 ミリ秒かかっていることがわかります。

 RERUN  test/sample.test.ts x3

 ✓ test/sample.test.ts (1) 1044ms
   ✓ Slack API client (1) 1042ms
     ✓ can perform api.test API call 1041ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  17:28:21
   Duration  1.21s

 PASS  Waiting for file changes...

ここまでの開発体験は非常にスムーズでした。テストコードに変更を加えてファイルを保存する度にすぐにテストが再実行されます。Vitest フレームワークが表示する標準出力の内容は理解しやすいように工夫されていて、次にどうすればいいのか迷うこともないはずです。

終わりに

私にとって Vitest と MSW は、このような TypeScript で HTTP リクエストを行う SDK の開発体験を大きく改善してくれました。サービス開発でも似たようなテストをしたいことは多いと思いますので、ぜひ一度試してみてください!

Discussion