メインコンテンツまでスキップ

第24章:テスト戦略②(ユースケースのテスト)🎮🧪

24.1 今日のゴール🎯✨

この章が終わったら、こうなれます👇💖

  • ユースケース(アプリケーションサービス)をテストで守れる
  • Repositoryを差し替えて(インメモリ・モック/スタブ)速くて安定したテストが書ける🚀
  • テストが 読みやすい(Given/When/Then) 形にできる📖✨

24.2 最新メモ(本日時点)🆕📌

「今どきの定番」を軽く押さえておくよ〜😊💡

  • TypeScript は npm 上の 最新版が 5.9.3(2026年1月時点)🧷 (npmjs.com)
  • Node.js は v24 が Active LTSv25 が Current(2026年1月時点)🌿⚡ (nodejs.org)
  • Jest は 30.0 が Stable として案内されてるよ🧪 (jestjs.io)
  • TypeScript の世界でユニットテストは Vitest が人気枠(チュートリアル追加の動きもあり)💛 (typescriptbook.jp)

※この章では、ユースケーステスト例を Vitest で書くよ🧡(Jestでも考え方は同じ!)


24.3 ユースケースのテストってなに?🤔🎮

Study Image

**ユースケース(アプリケーションサービス)**は、だいたいこの形でしたよね👇✨

  • Repositoryから集約を 取得📥
  • 集約のメソッドで 変更🔧(不変条件は集約が守る🔒)
  • Repositoryで 保存📤
  • 必要なら「イベント発行」などの 次の処理へ橋渡し📣

つまりユースケースは、**“手順の指揮者”**🎼👩‍✈️ だからユースケーステストは、こういうのを確認します👇

  • 手順が正しい順で呼ばれてる?🔁
  • エラーのときに余計な保存してない?🚫
  • Repository差し替えで I/Oなし でも検証できる?🔌✨

24.4 どこまでテストする?(第23章との分担)🧩🧪

ここめっちゃ大事〜!😊💖 テストがダブると、つらくなるからね…🥲

テスト種類守るもの🛡️速さ🏎️
集約ユニットテスト(第23章)不変条件🔒(壊れないルール)最速金額マイナス不可、在庫0未満不可
ユースケーステスト(この章)手順の正しさ🎮(取得→変更→保存)速い注文作成→保存、失敗時は保存しない
結合/統合テストDB/外部込みの動作🌍遅め実DBで保存できる、HTTP含む

👉 この章は “集約の中身を細かくテストしない” のがコツだよ🎀 (集約の中身は第23章でガチガチに守ってる前提!)


24.5 まずは最小ユースケースを1つ決めよう🛒✨

ここでは、いちばん分かりやすいこれ👇

**「注文作成 → 保存」**🛍️📦

  • 入力:商品行(商品ID、数量、単価)🧾
  • 出力:成功なら orderId、失敗ならエラー🚨
  • 保存先:OrderRepository📥📤

24.6 Repository差し替えの2パターン🧸🔁

パターンA:インメモリRepository(スタブ寄り)🧺✨

実装を差し替える方式。メリット👇

  • 本物っぽく動く💡
  • テストが自然で読みやすい📖
  • “保存されたか”が状態として見える👀

デメリット👇

  • ちょい実装が必要🛠️

パターンB:モック(呼ばれたか確認)📞✅

saveが呼ばれた?呼ばれてない? を見る方式。メリット👇

  • 書くのが早い🏃‍♀️💨
  • 「失敗時に保存してない」確認が超ラク🚫

デメリット👇

  • モックだらけにすると、テストが“手続きの暗記”になりがち🥺

👉 おすすめは Aを基本にして、Bはピンポイント🎯💕


24.7 手を動かす:ユースケーステストを書こう✍️🧪

24.7.1 必要ファイル(最小)📁

  • domain/:Order(集約)🧺
  • application/:PlaceOrder(ユースケース)🎮
  • tests/:ユースケーステスト🧪

24.7.2 まずは最小の型(Result)を用意📦✨

「例外でドカーン💥」を避けたいので、Result型で返すよ😊 (第20章の流れとも相性よし💖)

// shared/result.ts
export type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };

export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

24.7.3 ドメイン(Order集約)を“最小”で用意🧺🔒

この章では、集約の不変条件を「軽く」入れるだけでOK👌 (本気の不変条件テストは第23章でやってる前提だよ🫶)

// domain/order.ts
import { Result, ok, err } from "../shared/result";

export type OrderError =
| "EMPTY_ITEMS"
| "INVALID_QUANTITY"
| "INVALID_UNIT_PRICE";

export type OrderId = string;

export type OrderItem = {
productId: string;
quantity: number;
unitPrice: number;
};

export class Order {
private constructor(
public readonly id: OrderId,
private readonly items: OrderItem[],
) {}

static create(id: OrderId, items: OrderItem[]): Result<Order, OrderError> {
if (items.length === 0) return err("EMPTY_ITEMS");
if (items.some((x) => x.quantity <= 0)) return err("INVALID_QUANTITY");
if (items.some((x) => x.unitPrice < 0)) return err("INVALID_UNIT_PRICE");
return ok(new Order(id, items));
}

// この章では「合計計算」くらいに留める(詳細は第23章で!)
total(): number {
return this.items.reduce((sum, x) => sum + x.quantity * x.unitPrice, 0);
}
}

24.7.4 Repository(インターフェース)🧩📥📤

// domain/orderRepository.ts
import { Order, OrderId } from "./order";

export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
}

24.7.5 インメモリRepository(差し替え用)🧺✨

// tests/inMemoryOrderRepository.ts
import { Order, OrderId } from "../domain/order";
import { OrderRepository } from "../domain/orderRepository";

export class InMemoryOrderRepository implements OrderRepository {
private store = new Map<OrderId, Order>();

async save(order: Order): Promise<void> {
this.store.set(order.id, order);
}

async findById(id: OrderId): Promise<Order | null> {
return this.store.get(id) ?? null;
}

// テスト用:今入ってるデータを覗ける👀✨
dumpIds(): OrderId[] {
return [...this.store.keys()];
}
}

24.7.6 ユースケース(注文作成→保存)🎮🛒

ここがテスト対象だよ〜😊🧡

// application/placeOrder.ts
import { Order, OrderError, OrderItem } from "../domain/order";
import { OrderRepository } from "../domain/orderRepository";
import { Result, ok, err } from "../shared/result";

export type PlaceOrderInput = {
orderId: string;
items: OrderItem[];
};

export type PlaceOrderOutput = {
orderId: string;
total: number;
};

export class PlaceOrderService {
constructor(private readonly orderRepo: OrderRepository) {}

async execute(
input: PlaceOrderInput,
): Promise<Result<PlaceOrderOutput, OrderError>> {
const created = Order.create(input.orderId, input.items);
if (!created.ok) return err(created.error);

await this.orderRepo.save(created.value);

return ok({
orderId: created.value.id,
total: created.value.total(),
});
}
}

24.7.7 ユースケーステスト(Vitest)🧪🎉

✅ テスト1:成功したら保存される

// tests/placeOrder.usecase.test.ts
import { describe, it, expect } from "vitest";
import { PlaceOrderService } from "../application/placeOrder";
import { InMemoryOrderRepository } from "./inMemoryOrderRepository";

describe("PlaceOrderService(ユースケース)", () => {
it("注文作成に成功したら、保存されて orderId と total を返す 🎉", async () => {
// Given 🌸
const repo = new InMemoryOrderRepository();
const service = new PlaceOrderService(repo);

// When 🎬
const result = await service.execute({
orderId: "order-001",
items: [
{ productId: "p-1", quantity: 2, unitPrice: 300 },
{ productId: "p-2", quantity: 1, unitPrice: 500 },
],
});

// Then ✅
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.orderId).toBe("order-001");
expect(result.value.total).toBe(2 * 300 + 1 * 500);
}

// 保存されてる?👀
expect(repo.dumpIds()).toContain("order-001");
});
});

✅ テスト2:失敗したら保存しない(モックをピンポイントで)🚫🧸

「不正な入力のときに save されない」は、モック確認がラクだよ😊✨

// tests/placeOrder.usecase.fail.test.ts
import { describe, it, expect, vi } from "vitest";
import { PlaceOrderService } from "../application/placeOrder";
import { OrderRepository } from "../domain/orderRepository";

describe("PlaceOrderService(失敗パターン)", () => {
it("数量が不正なら、エラーを返して保存しない 🚫", async () => {
// Given 🌸
const repo: OrderRepository = {
save: vi.fn(),
findById: vi.fn(),
};
const service = new PlaceOrderService(repo);

// When 🎬
const result = await service.execute({
orderId: "order-002",
items: [{ productId: "p-1", quantity: 0, unitPrice: 300 }], // 👈 NG
});

// Then ✅
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe("INVALID_QUANTITY");
}

// 保存されてない?🚫
expect(repo.save).not.toHaveBeenCalled();
});
});

24.8 Given/When/Then をもっと読みやすくするコツ📖✨

テストって「将来の自分」への手紙💌だから、読みやすさが大事〜🥰

🧁 コツ1:変数名に気持ちを込める

  • repoorderRepo
  • serviceplaceOrder

🧁 コツ2:期待値は「何が嬉しいか」で書く

  • expect(result.ok).toBe(true) だけ
  • ✅ 「保存される」「totalが合う」も一緒に書く

🧁 コツ3:1テスト1メッセージ🎯

「何を守ってるテストか」が1行で言えるようにするよ💡


24.9 よくある事故😱💥(あるある救急箱🚑✨)

😵 事故1:ユースケーステストで集約の中身を全部検証し始める

➡️ 集約の細かい不変条件は 第23章(集約テスト) に寄せる🔒 ユースケース側は「手順」と「境界」の確認がメイン🎮

😵 事故2:DBやHTTPを叩き始めてテストが遅い&不安定

➡️ Repository差し替えで I/Oを消す🔌✨ 遅いテストは、やらなくなる…🥲(超もったいない!)

😵 事故3:モックだらけで、実装変更に弱い

➡️ **基本はインメモリ(状態で確認)**🧺 「呼ばれないこと」だけモックでピン留め📌🧸


24.10 仕上げチェックリスト✅🧡

ユースケーステストが書けたら、ここチェックしてね👀✨

  • テストが 速い(すぐ終わる)🏎️
  • I/O(DB/HTTP/時刻など)に触ってない🔌🚫
  • “手順”が守れてる(取得→変更→保存)🔁
  • 失敗時に 保存してない🚫
  • テスト名だけで意図が分かる📛✨

24.11 練習問題✍️🎀(ミニ課題で定着!)

できそうなところからでOKだよ〜😊🌸

問1️⃣:空アイテムのとき保存されないテストを書こう🧪

  • items: [] で呼ぶ
  • EMPTY_ITEMS が返る
  • save が呼ばれない

問2️⃣:合計金額が想定通りのテストをもう1本追加しよう💰

  • 端数が出ない例でOK
  • total の期待値を分かりやすく書く

問3️⃣:Given/When/Then を「コメントなしでも読める」形に整えてみよう📖✨

  • 変数名
  • 期待値の書き方
  • 1テスト1メッセージ

24.12 AI活用🤖✨(テストがスラスラになる魔法🪄)

AIは「丸投げ」じゃなくて、読みやすくする相棒にすると強いよ💖

🪄 プロンプト例1:Given/When/Then整形

「このテストコードを Given/When/Then の意図が一目で分かるように、変数名と期待値を改善して。仕様は変えないで。」

🪄 プロンプト例2:抜け漏れテスト洗い出し

「PlaceOrderService のユースケーステスト観点を列挙して。成功/失敗/境界値/保存されない条件を中心に。」

🪄 プロンプト例3:失敗時の副作用チェック

「失敗時に save が呼ばれないことを保証するテストを、Vitestで追加して。最小のモックで。」


まとめ🎀🧪

ユースケーステストは、“手順の正しさ”を守るテスト🎮✨ Repositoryを差し替えれば、I/Oなしで 速く・安定して・読みやすい テストが書けるようになります🧺💖