なぜ「テストしやすい非同期」が大事なのか
非同期処理って、バグが出ても再現しづらいし、
「たまたま動いた」状態になりがちです。
だからこそ、
「テストしやすい形で非同期コードを書く」
という発想がめちゃくちゃ重要になります。
テストしやすい非同期コードは、
動作が安定しやすい
リファクタリングしやすい
バグが出ても原因を絞り込みやすい
という、長期的に効いてくるメリットを持っています。
ここでは、初心者でも意識できる「テストしやすさのポイント」を、
具体例と一緒に整理していきます。
テストしづらい非同期コードの典型例
なんでもかんでも中で完結してしまうパターン
まずは「テストしづらい」コードから見てみます。
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本番では setTimeoutImpl に setTimeout を渡します。
テストでは、
「呼ばれたかどうか」だけを確認できます。
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 をテスト
JavaScriptUI 側はこうなります。
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 など)を、テスト時に差し替えられるようにしているか?
おすすめの練習は、
自分の非同期関数を一つ選んで、
外部依存を全部リストアップする
それを「引数」か「別関数」に追い出してみる
ということをやってみることです。
一度「テストしやすい形」に変えてみると、
コードの見通しも良くなるし、
非同期処理の設計そのものへの理解も一段深くなります。
それができるようになったとき、
あなたはもう「非同期をなんとなく書く人」ではなく、
「テストまで含めて非同期を設計できるエンジニア」 に近づいています。
