zukucode
主にWEB関連の情報を技術メモとして発信しています。

TypeScript ブラウザー操作を切り離して処理をテストしやすくする

保存後に画面を再読み込みする処理をテストしたいのに、テストの途中で本当に再読み込みが起きると、確認を続けにくくなります。

また、Node.jsで実行するテストには、ブラウザーのwindowがそのまま存在するわけではありません。

今回は、業務処理とブラウザーを操作する部分の境界を、小さな関数で作る方法を紹介します。

保存が成功した場合だけ再読み込みする処理を例にします。再読み込みを小さな関数へ分け、テストでは呼び出しを記録する関数へ置き換えます。

直接呼ぶとテスト対象とブラウザーが結び付く

次のような処理を考えます。

直接再読み込みする例
export const saveAndReload = async (save: () => Promise<void>) => {
  await save();
  window.location.reload();
};

本当に確かめたいのは、「保存が成功した後だけ再読み込みするか」という順序と条件です。

しかし、この関数をそのまま呼ぶとブラウザー操作まで実行されるため、その環境も準備する必要があります。

ブラウザー操作を小さな関数へ分ける

まず、再読み込みだけを担当する関数を用意します。

browserNavigation.ts
export const reloadBrowser = (): void => {
  window.location.reload();
};

1行を移しただけに見えますが、呼び出し側がブラウザーのAPIを直接触らなくなるため、置き換える場所が明確になります。

再読み込みやページ移動を共通のファイルへまとめると、画面のテストで、そのモジュールをテスト用に置き換える方法も使えます。

モジュールは、ここではimportできるファイルの単位と考えてください。Vitestならvi.mockで置き換えられます。方法の詳細は、Vitest公式ドキュメントで確認できます。

簡単な例では関数を引数で渡せる

この記事では、特定のテストツールを使わなくても試せるように、再読み込み関数を引数で渡せる形にします。モジュール全体を置き換える代わりに、使う関数だけを外から渡す方法です。

saveAndReload.ts
import { reloadBrowser } from './browserNavigation';

export const saveAndReload = async (
  save: () => Promise<void>,
  reload: () => void = reloadBrowser,
): Promise<void> => {
  await save();
  reload();
};

普段は第2引数を省略すれば、実際の再読み込み関数が使われます。テストでは、呼び出されたことだけを記録する関数を渡します。

この形は、必要な処理を外から渡すという意味で、依存性の注入の一例です。専用のフレームワークが必須というわけではありません。

成功した場合の順序を確認する

次の例は、同じディレクトリへ置く確認用コードです。TypeScriptを実行またはコンパイルできる環境で使用します。

saveAndReload.check.ts
import { 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.ts
import { 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.ts
import { 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つで、実ブラウザーでの確認だけで十分なら、追加の関数が不要なこともあります。

小さな関数へ分ける価値は行数を減らすことではありません。保存の順序や失敗時の動作を確認するときに、ブラウザーの操作まで一緒に実行しなくて済むことです。


関連記事