JavaScript | 非同期処理:設計・理解の深化 - テストしやすい非同期

JavaScript JavaScript
スポンサーリンク

なぜ「テストしやすい非同期」が大事なのか

非同期処理って、バグが出ても再現しづらいし、
「たまたま動いた」状態になりがちです。

だからこそ、
「テストしやすい形で非同期コードを書く」
という発想がめちゃくちゃ重要になります。

テストしやすい非同期コードは、

動作が安定しやすい
リファクタリングしやすい
バグが出ても原因を絞り込みやすい

という、長期的に効いてくるメリットを持っています。

ここでは、初心者でも意識できる「テストしやすさのポイント」を、
具体例と一緒に整理していきます。


テストしづらい非同期コードの典型例

なんでもかんでも中で完結してしまうパターン

まずは「テストしづらい」コードから見てみます。

async function initPage() {
  const res = await fetch("/api/user");
  if (!res.ok) {
    alert("ユーザー取得に失敗しました");
    return;
  }
  const user = await res.json();

  const header = document.querySelector("#header");
  header.textContent = `こんにちは、${user.name}さん`;
}
JavaScript

この関数をテストしようとすると、

fetch をどうやって差し替えるか
alert をどうやって止めるか
DOM をどうやって用意するか

など、いきなりハードルが高くなります。

理由はシンプルで、
「外部との依存(ネットワーク・UI)が、関数の中にべったり埋め込まれている」
からです。

テストしやすくするには、
この「依存」を外に出してあげる必要があります。


テストしやすさの基本:純粋な「ロジック」と「外部依存」を分ける

非同期でも「ロジック部分」は同期にできる

例えば、ユーザー情報から表示用のテキストを作る処理を考えます。

function formatUserGreeting(user) {
  if (!user) return "ゲストさん、こんにちは";
  return `${user.name}さん、こんにちは`;
}
JavaScript

これは完全に同期で、外部依存もありません。
テストは超簡単です。

formatUserGreeting(null)         // "ゲストさん、こんにちは"
formatUserGreeting({ name: "太郎" }) // "太郎さん、こんにちは"
JavaScript

ここで大事なのは、
「非同期処理の中にも、“ただのロジック”の部分が必ずある」
ということです。

そのロジック部分を関数として切り出しておけば、
そこだけを単体テストできます。

非同期関数の中に、
fetch と DOM 操作とロジックが全部混ざっていると、
テストが一気に難しくなります。


依存を注入する:テスト時に差し替えられるようにする

fetch を直接呼ばない形にする

さっきの「テストしづらい」例を、
テストしやすい形に変えてみます。

まず、データ取得を関数として分けます。

async function fetchCurrentUser(fetchImpl) {
  const res = await fetchImpl("/api/user");
  if (!res.ok) {
    throw new Error("ユーザー取得に失敗しました");
  }
  return res.json();
}
JavaScript

ここでのポイントは、
fetch を直接使わず、fetchImpl を引数として受け取っていることです。

本番コードではこう呼びます。

const user = await fetchCurrentUser(fetch);
JavaScript

テストでは、fetch の代わりに「ダミーの fetch」を渡せます。

async function fakeFetch() {
  return {
    ok: true,
    json: async () => ({ name: "テスト太郎" }),
  };
}

const user = await fetchCurrentUser(fakeFetch);
// user.name が "テスト太郎" であることをテストできる
JavaScript

これが「依存性の注入(DI)」という考え方です。

ここが重要です。
「非同期関数の中で外部 API を直接呼ぶのではなく、“外から渡してもらう”形にすると、テスト時に差し替えられる。」

UI 更新も「関数として渡す」

同じように、UI 更新も外から渡せます。

async function initPage({ fetchImpl, renderHeader, showError }) {
  try {
    const user = await fetchCurrentUser(fetchImpl);
    renderHeader(user);
  } catch (err) {
    showError(err.message);
  }
}
JavaScript

本番ではこう呼びます。

initPage({
  fetchImpl: fetch,
  renderHeader(user) {
    const header = document.querySelector("#header");
    header.textContent = `こんにちは、${user.name}さん`;
  },
  showError(message) {
    alert(message);
  },
});
JavaScript

テストでは、こう書けます。

const calls = {
  renderHeader: [],
  showError: [],
};

await initPage({
  fetchImpl: fakeFetch,
  renderHeader(user) {
    calls.renderHeader.push(user);
  },
  showError(message) {
    calls.showError.push(message);
  },
});

// calls.renderHeader に 1 回だけ呼ばれているか
// calls.showError が呼ばれていないか
// などをテストできる
JavaScript

非同期処理そのものは変わっていませんが、
「外部との接点を全部“引数”に追い出した」
ことで、テストが一気にやりやすくなります。


時間に依存する非同期をテストしやすくする

setTimeout / setInterval を直接使わない

例えば、こういうコードを考えます。

function showMessageWithDelay(message) {
  setTimeout(() => {
    console.log(message);
  }, 1000);
}
JavaScript

これをテストしようとすると、
「1秒待つ」のが面倒です。

ここでも同じ発想で、
「時間の仕組み」を外から渡せるようにします。

function showMessageWithDelay(message, { setTimeoutImpl }) {
  setTimeoutImpl(() => {
    console.log(message);
  }, 1000);
}
JavaScript

本番では setTimeoutImplsetTimeout を渡します。

テストでは、
「呼ばれたかどうか」だけを確認できます。

const calls = [];

function fakeSetTimeout(fn, ms) {
  calls.push({ fn, ms });
}

showMessageWithDelay("hello", { setTimeoutImpl: fakeSetTimeout });

// calls[0].ms が 1000 かどうか
// calls[0].fn を自分で呼んで console.log の結果を確認する、など
JavaScript

ここが重要です。
「時間に依存する非同期処理も、“時間の仕組み”を注入できるようにしておくとテストしやすくなる。」


非同期の「結果」をテストする形にする

副作用だけでなく「戻り値」も設計する

テストしづらい非同期関数は、
「中で全部やってしまって、何も返さない」ことが多いです。

async function loadAndRenderUser() {
  const res = await fetch("/api/user");
  const user = await res.json();
  renderUser(user);
}
JavaScript

これだと、テストしたいときに
renderUser をモックするしかありません。

一方で、
「結果を返す」形にしておくと、テストが楽になります。

async function loadUser(fetchImpl) {
  const res = await fetchImpl("/api/user");
  return res.json();
}
JavaScript

テストでは、
「この関数が返す値」だけを見ればよくなります。

const user = await loadUser(fakeFetch);
// user の中身をテスト
JavaScript

そして、UI 側ではこう使います。

async function initPage() {
  const user = await loadUser(fetch);
  renderUser(user);
}
JavaScript

ここが重要です。
「非同期関数は、できるだけ“テストしやすい戻り値”を持たせる。
副作用だけに頼らない。」


「テストしやすい非同期」を意識したミニ例題

悪い例から、テストしやすい形への変換

悪い例:

async function saveSettings(settings) {
  const res = await fetch("/api/settings", {
    method: "POST",
    body: JSON.stringify(settings),
  });

  if (!res.ok) {
    alert("保存に失敗しました");
    return;
  }

  alert("保存しました");
}
JavaScript

これをテストしようとすると、

fetch をどうモックするか
alert をどう止めるか

など、いきなり大変です。

テストしやすい形に変えてみます。

async function postSettings(fetchImpl, settings) {
  const res = await fetchImpl("/api/settings", {
    method: "POST",
    body: JSON.stringify(settings),
  });

  if (!res.ok) {
    return { ok: false, error: new Error("保存に失敗しました") };
  }

  return { ok: true };
}
JavaScript

これは「非同期 API」としてテストできます。

const result = await postSettings(fakeFetch, { theme: "dark" });
// result.ok や result.error をテスト
JavaScript

UI 側はこうなります。

async function saveSettingsWithUI(settings) {
  const result = await postSettings(fetch, settings);
  if (!result.ok) {
    alert(result.error.message);
  } else {
    alert("保存しました");
  }
}
JavaScript

非同期処理そのものは変わっていませんが、

外部依存(fetch, alert)を分離した
戻り値で成功・失敗を表現した

ことで、
「テストしやすい非同期」に変わっています。


初心者として「テストしやすい非同期」で意識してほしいこと

最後に、あなたに持っていてほしい問いをまとめます。

この非同期関数の中に、「外部依存(fetch, DOM, alert など)」がべったり埋まっていないか?
その依存を「引数として渡せる形」にできないか?
ロジック部分だけを取り出して、同期関数としてテストできないか?
この非同期関数は、何か意味のある「戻り値」を返せる形にできないか?
時間に依存する処理(setTimeout など)を、テスト時に差し替えられるようにしているか?

おすすめの練習は、
自分の非同期関数を一つ選んで、

外部依存を全部リストアップする
それを「引数」か「別関数」に追い出してみる

ということをやってみることです。

一度「テストしやすい形」に変えてみると、
コードの見通しも良くなるし、
非同期処理の設計そのものへの理解も一段深くなります。

それができるようになったとき、
あなたはもう「非同期をなんとなく書く人」ではなく、
「テストまで含めて非同期を設計できるエンジニア」 に近づいています。

タイトルとURLをコピーしました