第31章:分散っぽいバグを捕まえるテスト(故障注入)🧪🎲
この章でできるようになること ✅✨
- 遅延⏳・失敗💥・分断🔌・重複📨・順序ズレ🔀 を テストでわざと起こす
- “壊れるのが普通”な状況でも落ちないように 直すポイントが分かる🛠️
- ランダム要素があるのに テストがフレーク(たまに落ちる)にならないコツが身につく🍀

1) まず大事な前提:分散っぽいバグは「再現できた瞬間に勝ち」🏆😆
分散のつらさは、だいたいこの5つに集約されるよ👇
- 遅延する(しかも毎回違う)🐢⚡
- たまに失敗する(しかも途中まで成功してたりする)💥
- 分断する(片方だけ見えてる世界)🔌🌍
- 同じものが複数回来る(再送・リトライの副作用)📨📨📨
- 順序が入れ替わる(“先に来るはず”が崩れる)🔀😵💫
だからテストのゴールはシンプル👇 **「その5つをテストで再現して、落ちない仕様に直す」**🧪➡️🛠️➡️✅
2) 故障注入(Fault Injection)ってなに?🎲🧪
わざと“悪いネットワークっぽさ”を入れることだよ!
今回入れたい故障パターン(最小セット)🧰✨
- ランダム遅延 ⏳(0〜500msみたいにブレる)
- ランダム失敗 💥(一定確率でエラーにする)
- 分断(ドロップ) 🔌(送ったイベントを捨てる)
- 重複配達 📨(同じイベントを2〜3回流す)
- 順序シャッフル 🔀(キューで並び替えて届ける)
3) 方針:アプリ本体を汚さず、テスト側から“故障スイッチ”を差し込む🎛️✨
おすすめはこの2レイヤーで作ること👇
- (A) ユーティリティ層:遅延/失敗/重複/順序ズレを作る部品🧩
- (B) 使う場所:HTTP呼び出し、キュー、DBアクセス…の“境界”に差し込む🚪
特にテストで効く境界はこの3つ👇
- API → Worker への「イベント/ジョブ投入」📮
- Worker → DB の「更新」🗃️
- API → 外部サービスの「HTTP」🌐(外部はモックしやすい)
4) ハンズオン①:故障注入ユーティリティを作る🧰🎲
4-1. “テスト専用の故障設定”を用意する🎛️
テスト中だけONにしたいので、設定は 1つのオブジェクト にまとめるのが楽だよ😊
// test/faults/faultConfig.ts
export type FaultConfig = {
enabled: boolean;
// 遅延
delayMsMin: number;
delayMsMax: number;
// 失敗(0.0〜1.0)
failRate: number;
// 分断(ドロップ)
dropRate: number;
// 重複配達
duplicateRate: number;
// 順序シャッフル(キュー側で使う想定)
shuffleRate: number;
// テストを再現できるようにするための seed
seed: number;
};
export const defaultFaultConfig: FaultConfig = {
enabled: false,
delayMsMin: 0,
delayMsMax: 0,
failRate: 0,
dropRate: 0,
duplicateRate: 0,
shuffleRate: 0,
seed: 1,
};
4-2. “seed付き疑似乱数”でテストを安定させる🍀🎯
Math.random()のままだと「たまに落ちる」地獄になりがち😇
なので、簡単なseed付きPRNGを自前で用意しよう!
// test/faults/prng.ts
export class PRNG {
private state: number;
constructor(seed: number) {
this.state = seed >>> 0;
}
// 0.0 <= x < 1.0
nextFloat(): number {
// LCG (超簡易) : テスト用途ならこれで十分
this.state = (1664525 * this.state + 1013904223) >>> 0;
return this.state / 0x100000000;
}
nextInt(min: number, max: number): number {
const f = this.nextFloat();
return Math.floor(min + f * (max - min + 1));
}
}
4-3. 遅延・失敗・ドロップ・重複を“1か所で”適用する🎲
「送る直前」に差し込むのがいちばん分かりやすいよ📮✨
// test/faults/applyFaults.ts
import { FaultConfig } from "./faultConfig";
import { PRNG } from "./prng";
export type FaultResult<T> =
| { type: "dropped" }
| { type: "ok"; items: T[] };
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
export async function applyFaults<T>(
config: FaultConfig,
prng: PRNG,
item: T
): Promise<FaultResult<T>> {
if (!config.enabled) return { type: "ok", items: [item] };
// 遅延
const delay = prng.nextInt(config.delayMsMin, config.delayMsMax);
if (delay > 0) await sleep(delay);
// 失敗(例外にする)
if (prng.nextFloat() < config.failRate) {
throw new Error("Injected fault: random failure 💥");
}
// ドロップ(分断っぽい)
if (prng.nextFloat() < config.dropRate) {
return { type: "dropped" };
}
// 重複配達(同じものをもう一回)
if (prng.nextFloat() < config.duplicateRate) {
return { type: "ok", items: [item, item] };
}
return { type: "ok", items: [item] };
}
5) ハンズオン②:キューに“故障注入版ラッパー”をかぶせる📮🎲
API→Workerの境界はキューが多いので、ここに入れるとめちゃ効く!✨
5-1. InMemoryQueue(例)に、ドロップ/重複/順序ズレを入れる🔀📨
// test/faults/faultyQueue.ts
import { FaultConfig } from "./faultConfig";
import { PRNG } from "./prng";
import { applyFaults } from "./applyFaults";
export type Queue<T> = {
push(item: T): Promise<void>;
pop(): Promise<T | undefined>;
size(): number;
};
export function createFaultyQueue<T>(
base: Queue<T>,
config: FaultConfig
): Queue<T> {
const prng = new PRNG(config.seed);
return {
async push(item) {
const r = await applyFaults(config, prng, item);
if (r.type === "dropped") return;
// 重複の可能性を含む items を投入
for (const it of r.items) {
await base.push(it);
}
// たまに順序をシャッフル(キューの中身を混ぜる)
if (config.enabled && prng.nextFloat() < config.shuffleRate) {
// baseがInMemory実装なら “中身を取り出して混ぜて戻す” ができるようにしておくと◎
// ここでは「シャッフルできるbase」を渡す前提にしてもOK(実装はプロジェクトに合わせてね)
}
},
pop: base.pop,
size: base.size,
};
}
ポイント💡 “分散っぽい”は 境界に入れるのがコツ。内部ロジックにランダムを入れるとテストが読みにくくなるよ😵💫
6) ハンズオン③:テスト基盤(Vitest + MSW)を整える🧪🧰
HTTPモックは MSW が定番ルートだよ✨ Vitest公式でも「リクエストのモックにMSWがおすすめ」って書かれてる📝(Vitest) MSWはNode環境でHTTP/HTTPSなどをパッチして外向き通信を観測・制御できるよ(Mock Service Worker) Vitestの導入/基本も公式ガイドが分かりやすいよ(Vitest)
6-1. MSW(Node)最小セットアップ例 🧪🌐
// test/msw/server.ts
import { setupServer } from "msw/node";
import { http, HttpResponse, delay } from "msw";
export const server = setupServer(
http.post("https://payments.example/charge", async () => {
await delay(200); // 遅延も入れられる
return HttpResponse.json({ ok: true, paymentId: "p_123" });
})
);
// test/setup.ts
import { beforeAll, afterAll, afterEach } from "vitest";
import { server } from "./msw/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
MSW + Vitestのセットアップ手順は、MSW公式のQuick startでも案内されてるよ(Mock Service Worker)
7) “分散っぽいバグ”を捕まえるテスト設計(型)📐✨
テストは3段階に分けるとラクだよ😊
- ユニットテスト:小さい関数(冪等キー生成、状態遷移)🧩
- プロセス内統合テスト:API→Queue→Worker→DBを“同一プロセスで”再現📦
- 性質(プロパティ)テスト:いろんな入力と順序で「壊れない性質」を検証🌀
Property-based testing をやるなら fast-check が定番で、JS/TSで使えるよ(fast-check.dev)
8) ハンズオン④:故障注入ONでも落ちない統合テストを書こう🧪🔥
8-1. まずは“守りたい性質”を1行で書く✍️✨
例(注文ドメイン)👇
- 同じ注文イベントが複数回届いても、在庫は1回分しか減らない🧷✅
- **支払い成功イベントが先に来ても、最終的に注文が確定できる(または再試行される)**🔀✅
- 一時的にキューが落ちても、復帰後に収束する🔌➡️✅
8-2. 例:重複配達でも在庫が二重に減らないテスト📨🧪
// test/order.duplicates.test.ts
import { describe, it, expect } from "vitest";
import { defaultFaultConfig } from "./faults/faultConfig";
import { createFaultyQueue } from "./faults/faultyQueue";
// 例:プロジェクト側の関数(仮)
// - apiPlaceOrder: 注文受付してイベントをpush
// - workerDrainOnce: キューから取り出して1件処理
// - readInventory: 在庫数を読む
import { createInMemoryQueue } from "../src/infra/inMemoryQueue";
import { createApp } from "../src/app/createApp";
describe("重複配達でも壊れない(冪等)🧷", () => {
it("同じイベントが2回届いても在庫は1回しか減らない📦", async () => {
const baseQueue = createInMemoryQueue<any>();
const faultConfig = {
...defaultFaultConfig,
enabled: true,
duplicateRate: 1.0, // 必ず重複させる
seed: 42,
};
const queue = createFaultyQueue(baseQueue, faultConfig);
const app = createApp({ queue }); // queueをDIできる設計が最高✨
const before = await app.readInventory("item_apple");
await app.apiPlaceOrder({ orderId: "o_1", itemId: "item_apple", qty: 1 });
// Workerを2回回しても(重複分があっても)結果は1回分
await app.workerDrainOnce();
await app.workerDrainOnce();
const after = await app.readInventory("item_apple");
expect(after).toBe(before - 1);
});
});
ここで落ちたら🎯 「イベントの冪等処理(重複排除)」が足りてないサインだよ😱 次の節の“直し方テンプレ”で治せる!
9) よく落ちるところの“直し方テンプレ”🛠️💖
9-1. 重複配達で二重反映する → 処理済みイベントIDを保存する🧷
- Worker側で
eventIdを必ず持つ - 処理済みならスキップ(DBに記録が理想)
// src/domain/processedEventStore.ts(例)
export interface ProcessedEventStore {
has(eventId: string): Promise<boolean>;
mark(eventId: string): Promise<void>;
}
// Workerの処理(例)
export async function handleEvent(event: { eventId: string; type: string }, store: ProcessedEventStore) {
if (await store.has(event.eventId)) return; // ✅ 重複排除
// ここで在庫減算などの副作用
// ...
await store.mark(event.eventId);
}
9-2. 順序ズレで壊れる → 状態機械(State Machine)でガードする🔀🧠
「今そのイベントを適用していい状態?」をチェックして、ダメなら再試行へ🔁 (例:OrderCreated前にPaymentConfirmedが来た)
-
受けられないなら
- 保留(pending)に積む📥
- 再取得・再試行🔁
- DLQ(隔離)へ🗑️(次章以降の運用でも使う)
9-3. 分断(ドロップ)で収束しない → 再送/再同期の道を用意する🔌➡️✅
-
キューが落ちても「いつか追いつける」ルートが必要
-
例:
- 定期的に「未確定注文」を再走査して補正する🧹
- “イベントを取りこぼした”前提で 整合性回復ジョブを作る🧰
10) “ランダム”と仲良くしてテストをフレークにしないコツ🍀😆
- ランダムは seed固定(この章でやったPRNG方式が強い)🎯
- 「確率」に頼らず、確実に起こす(failRate=1.0など) テストも作る💥
- 壊れ方を増やす時は、1回に全部盛りしないで 1種類ずつ👶✨
- 失敗したら “どの故障設定だったか” をログに出す📋🕵️♀️
11) もう一歩:性質テスト(fast-check)で“想定外”を拾う🌀🔍
例:「イベントがどんな順序で来ても、最終的に在庫がマイナスにならない」みたいな“性質”をチェックするやつだよ😊 fast-check はJS/TSのProperty-based Testingフレームワークとして定番だよ(fast-check.dev)
(ここは最初は無理しなくてOK!でも一度やると世界が広がる🌈)
12) AIの使いどころ(この章向き)🤖✅
12-1. テスト観点の洗い出し(最強)📝✨
Copilot/Codexにこんな感じで頼むと良いよ👇
- 「注文〜在庫反映までの流れで、遅延/失敗/重複/順序ズレ/ドロップが起きたときのバグパターンを列挙して」🧠
- 「各バグパターンを再現する最小のテストケース案を出して」🧪
12-2. “落ちた原因の推理”を手伝わせる🕵️♀️
- 「このログから、重複配達で二重減算してる可能性ある?」みたいに聞くと速い⚡
13) 発展:本物のネットワーク障害をE2Eで試したい人へ🌐🔥
本気のネットワーク故障(遅延、切断、帯域制限など)を“プロキシで注入”するなら Toxiproxy みたいな道具があるよ🧪🔌(ShopifyのOSS)(GitHub) この章では「まずプロセス内で再現できる」がゴールだから、余裕が出たらでOK😊✨
この章の結論1行 ✍️✨
分散っぽいバグは “故障を起こすテスト” を持った瞬間から、怖さが半分になるよ🧪🎲➡️✅