第19章:例外・失敗のテスト(異常系入門)🚫

この章は「失敗も仕様にする」練習だよ〜!🙂✨ 正常系が通るだけだと、入力ミスや想定外の値でアプリが壊れやすいの。だから “落ち方” もテストで決める よ〜!🛡️💕
🎯 この章のゴール(できるようになること)✨
- 無効な入力のときに throw(同期) するテストが書ける✅
- Promiseが失敗するときに reject(非同期) するテストが書ける✅
- エラーの 種類(クラス) と メッセージ を“ちょうどよく”検証できる✅
- 「どんな失敗が仕様なの?」を整理できる✅
🧠 まず超重要:失敗には“種類”があるよ🗂️✨
失敗(エラー)って全部同じじゃないの〜!😵💫💦 ここを分けると、テストも設計もスッキリするよ💕
① 仕様としての失敗(テストする)✅
- 無効な入力(マイナス、空、桁あふれ、形式違い)
- ビジネスルール違反(上限超え、在庫なし、権限なし) → 「こう失敗してね」って決めたい からテストする💪✨
② バグとしての失敗(基本テストしない)⚠️
- null参照、型の勘違い、分岐ミス → これは 直すべきバグ。仕様にしない🧯
③ 外部要因の失敗(境界で扱う)🌐
- ネットワーク、DB、ファイル、API落ち → 後の章(依存の切り方)で強くなるやつ💪🔥
✅ Vitestで「throw / reject」をテストする基本形🧪
1) 同期の throw をテストする形(超大事)⚡
Vitestは「throwするコード」を 関数で包む 必要があるよ! 包まないと、expectの外で落ちちゃうの😇💥 (Vitest)
import { expect, test } from "vitest";
function parseAge(input: number) {
if (input < 0) throw new Error("age must be >= 0");
return input;
}
test("マイナスはエラーになる", () => {
expect(() => parseAge(-1)).toThrowError("age must be >= 0");
});
2) 非同期の reject をテストする形(Promise)⏳
Promiseは rejects で中身をほどいて検証できるよ〜!
(この場合は “関数で包む/包まない” の注意点がちょい違うから、下の例を真似してね🙂) (Vitest)
import { expect, test } from "vitest";
test("Promiseが失敗するとき", async () => {
const promise = Promise.reject(new Error("nope"));
await expect(promise).rejects.toThrowError("nope");
});
🛠️ ハンズオン:カフェ会計で「無効入力」を仕様にする☕️🧾🚫
🧩 お題:注文の入力チェックを作るよ💕
- 数量(qty)が 1以上じゃないとダメ
- 値段(price)が 0以上じゃないとダメ
- 注文が空(items.length === 0)もダメ
失敗時は ValidationError を投げることにするよ🙂✨ (普通の Error でもいいけど、後々ラクになるからここで慣れちゃお!)
🔴 Step 1:まずテストを書く(Red)🚦
ポイント:
- いきなりケースを増やしすぎない🙅♀️
- まず “一番ありそうな無効入力” を1つでOK✨
import { describe, expect, it } from "vitest";
import { calcTotal, ValidationError } from "../src/calcTotal";
describe("calcTotal(異常系)", () => {
it("注文が空なら ValidationError", () => {
expect(() => calcTotal([])).toThrowError(
expect.objectContaining({ name: "ValidationError", message: "items must not be empty" })
);
});
it("数量が0以下なら ValidationError", () => {
expect(() =>
calcTotal([{ name: "Coffee", qty: 0, price: 450 }])
).toThrowError(/qty must be >= 1/);
});
it("価格がマイナスなら ValidationError", () => {
expect(() =>
calcTotal([{ name: "Coffee", qty: 1, price: -1 }])
).toThrowError("price must be >= 0");
});
});
- メッセージは 完全一致より、部分一致(文字列/正規表現) を多用すると折れにくいよ🙂✨ (Vitest)
expect.objectContainingでエラーの形も見れるよ(Vitestの例にもあるよ)💡 (Vitest)
🟢 Step 2:最小実装で通す(Green)🌱
「まず通す」が最優先!✨ (ここでキレイにしようとして沼りがちなので注意😵💫)
export type Item = { name: string; qty: number; price: number };
export class ValidationError extends Error {
override name = "ValidationError";
}
export function calcTotal(items: Item[]): number {
if (items.length === 0) throw new ValidationError("items must not be empty");
for (const item of items) {
if (item.qty < 1) throw new ValidationError("qty must be >= 1");
if (item.price < 0) throw new ValidationError("price must be >= 0");
}
return items.reduce((sum, x) => sum + x.qty * x.price, 0);
}
🔵 Step 3:整える(Refactor)🧹✨
ここで「読める形」にするよ〜!💕
✅ リファクタ方針(この章の“ちょうどよさ”)
- チェック処理を
validateItemsに分ける - エラーメッセージを 仕様として読みやすくする
- ただし “共通化しすぎ” はまだしない(初心者が迷子になる)🙂
export type Item = { name: string; qty: number; price: number };
export class ValidationError extends Error {
override name = "ValidationError";
}
function validateItems(items: Item[]): void {
if (items.length === 0) throw new ValidationError("items must not be empty");
for (const item of items) {
if (item.qty < 1) throw new ValidationError("qty must be >= 1");
if (item.price < 0) throw new ValidationError("price must be >= 0");
}
}
export function calcTotal(items: Item[]): number {
validateItems(items);
return items.reduce((sum, x) => sum + x.qty * x.price, 0);
}
🌙 追加ミニ:async関数の異常系(reject)も1本だけ書こう⏳✨
「将来、割引クーポンの確認が非同期」みたいな想定で、雑に例を作るね🙂
import { expect, test } from "vitest";
async function loadCoupon(code: string): Promise<{ rate: number }> {
if (!code) throw new Error("code is required");
return Promise.reject(new Error("coupon not found"));
}
test("存在しないクーポンは reject", async () => {
await expect(loadCoupon("NOPE")).rejects.toThrowError("coupon not found");
});
rejects の使い方はVitestの説明でも出てるよ🧪✨ (Vitest)
🤖 AIの使い方(この章での“勝ちパターン”)🧠✨
AIはめっちゃ便利だけど、仕様を決めるのはあなた だよ〜!👑💕 使いどころは「候補出し」と「抜けチェック」がおすすめ🙂
💡 おすすめプロンプト(そのまま貼ってOK)✨
calcTotal(items) の異常系テストを書きたいです。
items は { name: string, qty: number, price: number } の配列です。
「仕様として扱うべき無効入力」を、重要度順に10個列挙して。
それぞれに “期待する失敗の種類(throw/reject)” と “メッセージ案(短く)” も付けて。
ただし、過剰に厳密なメッセージ一致は避けたいです。
✅ AIを使ったあとの“自分チェック”🪞
- その無効入力、ほんとにユーザーがやりそう?🙂
- 「落ち方」が一貫してる?(ValidationErrorに寄せる等)🧠
- メッセージが “仕様として読める”?📘✨
😵💫 よくあるミス集(ここで詰まりやすい!)🧯
❌ 1) throwテストで包んでなくて落ちる
- ダメ:
expect(calcTotal([])).toThrowError(...) - 正解:
expect(() => calcTotal([])).toThrowError(...)(Vitestも「包んでね」って言ってるよ) (Vitest)
❌ 2) asyncなのに rejects を使ってない / awaitしない
- ダメ:
expect(loadCoupon("NOPE")).toThrowError(...) - 正解:
await expect(loadCoupon("NOPE")).rejects.toThrowError(...)(Vitest)
❌ 3) メッセージ完全一致にしすぎてテストが折れる
- まずは部分一致(文字列/正規表現)で十分🙂 (Vitestの例も正規表現や部分一致を推してる感じだよ) (Vitest)
✅ 章末チェック(セルフ採点)📝💕
- “仕様としての失敗” を3つテストにできた✅
- throwテストは必ず関数で包めた✅ (Vitest)
- rejectテストは rejects を使えてる✅ (Vitest)
- メッセージを厳密一致しすぎず、読みやすさ優先にできた✅
- 失敗の種類がブレてない(例:ValidationErrorで統一)✅
🧷 ちょい最新メモ(2026-01-19時点)📌✨
- TypeScript は「現在 5.9」が最新として案内されてるよ🧠 (typescriptlang.org)
- Vitest は “latest 4.0.17(2026-01-12)” が見えてるよ🧪✨ (Yarn)
🌸 次章(第20章)につながるよ!
異常系テストが増えると、テスト名の良さが効いてくるよ〜!📝✨ 次は「落ちた瞬間に原因が分かる命名」へ進もうね🙂💕
必要なら、この章の提出物(合格ライン)もセットで作るよ〜!🎓💖