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

第27章:テスト:ハンドラ(副作用が呼ばれたか?)📞🧪

🎯 この章のゴール

  • ドメインイベントのハンドラを、外部I/Oなしで安全にテストできるようになる🧯✨
  • **モック(Mock)/スパイ(Spy)**を使って「呼ばれた?」「何回?」「引数は?」を検証できるようになる👀✅
  • 失敗ケース(外部サービスが落ちた💥)もテストで再現できるようになる😵‍💫➡️🧪

🧠 まず整理:ハンドラのテストって何を見ればいいの?

ハンドラの検証:副作用が正しく呼ばれたかをモックで確認する

ハンドラはだいたい「イベントを受け取って、副作用を起こす係」だよね📩🔔 だからテストは、ざっくりこの3つを見ればOK!✨

  1. 呼ばれた?(副作用が実行されたか)☎️
  2. 正しい回数?(1回だけ?0回?2回以上はダメ?)🔁
  3. 正しい内容?(引数・ログ・送信内容・保存内容)📦🧾

そしてハンドラのテストは、だいたい Arrange / Act / Assert の3段でスッキリ書けるよ🧼✨

  • Arrange:準備(イベント作る、モック作る)🧰
  • Act:実行(handler.handle(event))▶️
  • Assert:確認(呼ばれた回数・引数・例外など)✅

🧩 テストしやすいハンドラの形:DI(差し替え)で勝つ🎭

外部I/O(メール送信・DB・HTTPなど)を直で呼ぶとテストが地獄👿 だからハンドラは「外部I/Oを インターフェース越し に受け取る」形にするよ💡

✅ 例:メール送信をするハンドラ(OrderPaid → Email)

// domainEvent.ts
export type DomainEvent<TType extends string, TPayload> = Readonly<{
eventId: string
occurredAt: string // ISO文字列でOK(例: new Date().toISOString())
aggregateId: string
type: TType
payload: TPayload
}>
// orderPaid.ts
import type { DomainEvent } from "./domainEvent"

export type OrderPaid = DomainEvent<
"OrderPaid",
{
orderId: string
userId: string
email: string
amount: number
currency: "JPY" | "USD"
}
>
// ports.ts(外部I/Oはここに置くイメージ)
export interface EmailSender {
send(to: string, subject: string, body: string): Promise<void>
}

export interface Logger {
info(message: string, meta?: unknown): void
error(message: string, meta?: unknown): void
}
// orderPaidEmailHandler.ts
import type { OrderPaid } from "./orderPaid"
import type { EmailSender, Logger } from "./ports"

export class OrderPaidEmailHandler {
constructor(
private readonly emailSender: EmailSender,
private readonly logger: Logger,
) {}

async handle(event: OrderPaid): Promise<void> {
const { email, amount, currency, orderId } = event.payload

// ガード:送れないなら何もしない(テストしやすい!)🛡️
if (!email) return

try {
await this.emailSender.send(
email,
"お支払い完了のお知らせ💳✨",
`ご注文 ${orderId} のお支払いが完了しました!\n金額:${amount} ${currency}`,
)

this.logger.info("OrderPaid email sent", { eventId: event.eventId, orderId })
} catch (e) {
// 失敗時の方針:ログして投げ直す(この章ではこの形でいくよ)🚨
this.logger.error("OrderPaid email failed", { eventId: event.eventId, error: e })
throw e
}
}
}

ポイント💡

  • ハンドラは EmailSender を知らない(ただの interface として扱う)🎭
  • だからテスト側で EmailSender を モックに差し替えできる🪄

🧪 Vitestで「副作用が呼ばれたか?」をテストする📞✅

ここでは Vitestvi.fn() / vi.spyOn() を使うよ✨ (モックの基本は vi って覚えるとラク!)🧠

✅ テスト:メール送信が1回呼ばれる📩

// orderPaidEmailHandler.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { OrderPaidEmailHandler } from "./orderPaidEmailHandler"
import type { EmailSender, Logger } from "./ports"
import type { OrderPaid } from "./orderPaid"

const createEvent = (): OrderPaid => ({
eventId: "evt-001",
occurredAt: new Date().toISOString(),
aggregateId: "order-001",
type: "OrderPaid",
payload: {
orderId: "order-001",
userId: "user-001",
email: "a@example.com",
amount: 1200,
currency: "JPY",
},
})

describe("OrderPaidEmailHandler", () => {
let sendMock: ReturnType<typeof vi.fn<(to: string, subject: string, body: string) => Promise<void>>>
let logger: Logger
let handler: OrderPaidEmailHandler

beforeEach(() => {
sendMock = vi.fn<(to: string, subject: string, body: string) => Promise<void>>()
.mockResolvedValue(undefined)

const emailSender: EmailSender = { send: sendMock }

logger = {
info: vi.fn(),
error: vi.fn(),
}

handler = new OrderPaidEmailHandler(emailSender, logger)

vi.clearAllMocks() // 念のためスッキリ🧼
})

it("メール送信が1回呼ばれる📩✅", async () => {
// Arrange
const event = createEvent()

// Act
await handler.handle(event)

// Assert
expect(sendMock).toHaveBeenCalledTimes(1)
expect(sendMock).toHaveBeenCalledWith(
"a@example.com",
expect.stringContaining("完了"),
expect.stringContaining("1200"),
)

expect(logger.info).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledTimes(0)
})
})

✅ テスト:emailが空なら「何もしない」🚫📩

it("emailが空なら送らない🚫📩", async () => {
const event = createEvent()
event.payload.email = ""

await handler.handle(event)

expect(sendMock).toHaveBeenCalledTimes(0)
expect(logger.info).toHaveBeenCalledTimes(0)
expect(logger.error).toHaveBeenCalledTimes(0)
})

💥 失敗ケースのテスト:外部サービスが落ちたらどうなる?

「メール送信が失敗した」みたいなケース、テストでちゃんと再現しよ🧨🧪

✅ テスト:EmailSenderが失敗したら例外が投げられる😵‍💫

it("送信に失敗したら例外を投げてログに残す🚨", async () => {
const event = createEvent()
sendMock.mockRejectedValueOnce(new Error("SMTP Down"))

await expect(handler.handle(event)).rejects.toThrow("SMTP Down")

expect(logger.error).toHaveBeenCalledTimes(1)
expect(sendMock).toHaveBeenCalledTimes(1)
})

ここで大事なのは、

  • **失敗時の方針(投げ直す?握りつぶす?リトライ要求?)**を先に決めること📌
  • 決めたら、テストで固定すること🧷✅

👀 Spy(スパイ)って何? vi.spyOn の使いどころ

  • vi.fn():ゼロから偽物の関数を作る(モック)🎭
  • vi.spyOn():既存オブジェクトのメソッドを監視する(スパイ)👀

✅ 例:既存オブジェクトの info を監視する🕵️‍♀️

it("logger.info が呼ばれたか spy で見る👀", async () => {
const event = createEvent()

const infoSpy = vi.spyOn(logger, "info")

await handler.handle(event)

expect(infoSpy).toHaveBeenCalledTimes(1)
})

🧯 よくある落とし穴(ハンドラテストあるある)🤣

1) 「実装の細部」をテストしすぎる👗💦

  • ❌ メール本文が1文字違うだけで落ちるテスト

  • ✅ 「送信された」「重要な情報が含まれる」くらいに寄せる

    • expect.stringContaining(...) がちょうど良いよ🧡

2) モックを作りすぎて何のテストかわからなくなる🧟‍♀️

  • まずは 必要最小限(今回なら EmailSender と Logger だけ)でOK👌

3) テスト間で呼び出し履歴が残る😱

  • beforeEach で作り直すか、vi.clearAllMocks() を使う🧼✨

📝 演習(やってみよう)💪🧪

演習1:ポイント付与ハンドラをテストしよう🪙

  • OrderPaid を受けて PointsService.add(userId, points) を呼ぶハンドラを書く

  • テストで👇を確認!

    • ✅ 1回呼ばれた
    • ✅ 引数が正しい
    • amount=0 のときは呼ばれない

演習2:通知ハンドラを2種類に分けて、それぞれテスト📣📩

  • 「メール通知」と「アプリ内通知」を別ハンドラにして
  • それぞれ “自分の副作用だけ” をテストする🎯✨

演習3:失敗時の方針を変えてテストも変える🔁🚨

  • 例)失敗したら例外にせず、logger.error だけ残して終わる
  • その方針に合わせてテストも作り直す✍️

🤖 AI活用(Copilot / Codex向け)プロンプト例🧠✨

  • 「このハンドラのテスト観点を Given/When/Then で10個出して」🧾
  • 「Vitestで vi.fn() を使って 型安全なモック を作る例を3パターン」🧩
  • 「このテスト、実装依存になってる部分を見つけて改善案ちょうだい」🔍
  • 「失敗時の方針(投げ直す/握る/リトライ)ごとのテスト例を比較して」⚖️

✅ まとめ(この章で覚えたいこと)🎀

  • ハンドラテストは「副作用が呼ばれたか?」を見る📞✅
  • 外部I/Oは interface + DI で差し替え可能にする🎭
  • vi.fn() でモック、vi.spyOn() で監視👀
  • 失敗ケースもテストで再現して、方針を固定する🧷🚨

📚 参考(公式)🔗

  • Vitest:モックの考え方(Mocking Guide) (Vitest)
  • Vitest:vi API(モック関連) (Vitest)
  • Vitest:モジュールのモック(Mocking Modules) (Vitest)