第49章:fetch境界(ネット無しでテスト)🌐

🎯この章でできるようになること
fetchを そのまま直呼びせず、差し替え可能な「境界」にできるようになる🔌✨- ネット無しで、成功/404/500/通信失敗 をテストできるようになる🧪💪
- 「HTTPの失敗」を 設計として整理して、呼び出し側が扱いやすい形にできる🧩💡
ちなみに Node の
fetchは内部で Undici が使われる前提で説明するよ🫶(だから“ネットワーク層は別物”として分けると、テストが安定しやすい!) (Node.js)
🧠まずイメージ:なぜ fetch 直呼びがツラいの?😵💫

fetch をビジネスロジックの真ん中で呼ぶと…
- テストがネット状況に左右される(落ちる/遅い/たまに失敗)💥
- 404/500 の扱いが 各所に散らかる(if地獄)🌀
- テストで「APIが返す値」を作るのが大変(毎回 Response 作るのしんどい)🫠
なのでこの章は、fetch を“境界”に追い出して、ロジック中心を守るよ🏰✨
✅今回の方針:fetch を注入(DI)して差し替える🔁
ポイントはこれだけ👇💗
- 本番:
new HttpClient(fetch)(本物のfetchを渡す) - テスト:
new HttpClient(fakeFetch)(偽物fetchを渡す)
これで ネット無しで回るようになるよ〜!🚀
🧪ハンズオン:HTTPラッパーをTDDで作ろう(成功/404/500/通信失敗)
ここでは「取得してJSONを返す」最小の HttpClient を作るよ🧁
戻り値は Result型(成功/失敗を値で返す)にするね✨
0) ファイル構成📁
こんな感じでOK!(名前は好みで👍)
src/
http/
httpClient.ts
tests/
httpClient.test.ts
1) まずテストを書く(Red)🔴🧪
「200ならJSONが取れる」からいくよ〜!
// tests/httpClient.test.ts
import { describe, it, expect, vi } from "vitest";
import { HttpClient, type FetchLike } from "../src/http/httpClient";
const makeResponse = (status: number, body: unknown) => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
});
describe("HttpClient.getJson", () => {
it("200ならokでJSONを返す😊", async () => {
const fetcher: FetchLike = vi.fn().mockResolvedValue(
makeResponse(200, { name: "Alice" })
);
const client = new HttpClient(fetcher);
const result = await client.getJson<{ name: string }>("https://example.test/users/1");
expect(result).toEqual({ ok: true, value: { name: "Alice" } });
});
});
🔎 ここで大事:fetcher は Responseっぽいものを返せばOK!
本物の Response を頑張って作らなくていいよ🥹✨(テストが軽い!)
2) 最小実装(Green)🟢✨
次に実装するよ!
// src/http/httpClient.ts
export type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
export type HttpProblem =
| { kind: "not_found"; status: 404; url: string }
| { kind: "server_error"; status: number; url: string }
| { kind: "http_error"; status: number; url: string }
| { kind: "network_error"; url: string; message: string };
export type ResponseLike = {
ok: boolean;
status: number;
json(): Promise<unknown>;
text(): Promise<string>;
};
export type FetchInit = {
method?: string;
headers?: Record<string, string>;
body?: string;
};
export type FetchLike = (url: string, init?: FetchInit) => Promise<ResponseLike>;
export class HttpClient {
constructor(private readonly fetcher: FetchLike) {}
async getJson<T>(url: string): Promise<Result<T, HttpProblem>> {
try {
const res = await this.fetcher(url, { method: "GET" });
if (res.ok) {
const data = (await res.json()) as T;
return { ok: true, value: data };
}
if (res.status === 404) {
return { ok: false, error: { kind: "not_found", status: 404, url } };
}
if (res.status >= 500) {
return { ok: false, error: { kind: "server_error", status: res.status, url } };
}
return { ok: false, error: { kind: "http_error", status: res.status, url } };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { ok: false, error: { kind: "network_error", url, message } };
}
}
}
💡これで「ネットが落ちたら例外で落ちる」じゃなくて、失敗も値で返るから呼び出し側が扱いやすいよ🫶✨
3) 404のテスト追加(Red→Green)🔴🟢
「見つからない」はよくあるから、仕様として固定しよ🎯
it("404ならnot_foundになる🥺", async () => {
const fetcher: FetchLike = vi.fn().mockResolvedValue(
makeResponse(404, { message: "not found" })
);
const client = new HttpClient(fetcher);
const result = await client.getJson("https://example.test/users/999");
expect(result).toEqual({
ok: false,
error: { kind: "not_found", status: 404, url: "https://example.test/users/999" },
});
});
4) 500のテスト追加(Red→Green)🔥
サーバ側が死ぬのも、現実では普通にある😇
it("500ならserver_errorになる💥", async () => {
const fetcher: FetchLike = vi.fn().mockResolvedValue(
makeResponse(500, { message: "oops" })
);
const client = new HttpClient(fetcher);
const result = await client.getJson("https://example.test/users/1");
expect(result).toEqual({
ok: false,
error: { kind: "server_error", status: 500, url: "https://example.test/users/1" },
});
});
5) 通信失敗(fetchがthrow)も固定する(Red→Green)📡💔
Wi-Fi切れた、DNS死んだ、タイムアウト…などなど😭 ここも「たまに落ちる」を潰す大事ポイント!
it("fetchが例外を投げたらnetwork_errorになる📡💔", async () => {
const fetcher: FetchLike = vi.fn().mockRejectedValue(new Error("ECONNRESET"));
const client = new HttpClient(fetcher);
const result = await client.getJson("https://example.test/users/1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.kind).toBe("network_error");
expect(result.error.message).toContain("ECONNRESET");
}
});
✨ここまでの“境界”が作れたら勝ち!🏆
この HttpClient があると、呼び出し側はこう書けるよ👇💕
const http = new HttpClient(fetch); // 本物fetchを注入
const r = await http.getJson<{ name: string }>("https://api.example.com/me");
if (r.ok) {
console.log("こんにちは", r.value.name);
} else {
switch (r.error.kind) {
case "not_found":
// 404のUIとか
break;
case "server_error":
// リトライ候補
break;
case "network_error":
// オフライン案内
break;
}
}
🧷補足:どうしても global fetch を差し替えたい時(任意)
「既存コードが fetch(...) 直呼びで、今すぐ注入に直せない…🥺」って時は、Vitest の vi.stubGlobal で一時的に差し替えもできるよ。 (Vitest)
import { vi, afterEach } from "vitest";
afterEach(() => {
vi.unstubAllGlobals(); // 戻す
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(/* Responseっぽいもの */));
ただしこれ、依存が見えにくくなりがちだから、基本は「注入」に寄せるのがおすすめだよ〜🙂↕️✨ (Vitest)
🌐もう一段リアルに:MSWで“ネットっぽく”テスト(任意)
「fetchの注入テスト」は超速いけど、 “実際のHTTPリクエストの形(URL/クエリ/ヘッダ)”も含めて確かめたい時があるよね👀
その場合は MSW みたいに リクエストを横取りして返す方法が便利✨(Node+Vitest向けの案内もあるよ) (mswjs.io)
使い分けの目安🍀
- ユニット(速い):注入(この章)
- 統合(ちょいリアル):MSW(必要なところだけ)
🤖AIの使いどころ(この章のテンプレ)💬✨
コピペで使えるやつ置いとくね🫶
- 「404/500/通信失敗」をどう扱うべき?(UI/ログ/リトライ方針も含めて)🧭
HttpProblemの union 設計、他に分けるべき種類ある?💡- テストケースの抜け(例えば 401/403/429)ってどれが優先?🎯
- “やりすぎない”リトライ戦略案を3つ(弱/中/強)で出して⚡️
✅チェックリスト(できてたら合格🎉)
- テストが ネット無しで全部通る🌈
- 200/404/500/通信失敗 を 仕様として固定できた🧪
- 呼び出し側が
switch (error.kind)で迷わず分岐できる🧩 - 例外ログを貼り付けなくても、失敗の種類が見える👀✨
🧩次章へのつなぎ(第50章)
この章は「HTTPとして成功か失敗か」を整理しただけで、JSONの中身が正しいかまでは守ってないよ〜!🙈 次は「壊れたデータを入口で弾く」=バリデーション/スキーマで、型と現実を一致させにいくよ🧷💖(第50章へGO!)