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

第32章 テスト①:ユースケース単体テスト(最優先)🧪💪

![hex_ts_study_032[(./picture/hex_ts_study_032_unit_testing_usecases.png)

(Past chat)(Past chat)(Past chat)(Past chat)(Past chat)

1) 今日やること(ざっくり)🎯

この章で作るのは「ユースケースの単体テスト」だよ😊 ポイントはこれだけ👇

  • DBもHTTPも触らない(中心だけを試す🧠❤️)
  • Portは差し替える(InMemoryでOK🔁)
  • ✅ テストが 仕様書みたいに読める ようにする📖✨

2) なんで“ユースケース”からテストするの?🧐💡

ユースケース(AddTodo / CompleteTodo / ListTodos)は、アプリの「判断」と「手順」が集まる場所だよね✨ ここをテストすると…

  • 🔥 仕様が崩れてもすぐ気づける
  • 🔁 入口(CLI→HTTP)を変えても安心
  • 🧪 外側(DB/ファイル/ネット)が不安定でも、中心は安定して検証できる

つまり「中心を守る🛡️」が、テストでも実現できるってこと😊💕


3) 今日のテスト戦略(結論)🧩

ユースケース単体テストの定番セットはこれ👇

  • 🧠 InMemoryRepository(配列で保存するやつ)
  • FakeClock(固定時刻を返す)
  • 🆔(必要なら)FakeIdGenerator(固定IDを返す)

外の都合をぜんぶ固定すると、テストが 爆速&安定 になるよ🚀✨


4) テスト基盤(2026の定番寄り)⚙️✨

いま「爆速・TS相性よし」で選ばれがちなのが Vitest だよ🧪⚡ Vitest 4 系が現行メジャーで、VS Code連携も強い✨ (Vitest)

ちなみに Jest も現役で、Jest 30 が “Stable” 側にいるよ(好みで選んでOK)🧪 (Jest)

インストール(Vitest)

npm i -D vitest

ついでにカバレッジ(あとで使う)

Vitest は起動時に必要パッケージのインストールを促してくれるけど、手で入れてもOK😊 V8 カバレッジならこれ👇 (Vitest)

npm i -D @vitest/coverage-v8

package.json にスクリプト追加(例)

{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
}
}

vitest.config.ts(最小)

import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
// 迷ったらまずこれだけでOK😊
globals: true
}
})

5) テスト対象(今回はここだけ)🧠❤️

第32章で狙うのはこれ👇

  • ✅ AddTodoUseCase:追加できる/できない
  • ✅ CompleteTodoUseCase:完了できる/二重完了できない
  • ✅ ListTodosUseCase:一覧がDTOで返る

そして重要なのが… ユースケースはI/Oを知らないので、テストでは Outbound Port を差し替えるだけで動くはず✨


6) テスト用の“差し替え部品”を用意しよう🧰✨

InMemoryTodoRepository(例)

(すでに Chapter 26 のを持ってるならそれを使ってOKだよ😊)

// src/adapters/outbound/InMemoryTodoRepository.ts
import type { TodoRepositoryPort } from '../../app/ports/TodoRepositoryPort'
import type { Todo } from '../../domain/Todo'

export class InMemoryTodoRepository implements TodoRepositoryPort {
private todos: Todo[] = []

async save(todo: Todo): Promise<void> {
const i = this.todos.findIndex(t => t.id === todo.id)
if (i >= 0) this.todos[i] = todo
else this.todos.push(todo)
}

async findById(id: string): Promise<Todo | null> {
return this.todos.find(t => t.id === id) ?? null
}

async findAll(): Promise<Todo[]> {
return [...this.todos]
}
}

FakeClock(例)

// test/fakes/FakeClock.ts
import type { ClockPort } from '../../src/app/ports/ClockPort'

export class FakeClock implements ClockPort {
constructor(private readonly fixed: Date) {}

now(): Date {
return this.fixed
}
}

「時間」をPortにしておくと、テストで一生ラクだよ⏰✨(固定できるのが強すぎる)


7) まず1本!AddTodo の “成功テスト” を書く 🎉🧪

テストの型(Arrange → Act → Assert)🧁

  • 🍳 Arrange:準備
  • ▶️ Act:実行
  • ✅ Assert:確認
// test/usecases/AddTodoUseCase.test.ts
import { describe, it, expect } from 'vitest'
import { InMemoryTodoRepository } from '../../src/adapters/outbound/InMemoryTodoRepository'
import { FakeClock } from '../fakes/FakeClock'
import { AddTodoUseCase } from '../../src/app/usecases/AddTodoUseCase'

describe('AddTodoUseCase', () => {
it('タイトルが正常なら Todo を追加できる ✅', async () => {
// Arrange 🍳
const repo = new InMemoryTodoRepository()
const clock = new FakeClock(new Date('2026-01-23T00:00:00Z'))
const useCase = new AddTodoUseCase(repo, clock)

// Act ▶️
const result = await useCase.execute({ title: '牛乳を買う' })

// Assert ✅
expect(result.title).toBe('牛乳を買う')
expect(result.completed).toBe(false)

const all = await repo.findAll()
expect(all).toHaveLength(1)
expect(all[0]!.title).toBe('牛乳を買う')
})
})

この時点で「テストが文章っぽい」感じ出てきたでしょ?😊📖✨


8) AddTodo の “失敗テスト” を足す(仕様を守る🛡️)🚫🧪

「タイトル空は禁止」みたいなルールは、テストで固定しよ💪

import { describe, it, expect } from 'vitest'
import { InMemoryTodoRepository } from '../../src/adapters/outbound/InMemoryTodoRepository'
import { FakeClock } from '../fakes/FakeClock'
import { AddTodoUseCase } from '../../src/app/usecases/AddTodoUseCase'
import { ValidationError } from '../../src/domain/errors/ValidationError'

describe('AddTodoUseCase', () => {
it('タイトルが空なら ValidationError 🚫', async () => {
const repo = new InMemoryTodoRepository()
const clock = new FakeClock(new Date('2026-01-23T00:00:00Z'))
const useCase = new AddTodoUseCase(repo, clock)

await expect(
useCase.execute({ title: '' })
).rejects.toBeInstanceOf(ValidationError)

const all = await repo.findAll()
expect(all).toHaveLength(0)
})
})

ここでのコツ✨ 失敗したときに「保存されてない」も確認すると、事故が減るよ😊🧯


9) CompleteTodo のテスト(状態遷移の守り🧷✨)

やりたいのはこの3つ👇

  • ✅ 未完了 → 完了できる
  • 🚫 2回目の完了はできない
  • 🚫 存在しないIDはエラー
import { describe, it, expect } from 'vitest'
import { InMemoryTodoRepository } from '../../src/adapters/outbound/InMemoryTodoRepository'
import { AddTodoUseCase } from '../../src/app/usecases/AddTodoUseCase'
import { CompleteTodoUseCase } from '../../src/app/usecases/CompleteTodoUseCase'
import { FakeClock } from '../fakes/FakeClock'
import { DomainError } from '../../src/domain/errors/DomainError'

describe('CompleteTodoUseCase', () => {
it('未完了のTodoは完了できる ✅', async () => {
const repo = new InMemoryTodoRepository()
const clock = new FakeClock(new Date('2026-01-23T00:00:00Z'))

const add = new AddTodoUseCase(repo, clock)
const created = await add.execute({ title: 'レポート出す' })

const complete = new CompleteTodoUseCase(repo, clock)
const done = await complete.execute({ id: created.id })

expect(done.completed).toBe(true)
})

it('完了の二重適用はエラー 🚫', async () => {
const repo = new InMemoryTodoRepository()
const clock = new FakeClock(new Date('2026-01-23T00:00:00Z'))

const add = new AddTodoUseCase(repo, clock)
const created = await add.execute({ title: '掃除する' })

const complete = new CompleteTodoUseCase(repo, clock)
await complete.execute({ id: created.id })

await expect(
complete.execute({ id: created.id })
).rejects.toBeInstanceOf(DomainError)
})
})

10) ListTodos のテスト(DTOで返ってくるのが嬉しい📮✨)

ここで大事なのは👇

  • domain型を外に漏らさない(DTOで返す)
  • ✅ 並び順などがあるなら、テストで固定
import { describe, it, expect } from 'vitest'
import { InMemoryTodoRepository } from '../../src/adapters/outbound/InMemoryTodoRepository'
import { AddTodoUseCase } from '../../src/app/usecases/AddTodoUseCase'
import { ListTodosUseCase } from '../../src/app/usecases/ListTodosUseCase'
import { FakeClock } from '../fakes/FakeClock'

describe('ListTodosUseCase', () => {
it('TodoがDTOの配列で返る 📝', async () => {
const repo = new InMemoryTodoRepository()
const clock = new FakeClock(new Date('2026-01-23T00:00:00Z'))

const add = new AddTodoUseCase(repo, clock)
await add.execute({ title: 'パン買う' })
await add.execute({ title: 'メール返す' })

const list = new ListTodosUseCase(repo, clock)
const result = await list.execute({})

expect(result.items).toHaveLength(2)
expect(result.items[0]!).toHaveProperty('id')
expect(result.items[0]!).toHaveProperty('title')
expect(result.items[0]!).toHaveProperty('completed')
})
})

11) テストを“仕様書っぽくする”コツ集 📖✨

テストって、読み物として強いとめちゃくちゃ価値が上がるよ😊💕

  • 🏷️ it('〜できる')日本語で仕様っぽく
  • 🧁 Arrange/Act/Assert をコメントで分ける
  • 🎁 期待値は「最小だけ」+「大事な副作用(保存された)」を足す
  • 🧨 バグりやすい境界値を先に書く(空文字、二重完了、存在しないID)

12) VS Codeで気持ちよく回す(実行・デバッグ)🧪🕵️‍♀️

Vitest は VS Code の拡張で、テストの実行・監視・デバッグがしやすいよ✨ (Visual Studio Marketplace)

  • ▶️ テスト横の再生ボタンで1本だけ実行
  • 👀 watch で保存のたびに自動実行
  • 🧷 ブレークポイント置いてデバッグもOK

13) ちょい足し:カバレッジ(あとで効くやつ)📊✨

「テストしてる気になってた…」を防ぐために、たまに見るのはアリ😊 Vitest は V8 / Istanbul を選べて、V8がデフォルトだよ📌 (Vitest)

npm run coverage

Vitest 4 系ではカバレッジ周りに変更もあるから、更新時は migration をチラ見すると安心だよ👀✨ (Vitest)


14) AI拡張の使い方(この章での正解🤖✨)

AIはここで超頼れるよ😆💕

✅ 使っていい(むしろ使うと速い)

  • テストケースの洗い出し(境界値リスト化)📝
  • AAAの雛形生成(describe/it の骨組み)🦴
  • 失敗ケースの網羅チェック✅

⚠️ 注意(ここは自分で握る)

  • Portの粒度を勝手に変える
  • ユースケースに外側の型を混ぜる
  • 例外方針を勝手に変える

そのまま使えるお願いテンプレ🎁

  • 「AddTodo/CompleteTodo/ListTodosの**仕様(成功/失敗)**を箇条書きで出して」
  • 「各仕様を it文(日本語) にして」
  • 「副作用(保存・更新)も確認するAssert案を足して」

まとめ 🎁💖

  • 🧪 まずは ユースケース単体テスト が最優先
  • 🔁 Portは InMemory/Fake に差し替えて固定
  • 📖 テストを 仕様書みたいに読む を目指す

次の章(エラー設計)に進むと、今日書いたテストがさらに “設計のガードレール” になるよ🧯✨