こちらの記事は「コドモン Advent Calendar 2023」の 24日目の記事です🎅
こんにちは! コドモンプロダクト開発部エンジニアの関口です。 みなさまはsetTimeout関数を利用していますか? setTimeout関数は一定時間後に指定した関数を実行させることができる関数で、私はリトライ処理などでよく使っています。最近、珍しくフロントエンドからバックエンドAPIをポーリングする処理のためにsetTimeout関数を利用する機会がありましたので、その際に得た学びについて書きます。
やりたかったこと
実現したかった機能要件は大まかに言って以下のようなものになります。
- ブラウザで動くTypeScriptで実装されたある画面がある
- その画面を開いている間、あるAPIをポーリングし、そのレスポンスを元にその画面の一部の表示を自動更新し続ける
- ポーリングの間隔は、APIの取得結果に次のポーリング時間の指示があればその時間に、そうでなければ10分後に再度ポーリングを行う
悩みどころ
実装はsetTimeoutと再帰を利用すれば簡単にできるのですが、仮にこれをe2eの自動テストで担保しようとすると、テスト仕様はシンプルですが以下のような課題が生じます。
- 愚直にやると最小でも10分かかるケースが生じてしまいます。それを避けるために環境変数などでテスト時だけポーリング間隔を短縮する方法はあリますが、テストのためだけの環境変数を定義し、ビルドして埋め込む必要が生じます
- アサーションが難しいです。画面が変化したことと、変化するのに要した時間を検知してアサーションしないといけません。実装も難しいしその手のアサーションは偽失敗の結果を生じがちです。偽失敗が頻発すると自動テストの信頼性は低下しますし、再実行回数が増えればリリースまでの待ち時間がさらに伸びます
- テスト仕様を実現するためにバックエンドのAPIの応答を変化させるための仕組み作りも難易度が高いです。mockで切り抜ける手はありますが、e2eテストとしての疑問符がつくので避けたいところです
一方、単体テストで担保しようとすると、機能要件にあることをそのままテスト仕様とすることはできませんので、まずは何をどのようにテストできれば安心できるのかを考え抜かないといけません。その上で、そのテストが実現可能かつテスト実行時と本番稼働時の両方で透過的に実行できるセキュアなプロダクションコードを実装しないといけません。
結局、e2eの自動テストで担保するために直面するであろう困難を避けたかったのと、t_wadaさんが弊社で話してくれた「テストピラミッドをアイスクリームコーンからピラミッドへ」(弊社開発ブログに当日のワークショップのレポートが載っています。テストピラミッドの話はこちらの動画の32:30あたりから話されている内容に相当します)の話に強い感銘を覚えたのを思い出し、早々に単体テストで品質を担保する方向を模索することにしました。
単体テストで品質を担保するために
最初にしたことは、何が担保できれば安心できるのか? を考えることでした。以下のように状態遷移の図を起こして検討した結果、ポーリングの開始からポーリングの終了までの状態遷移により起きる変化が観察できればかなり安心できそうだという結論に達しました。
観察したい変化の具体は、以下二点です。
- APIの取得結果を受けてどのような変化を画面に伝えようとしているか?
- 次のポーリング時間を何秒後に設定したか?
次は単体テスト可能なモジュール設計です。重要でないものをmockできるようにするため、以下のような実装をしました。
- APIとのやりとりを抽象化するためのインターフェースを外から注入し(repositoryと命名)、それを利用する。実際のプロダクトコードではAPIへのリクエスト/レスポンスをハンドリングする実装を注入する
- 再帰関数の中ではsetTimeoutを直接使用しない。代わりにモジュール初期化時に秒数をもらって関数を実行するインターフェースを外から注入し(executorと命名)、それを利用する。実際のプロダクトコードではexecutorの実装にsetTimeoutを注入する
// ステータスの定義 export const Status = { FINE: 'fine', SLEEPY: 'sleepy', SLEEPING: 'sleeping', READY: 'ready', } as const; // 'fine' | 'sleepy' | 'sleeping' | 'ready'; export type Status = typeof Status[keyof typeof Status]; // サーバーから次の更新までの時間の指示がなかった場合にデフォルトで待機する時間 const defaultWaitMinutes = 10; export function pollingServerStatus( ref: { status: Status }, // 画面の状態を保持するオブジェクト repository: () => Promise<{ status: Status; minutesUntilNextUpdate?: number }>, executor: (func: () => Promise<void>, waitMinutesBeforeExecution: number) => void ) { // ポーリング停止フラグ let stopPolling = false; const refreshStatus = async () => { // ポーリング停止フラグが立っていたら終了 if (stopPolling) { return; } // APIのレスポンスで画面を更新 const { status, minutesUntilNextUpdate } = await repository(); ref.status = status; // 次の更新までの時間が予告されていればその時間、そうでなければデフォルトの時間待機して再実行 executor(refreshStatus, minutesUntilNextUpdate || defaultWaitMinutes); }; return { start: refreshStatus, dispose: () => { // ポーリングを停止する stopPolling = true; }, }; }
最後はテストの実装です。単体テストはjestを使って以下のイメージで実装しました。少々長くなりますが、実装例は最初のシーケンス図の例を元に、状態遷移図の全パスを網羅する1ケースを記載します。
- pollingServerStatus関数に、APIとのやりとりを抽象化するrepositoryのmock実装を注入する
- pollingServerStatus関数に、以下の機能を持つexecutorの実装を注入し、状態遷移をシミュレートする
- 実行ごとの画面の状態のスナップショットと、引数に受け取る待機秒数及び非同期関数をキャプチャする
- 指定回数実行した後に、disposeを呼び出してポーリングを終了させる
- pollingServerStatus関数を実行してstart()とdispose()を取得し、executorの実装にdisposeを注入してからstart()を呼び出す
- setTimeoutは渡した関数を同期的に実行できないので、excutorでキャプチャした非同期関数を全てexcutorの外側で実行して同期する
- キャプチャした各時点でのスナップショットと秒数を期待値をアサートする
import { pollingServerStatus, Status } from './pollingServerStatus'; const mockRepository = (responses: { status: Status; minutesUntilNextUpdate?: number }[]) => { let countCalled = 0; return async () => { const response = responses[countCalled++]; if (!response) { throw new Error('called too many times, check the test case.'); } return response; }; }; type SnapShot = { ref: { status: Status }; waitMinutesBeforeExecution: number; countExecuted: number; }; const mockExecutor = (ref: { status: Status }, countPolling: number) => { const snapShots: SnapShot[] = []; let countCalled = 0; let dispose = () => {}; let functions: (() => Promise<void>)[] = []; return { executor: (func: () => Promise<void>, waitMinutesBeforeExecution: number) => { // 現時点の状態スナップショットを取得(ついでに待機時間も取得する) snapShots.push({ ref: { ...ref }, // コピーを取る waitMinutesBeforeExecution, countExecuted: ++countCalled, }); // テスト時に処理の終了を待つため、ここで実行せずにfunctionを順番にキャプチャしておく functions.push(func); // ポーリング回数が指定回数に達したらdisposeする if (countCalled === countPolling) { dispose(); } }, setDispose: (func: () => void) => { dispose = func; }, snapShots: () => snapShots, functions: () => functions, }; }; describe('pollingServerStatus', () => { it('should be', async () => { // repositoryをモック化 const repository = mockRepository([ { status: Status.FINE, minutesUntilNextUpdate: 7 }, { status: Status.SLEEPY, minutesUntilNextUpdate: 9 }, { status: Status.SLEEPING }, { status: Status.READY, minutesUntilNextUpdate: 8 }, { status: Status.FINE, minutesUntilNextUpdate: 11 }, ]); // 初期状態 const ref = { status: Status.SLEEPING }; // executorをモック化 const { executor, setDispose, functions, snapShots } = mockExecutor(ref, 4); // pollingServerStatusを実行 const { start, dispose } = pollingServerStatus(ref, repository, executor); // モックしたexecutorにdispose関数を渡し、指定回数ポーリングが終わった際にポーリングを停止できるようにする setDispose(dispose); // キックして、再帰を全部回す await start(); // executorがキャプチャした関数を順番に同期実行する for (const fn of functions()) { await fn(); } const actual = snapShots(); const expected: SnapShot[] = [ { ref: { status: Status.FINE }, waitMinutesBeforeExecution: 7, countExecuted: 1, }, { ref: { status: Status.SLEEPY }, waitMinutesBeforeExecution: 9, countExecuted: 2, }, { ref: { status: Status.SLEEPING }, // APIが待機時間を返さないので、デフォルトの待機時間が設定される waitMinutesBeforeExecution: 10, countExecuted: 3, }, { ref: { status: Status.READY }, waitMinutesBeforeExecution: 8, countExecuted: 4, }, ]; // スナップショットの内容を検証 expect(actual).toStrictEqual(expected); // refが最終状態で止まっていることを確認 expect(ref).toStrictEqual({ status: Status.READY }); }); });
やってみた結果
まずはいい話からです。上記の結果、待ち時間なく状態遷移をシミュレートし、状態の変化をassertできたおかげで「このテストが通れば本番で稼働しても大丈夫」という確信を強く持てました。頭痛のタネだったe2eは、最初の画面表示の部分だけで済ませることができ、テストの精度や実行時間の懸念を回避しつつ自動テストでカバーできる範囲を広げられました。また、この機能要件がリリースしたいサービスのコアバリューを実現するものであったこと、リリースが早ければ早いほど価値の高いものであったことに鑑み、この判断は少なくともその時の状況に対してはよいソリューションだったと自負しています。
しかし、もちろんいいことばかりではありませんでした。実際は手でテストした結果、10分待っても画面の反映がうまくいかないバグを発見しています(jsのフレームワーク仕様の考慮漏れが原因でした)。また、実際のプロダクトでは今回例示した機能要件に加え、画面更新時にローディング表示をしたり、クライアントの時計でポーリングに行く場合はリクエストの集中を避けるために時間をある程度バラけさせる必要があるなどの追加の考慮が必要になります。このため、mockで注入するものが多くなった結果、単体テストの複雑性が高くなっており、メンテが大変になっています。この辺りは今後さらなる責務の分割や移動により複雑度を下げるリファクタリングが必要です。最後に、「重要でない」と切り捨てた部分へのリグレッションテストが自動化されていない点も、将来に考慮が必要になるシーンが訪れるかもしれません。
結論
上述の通り銀の弾丸はありませんでした。状況に応じてbetterな選択をしただけで、未来永劫の安心は依然手に入っていません。しかしながら、この選択によっていくつか副次的な学びを得ることもできました。
まずは、テストとモジュールの設計が大切だということです。何にフォーカスして何を抽象化するかの判別が単体テストを効果的にすることがわかりました。また、今回のケースでは単体テストがモジュールの設計を駆動したと言っても過言ではありません。「なるべく単体テストできるようにする」というモチベーションがモジュール構造の決定において重要な役割を果たし得ることを実感しましたし、実際にクラスや関数の責務をどう分割するか? の問題にあまり悩まずにスパッと決まることを体験できました。
上記に加え、なるべく単体テストで品質をカバーする試みのよさも実感できました。リリースを重ねるごとにe2eに比べて
- 実行時間が短い
- メンテナンスが容易
- 楽に動かせる
- どこでも動く(実行環境の相違に左右されにくい)
- 結果の精度が非常に高い
と言った単体テストの良さが身に染みます。e2eは作成もメンテも失敗した際の調査も重い作業になりがちなので、それらを簡単にできる方法が見つかるまでは「まず単体テストで極力品質を担保できないか?」を最初に検討しようと思う次第です。
お気づきの方も多いかと思いますが、本稿で中心的な話題としている「e2e vs 単体テスト」、「mock vs 本物」のような議論は目新しいものではなく「単体テストの考え方/使い方」の内容などで既に十分述べられているものになります。もし、このあたりの議論をもっと知りたい場合は、ご一読をおすすめします。
テストの方針についてはプロダクトの性質や機能要件/非機能要件、プロジェクトの状況やチームの成熟度など様々な変数によって様々な判断があり得ると思います。本稿で述べたソリューションが上手く当てはまるケースは多くないかもしれませんが、何かの参考になれば幸いです。
以上です。次回最終日の大トリは弊社Engineering Office稀代のエース@okapallさんです。みなさま、どうぞよいクリスマスイブをお過ごしください。