TypeScript ブラウザー操作を切り離して処理をテストしやすくする
保存後に画面を再読み込みする処理をテストしたいのに、テストの途中で本当に再読み込みが起きると、確認を続けにくくなります。
また、Node.jsで実行するテストには、ブラウザーのwindowがそのまま存在するわけではありません。
今回は、業務処理とブラウザーを操作する部分の境界を、小さな関数で作る方法を紹介します。
保存が成功した場合だけ再読み込みする処理を例にします。再読み込みを小さな関数へ分け、テストでは呼び出しを記録する関数へ置き換えます。
直接呼ぶとテスト対象とブラウザーが結び付く
次のような処理を考えます。
直接再読み込みする例export const saveAndReload = async (save: () => Promise<void>) => {
await save();
window.location.reload();
};本当に確かめたいのは、「保存が成功した後だけ再読み込みするか」という順序と条件です。
しかし、この関数をそのまま呼ぶとブラウザー操作まで実行されるため、その環境も準備する必要があります。
ブラウザー操作を小さな関数へ分ける
まず、再読み込みだけを担当する関数を用意します。
browserNavigation.tsexport const reloadBrowser = (): void => {
window.location.reload();
};1行を移しただけに見えますが、呼び出し側がブラウザーのAPIを直接触らなくなるため、置き換える場所が明確になります。
再読み込みやページ移動を共通のファイルへまとめると、画面のテストで、そのモジュールをテスト用に置き換える方法も使えます。
モジュールは、ここではimportできるファイルの単位と考えてください。Vitestならvi.mockで置き換えられます。方法の詳細は、Vitest公式ドキュメントで確認できます。
簡単な例では関数を引数で渡せる
この記事では、特定のテストツールを使わなくても試せるように、再読み込み関数を引数で渡せる形にします。モジュール全体を置き換える代わりに、使う関数だけを外から渡す方法です。
saveAndReload.tsimport { reloadBrowser } from './browserNavigation';
export const saveAndReload = async (
save: () => Promise<void>,
reload: () => void = reloadBrowser,
): Promise<void> => {
await save();
reload();
};普段は第2引数を省略すれば、実際の再読み込み関数が使われます。テストでは、呼び出されたことだけを記録する関数を渡します。
この形は、必要な処理を外から渡すという意味で、依存性の注入の一例です。専用のフレームワークが必須というわけではありません。
成功した場合の順序を確認する
次の例は、同じディレクトリへ置く確認用コードです。TypeScriptを実行またはコンパイルできる環境で使用します。
saveAndReload.check.tsimport { saveAndReload } from './saveAndReload';
export const checkSuccess = async (): Promise<void> => {
const events: string[] = [];
await saveAndReload(
async () => {
events.push('save-start');
await Promise.resolve();
events.push('save-end');
},
() => {
events.push('reload');
},
);
if (events.join(',') !== 'save-start,save-end,reload') {
throw new Error('保存完了前に再読み込みされています。');
}
};保存完了の後に再読み込みの依頼があることを確認しています。await save()を誤ってsave()へ変えた場合にも、この順序の確認が役立ちます。
ここでは、reloadが実際にブラウザーを動かさず、配列へ記録するだけなので、確認用コードの実行を続けられます。
失敗した場合に実行しないことも確認する
saveAndReload.failure.check.tsimport { saveAndReload } from './saveAndReload';
export const checkFailure = async (): Promise<void> => {
const saveError = new Error('保存できませんでした。');
let reloadCalled = false;
let caught: unknown;
try {
await saveAndReload(
async () => {
throw saveError;
},
() => {
reloadCalled = true;
},
);
} catch (error) {
caught = error;
}
if (caught !== saveError || reloadCalled) {
throw new Error('保存失敗時の動作が想定と異なります。');
}
};この例のsaveは、失敗したら例外を投げる約束です。成功・失敗を戻り値で返すAPIなら、その結果を確認する分岐が必要です。
また、例外を受け取った画面側では、エラーメッセージを表示します。この小さな関数には、メッセージの見た目まで持たせていません。
確認用の関数は、定義するだけでは実行されません。次のファイルから呼び出します。
run-checks.tsimport { checkSuccess } from './saveAndReload.check';
import { checkFailure } from './saveAndReload.failure.check';
const main = async (): Promise<void> => {
await checkSuccess();
await checkFailure();
console.log('成功時と失敗時の確認が完了しました。');
};
void main();自動確認と実ブラウザーの確認を分ける
関数を置き換えたテストでは、本当の再読み込みは確認できません。
| 確認方法 | 確認できること |
|---|---|
| 保存処理と再読み込みを置き換える | 呼ぶ条件、呼ぶ順序、失敗時の扱い |
| 実ブラウザーで操作する | 再読み込み後の表示、ログイン状態、通信とのつながり |
ページ移動にも同じ方法を使えますが、遷移先のURLを信用してよいかは別の問題です。関数へまとめただけで、URLの検証や権限の確認が行われるわけではありません。
また、普通の保存で毎回再読み込みする必要があるとは限りません。画面の状態やキャッシュを更新する方が自然な場合もあります。ここでは、再読み込みが必要な場合のテスト方法を説明しています。
すべてのAPIを包む必要はない
テストのためにwindowの全機能を同じ形で包むと、実装も呼び出し方も増えてしまいます。
最初は、再読み込み、ページ移動など、テストすると実行環境へ影響する操作に絞れます。呼び出し箇所が1つで、実ブラウザーでの確認だけで十分なら、追加の関数が不要なこともあります。
小さな関数へ分ける価値は行数を減らすことではありません。保存の順序や失敗時の動作を確認するときに、ブラウザーの操作まで一緒に実行しなくて済むことです。