Skip to main content

第31章:分散っぽいバグを捕まえるテスト(故障注入)🧪🎲

この章でできるようになること ✅✨

  • 遅延⏳・失敗💥・分断🔌・重複📨・順序ズレ🔀 を テストでわざと起こす
  • “壊れるのが普通”な状況でも落ちないように 直すポイントが分かる🛠️
  • ランダム要素があるのに テストがフレーク(たまに落ちる)にならないコツが身につく🍀

分散テスト


1) まず大事な前提:分散っぽいバグは「再現できた瞬間に勝ち」🏆😆

分散のつらさは、だいたいこの5つに集約されるよ👇

  • 遅延する(しかも毎回違う)🐢⚡
  • たまに失敗する(しかも途中まで成功してたりする)💥
  • 分断する(片方だけ見えてる世界)🔌🌍
  • 同じものが複数回来る(再送・リトライの副作用)📨📨📨
  • 順序が入れ替わる(“先に来るはず”が崩れる)🔀😵‍💫

だからテストのゴールはシンプル👇 **「その5つをテストで再現して、落ちない仕様に直す」**🧪➡️🛠️➡️✅


2) 故障注入(Fault Injection)ってなに?🎲🧪

わざと“悪いネットワークっぽさ”を入れることだよ!

今回入れたい故障パターン(最小セット)🧰✨

  1. ランダム遅延 ⏳(0〜500msみたいにブレる)
  2. ランダム失敗 💥(一定確率でエラーにする)
  3. 分断(ドロップ) 🔌(送ったイベントを捨てる)
  4. 重複配達 📨(同じイベントを2〜3回流す)
  5. 順序シャッフル 🔀(キューで並び替えて届ける)

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段階に分けるとラクだよ😊

  1. ユニットテスト:小さい関数(冪等キー生成、状態遷移)🧩
  2. プロセス内統合テスト:API→Queue→Worker→DBを“同一プロセスで”再現📦
  3. 性質(プロパティ)テスト:いろんな入力と順序で「壊れない性質」を検証🌀

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行 ✍️✨

分散っぽいバグは “故障を起こすテスト” を持った瞬間から、怖さが半分になるよ🧪🎲➡️✅