第22章:テスト戦略(VO・Entity・境界・変換)🧪🍰
この章は「1人開発でも壊れない守り」を作る回だよ〜!😊✨ テストって“バグ取り”に見えるけど、ほんとは 設計(ルール)を守らせるための安全ベルトなんだ🛡️💕
1) 今日のゴール🎯✨
読み終わったら、こうなれるよ👇
- **VOは「単体テストが最強」**って体感できる💎✅
- Entityは「ルール(ふるまい)」中心にテストできる🪪🧱
- **境界(DTO→ドメイン)**で“変な入力”を止められる🚪⚠️
- **Mapper(Domain↔Persistence)**で「変換ミス」を潰せる🔁💾
- どの粒度で、どれだけテストを書くべきか迷わない🗺️😊
2) 2026年のテスト環境、どれが今っぽい?🧰✨
結論:TypeScriptなら「Vitest」中心がかなり扱いやすいよ〜!⚡️ VitestはVite由来で高速&設定が軽め、TSとも相性が良いのが強み💨 (Vitest)
- Vitest 4 系が現行メジャーで、4.0.x が安定ライン(2026年1月時点でも更新が続いてる)🧪✨ (Vitest)
- Node.js は v24 が Active LTS、v25 が Current(最新機能)という位置づけだよ📌 (Node.js)
- TypeScript は npm の latest が 5.9.3(2026年1月時点)📦 (npm)
- Jest は 30 系が安定版(Jest 30)として案内されてるよ🃏 (Jest)
もちろん Jest 派も多いけど、TS+今どき構成だと **Vitest のほうが「詰まりポイント少なめ」**になりやすいよ☺️ (とくに変換・ESM周りで悩みたくない人向き!)
3) テストは「4段ケーキ」🍰🧪(この章の本題!)
このロードマップの“4段”は超いい整理👏✨ おすすめはこう👇(割合は目安だよ〜)
🍰テストの4段(粒度)
- VOテスト(いちばん多く):60〜75% 💎✅
- Entityテスト(ルール中心):20〜30% 🪪🧱
- 境界テスト(DTO→ドメイン):5〜10% 🚪⚠️
- Mapperテスト(Domain↔Record):5〜10% 🔁💾

なんでこの配分?🤔💭
- VOは「入力→検証→値」みたいに 純度が高いからテストが軽い&強い💎
- Entityは「状態遷移」「不変条件」など バグの温床になりやすい🪪💥
- 境界と変換は 地味に事故る(null、単位、丸め、命名ズレ)ので最低限押さえる🔁😇
4) “良いテスト”の最低ルール3つ🧠✨
① テスト名は「仕様の日本語」っぽく📝💕
- ❌
shouldWork() - ✅
無効なEmailは作れない() - ✅
支払い済みはキャンセルできない()
② 形はこれでOK(AAA)🧁
- Arrange(準備)
- Act(実行)
- Assert(確認)
③ “実装”じゃなく“ルール”を見る👀✨
- ❌ private変数の中身を覗く
- ✅ 「この入力ならOK」「この状態ならNG」を確認する
5) 実例でいくよ!🧪✨(VO → Entity → 境界 → Mapper)
ここからは “雰囲気が伝わる最小例” でまとめるね😊 (全部コピペできる形に寄せてるよ〜)
5-1) VO:Email(作れない状態を作らない)📧🚫
Email VO(例)
export class Email {
private constructor(private readonly value: string) {}
public static create(raw: string): Email {
const v = raw.trim();
// 超シンプル判定(学習用):
// 実務はもう少し厳密にするけど、まずは「無効を作らない」を体験しよ🙂
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
if (!ok) throw new Error("InvalidEmail");
return new Email(v);
}
public toString(): string {
return this.value;
}
public equals(other: Email): boolean {
return this.value === other.value;
}
}
Email のテスト(テーブル駆動)🧪✨
import { describe, it, expect } from "vitest";
import { Email } from "./Email";
describe("Email", () => {
it("有効なEmailは作れる", () => {
const email = Email.create("a@example.com");
expect(email.toString()).toBe("a@example.com");
});
it("無効なEmailは作れない", () => {
const badList = ["", "a@", "@b.com", "a@b", "a b@c.com"];
for (const raw of badList) {
expect(() => Email.create(raw)).toThrowError("InvalidEmail");
}
});
it("等価性:同じ値ならtrue", () => {
const a = Email.create("a@example.com");
const b = Email.create("a@example.com");
expect(a.equals(b)).toBe(true);
});
});
✅ ここでのポイント
- **VOは“生成できた時点で正しい”**のが気持ちいい💎✨
- テストも軽い!だからたくさん書ける🧪💨
5-2) Entity:Order(ルール&状態遷移をテスト)🪪🚦
OrderStatus(状態)
export type OrderStatus = "Draft" | "Submitted" | "Paid" | "Cancelled";
Order Entity(例)
export class Order {
private status: OrderStatus;
private constructor(
private readonly id: string,
status: OrderStatus
) {
this.status = status;
}
public static create(id: string): Order {
return new Order(id, "Draft");
}
public submit(): void {
if (this.status !== "Draft") throw new Error("CannotSubmit");
this.status = "Submitted";
}
public pay(): void {
if (this.status !== "Submitted") throw new Error("CannotPay");
this.status = "Paid";
}
public cancel(): void {
// 例:支払い済みはキャンセル不可、みたいなルール
if (this.status === "Paid") throw new Error("CannotCancelPaid");
this.status = "Cancelled";
}
public getStatus(): OrderStatus {
return this.status;
}
}
Entityテスト(“ルールだけ”を見る)🧪✨
import { describe, it, expect } from "vitest";
import { Order } from "./Order";
describe("Order", () => {
it("作成直後はDraft", () => {
const order = Order.create("o-1");
expect(order.getStatus()).toBe("Draft");
});
it("Draftはsubmitできる", () => {
const order = Order.create("o-1");
order.submit();
expect(order.getStatus()).toBe("Submitted");
});
it("Submitted以外はpayできない", () => {
const order = Order.create("o-1");
expect(() => order.pay()).toThrowError("CannotPay");
});
it("Paidはcancelできない(ルール)", () => {
const order = Order.create("o-1");
order.submit();
order.pay();
expect(() => order.cancel()).toThrowError("CannotCancelPaid");
});
});
✅ ここでのポイント
- Entityのテストは **「状態遷移+禁止ルール」**がメイン🚦🚫
- getterで中身を覗くのはOKだけど、**“内部実装に依存しすぎない”**ようにね🙂
5-3) 境界:DTO→ドメイン(変な入力を止める)🚪⚠️
境界ではよくこうなる👇
- 入力は string / number / null 混ざりがち😇
- ドメインは「作れない状態を作らない」ので、変換が必要🔁
Result型(超ミニ)
export type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
export const Ok = <T>(value: T): Result<T> => ({ ok: true, value });
export const Err = (error: string): Result<never> => ({ ok: false, error });
DTO→Order(例)
import { Order } from "../domain/Order";
import { Result, Ok, Err } from "./Result";
export type CreateOrderDto = {
id: string;
action: "submit" | "stayDraft";
};
export function createOrderFromDto(dto: CreateOrderDto): Result<Order> {
if (!dto.id?.trim()) return Err("ValidationError: id is required");
const order = Order.create(dto.id.trim());
if (dto.action === "submit") {
order.submit();
}
return Ok(order);
}
境界テスト(入力の守り)🧪🧯
import { describe, it, expect } from "vitest";
import { createOrderFromDto } from "./createOrderFromDto";
describe("createOrderFromDto", () => {
it("idが空なら失敗する", () => {
const r = createOrderFromDto({ id: "", action: "stayDraft" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("id is required");
});
it("submit指定ならSubmittedになる", () => {
const r = createOrderFromDto({ id: "o-1", action: "submit" });
expect(r.ok).toBe(true);
if (r.ok) expect(r.value.getStatus()).toBe("Submitted");
});
});
✅ ここでのポイント
- 境界は「入力が汚い」前提で守る場所🚪🧼
- ここを薄くしておくと、ドメインが綺麗なまま保てる✨
5-4) Mapper:Domain ↔ Record(変換事故を潰す)🔁💾
Mapperでありがち事故👇😇
moneyが 円なのかドルなのかDateが ISO文字列なのか数値なのかstatusの命名がズレる(PAIDvsPaid)
Record型(例)
import type { OrderStatus } from "../domain/OrderStatus";
export type OrderRecord = {
id: string;
status: OrderStatus; // 例では同じにしてる(まずは最小でOK)
};
Mapper(例:往復できることを重視)
import { Order } from "../domain/Order";
import type { OrderRecord } from "./OrderRecord";
export const OrderMapper = {
toRecord(order: Order): OrderRecord {
return {
id: (order as any).id ?? "unknown", // 学習用に簡略化(本来はid getter推奨)
status: order.getStatus(),
};
},
fromRecord(r: OrderRecord): Order {
const order = Order.create(r.id);
// statusを復元(最小例)
if (r.status === "Submitted") order.submit();
if (r.status === "Paid") {
order.submit();
order.pay();
}
if (r.status === "Cancelled") order.cancel();
return order;
},
};
※ここは学習用に雑に見せたよ! 実務では
idは getter 用意したり、復元専用のファクトリを作ったりするのが綺麗😊✨
Mapperテスト(Round-trip)🧪🔁
import { describe, it, expect } from "vitest";
import { Order } from "../domain/Order";
import { OrderMapper } from "./OrderMapper";
describe("OrderMapper", () => {
it("Domain → Record → Domain で同じ状態に戻る", () => {
const order = Order.create("o-1");
order.submit();
order.pay();
const record = OrderMapper.toRecord(order);
const restored = OrderMapper.fromRecord(record);
expect(restored.getStatus()).toBe("Paid");
});
});
✅ ここでのポイント
- Mapperは「地味だけど事故る」ので、往復テストが超効く🔁✨
6) この章の演習(やってみよ〜!)🎒🧪
演習A:VOテストを増やす💎
- Email の OK / NG を 20個に増やす📧✨
- 「前後スペースがある」ケースも入れてね🙂
演習B:Entityの状態遷移を網羅する🚦
Draft → CancelledはOK?Cancelled → SubmitはOK?(たぶんNGだよね?)- ルールを決めてテストを書こう📝🧪
演習C:境界の“変な入力”を増やす🚪⚠️
id: " "(空白だけ)actionが想定外(型で防げても、境界で守る練習として)
演習D:Mapperの事故パターンを作って潰す🔁💾
statusを"PAID"みたいに外部表現にして、Mapperで翻訳する- 翻訳ミスがあるとテストで落ちるようにする🧨✅
7) 小テスト(5問)🎓✨
-
VOテストが“最強”な理由、いちばん近いのはどれ? A. DB接続が速いから B. 状態を持たず小さく決定的で、テストが軽いから C. 画面が派手だから
-
Entityテストで見るべき中心は? A. private変数の中身 B. ルール(禁止・許可)と状態遷移 C. console.log の出力
-
境界(DTO→ドメイン)の役割は? A. UIを作る B. 汚い入力を綺麗にしてドメインに入れる C. 画像を生成する
-
Mapperでやりがちな事故は?(複数OK) A. 単位のズレ B. null/欠損の扱い C. 命名や列のズレ → **全部ある!**😇
-
テスト名のおすすめは? A. shouldWork B. test1 C. 「支払い済みはキャンセルできない」
8) AIプロンプト集(そのまま使えるやつ)🤖✨
VO向け💎
- 「このVOの不変条件から、OK/NGのテストケースを20個ずつ作って。境界値も混ぜてね」
- 「このVOの equals のテスト観点を箇条書きで出して」
Entity向け🪪🚦
- 「この状態遷移の“抜けケース”を10個出して。特に禁止遷移を中心に」
- 「このEntityのテスト名を日本語で“仕様っぽく”リネームして」
境界向け🚪⚠️
- 「DTOにありがちな汚い入力(空白、null、桁違い、未知値)を例で出して」
- 「ValidationError と SystemError の切り分け案を出して」
Mapper向け🔁💾
- 「変換で事故るパターン(単位、丸め、時刻、列名)を列挙して、テストで検知する方法も」
- 「Domain→Record→Domain の round-trip テストを雛形で書いて」
9) 仕上げチェックリスト✅✨(これで“壊れにくい”になる!)
- VOは「作れない状態を作らない」+テストが十分💎🧪
- Entityは「状態遷移」「禁止ルール」のテストがある🚦🚫
- 境界は「変な入力」を止めてる🚪🧯
- Mapperは「往復テスト」で変換事故を検知できる🔁✅
- テスト名が仕様っぽくて、見れば意図が分かる📝💕
次の章(第23章)は「リファクタ演習」だよね🔧✨ もしよければ、今のミニ題材に合わせて “演習用のダメ実装(Entityにベタ書き)→VO化して直す” の教材セットも、そのまま作っていけるよ😊🎀