第16章:テスト入門(Model/Serviceを守る)🛡️🧪✨
この章は、「壊れやすいのに、壊れると地味に痛いところ」= Model / Service をテストで守る章だよ〜🥳 (ViewやControllerはUI変更で揺れやすいので、まずは“ロジックの心臓部”から守るのがコスパ最高💘)
16.1 まず今日のゴール🎯✨

終わる頃にはこれができるようになるよ👇
- Modelの「ルール(不変条件)」をテストで固定できる🧱🛡️
- Serviceの「処理の流れ(保存した?戻り値どう?)」をテストで確認できる🔁✅
- 正常 / 異常 / 境界値 を意識してテストを作れる📏🧠
- AIに「テストケース案」を出してもらって、自分で取捨選択できる🤖✂️
16.2 テストって結局なに?(超ざっくり)🍀

テストは一言でいうと、
「このコード、これから先も同じ約束を守れてる?」を自動で確認する仕組み🔒✨
特にModel/Serviceは、ちょっとした修正で壊れやすいのに、壊れても気づきにくい😇 だからテストで “地雷除去” しておくと安心感が段違いだよ〜〜🧯💕
16.3 この章で使うテスト道具(いまどき構成)🧰✨
今回は Vitest を使うよ〜! Vite系の開発と相性が良くて、導入も軽め🪽 公式でも「依存関係として入れるのがおすすめ」って書かれてるよ📦✨ (vitest.dev)
さらに👇みたいな特徴もあるよ:
- ふだんは watchモードが基本(変更すると関連テストが賢く走る)🔁⚡ (vitest.dev)
- 1回だけ実行したいときは
vitest runが使える🏃♀️💨 (vitest.dev) - カバレッジは既定で V8 provider(NodeみたいなV8系ランタイムが必要)📊 (vitest.dev)
ちなみにNodeは2026年1月時点で v24 が Active LTS、v25 が Current だよ🟢🆕(更新日も確認できる) (Node.js) TypeScriptも 5.9 が提供されてるよ〜🧡 (Microsoft for Developers)
16.4 セットアップ(最小)🛠️✨
① 依存関係を追加📦
npm i -D vitest @vitest/coverage-v8
@vitest/coverage-v8はカバレッジ(網羅率)を出す用📊 (npm)
② package.json に scripts を追加▶️
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
test:開発中はこれが一番ラク(watch)🔁test:run:CIや「1回だけ」で便利🏁 (vitest.dev)test:ui:ブラウザUIで気持ちよく確認できるやつ✨ (vitest.dev)
③ 設定ファイル(必要なら)⚙️
まずはデフォルトでもOK! でもカバレッジ出力先とか整えたいなら👇
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
reporter: ['text', 'html'],
reportsDirectory: './tests/coverage'
}
}
});
environmentは既定が Node だよ🐢(DOMテストしたいならjsdom/happy-domも選べる) (vitest.dev)
16.5 テストの置き場所&命名ルール(迷子防止)🗂️✨
おすすめはこれ👇(シンプルでOK)
-
src/…本体 -
tests/…テストtests/model/xxx.test.tstests/service/yyy.test.ts
命名の雰囲気はこんな感じ💡
TodoItem.test.ts(またはtodoItem.test.ts)TodoService.test.ts
16.6 まず1本:Modelテスト(不変条件を守る)🛡️📦
✅ ねらい
Modelが保証したいルール(例)👇
- タイトルは空欄ダメ🙅♀️
- タイトルは前後の空白をトリムする✂️
- 期限は「過去日付」ダメ(必要なら)⏳🚫
例:Model(TodoItem)側をこんな形にする📦
※すでに似た構造があるなら、読み替えてOKだよ〜🙆♀️✨
// src/model/TodoItem.ts
export class DomainError extends Error {
constructor(public code: 'TITLE_EMPTY' | 'DUE_IN_PAST') {
super(code);
}
}
export type TodoItemParams = {
id: string;
title: string;
done?: boolean;
dueDate?: Date;
};
export class TodoItem {
private _title: string;
private _done: boolean;
private _dueDate?: Date;
private constructor(params: TodoItemParams) {
this._title = params.title;
this._done = params.done ?? false;
this._dueDate = params.dueDate;
}
static create(params: TodoItemParams, now: Date = new Date()): TodoItem {
const title = params.title.trim();
if (title.length === 0) throw new DomainError('TITLE_EMPTY');
if (params.dueDate) {
// “過去”判定はプロジェクトのルールに合わせてね(ここでは now より前はNG)
if (params.dueDate.getTime() < now.getTime()) throw new DomainError('DUE_IN_PAST');
}
return new TodoItem({ ...params, title });
}
get title(): string {
return this._title;
}
}
✅ Modelテスト(1本目)✍️
// tests/model/TodoItem.test.ts
import { describe, it, expect } from 'vitest';
import { TodoItem, DomainError } from '../../src/model/TodoItem';
describe('TodoItem.create', () => {
it('タイトル前後の空白をトリムして作成できる✨', () => {
const now = new Date('2026-01-14T00:00:00+09:00');
const todo = TodoItem.create({ id: '1', title: ' レポート ' }, now);
expect(todo.title).toBe('レポート');
});
it('タイトルが空欄(空白だけ)ならエラーになる🚫', () => {
const now = new Date('2026-01-14T00:00:00+09:00');
expect(() => TodoItem.create({ id: '1', title: ' ' }, now))
.toThrowError(DomainError);
});
it('期限が過去ならエラーになる⏳🚫', () => {
const now = new Date('2026-01-14T00:00:00+09:00');
const past = new Date('2026-01-13T23:59:59+09:00');
expect(() => TodoItem.create({ id: '1', title: '課題', dueDate: past }, now))
.toThrowError(DomainError);
});
});
🌟ここがポイント
- nowを引数で固定してるから、テストが日付でブレないよ📅🧊
- ルールが変わったら「Modelとテスト」だけ直せばOKになりやすい🙆♀️✨
16.7 次の1本:Serviceテスト(処理の流れを守る)🍔➡️🥗🧪
✅ ねらい
Serviceはだいたいこういう事故が起きがち💥
- 保存し忘れて、再読み込みで消える😇
- 返すデータがズレる(UIが変になる)😵💫
- 例外/エラー時の扱いが曖昧でバグる🌀
だから Service は FakeRepository を使って “外部なし” で確かめるよ🧸✨
例:RepositoryとService(超ミニ)📦
// src/repository/ITodoRepository.ts
import { TodoItem } from '../model/TodoItem';
export interface ITodoRepository {
saveAll(items: TodoItem[]): Promise<void>;
loadAll(): Promise<TodoItem[]>;
}
// src/service/TodoService.ts
import { ITodoRepository } from '../repository/ITodoRepository';
import { TodoItem } from '../model/TodoItem';
export class TodoService {
constructor(
private repo: ITodoRepository,
private nowProvider: () => Date = () => new Date()
) {}
async add(title: string): Promise<TodoItem[]> {
const now = this.nowProvider();
const current = await this.repo.loadAll();
const newItem = TodoItem.create(
{ id: crypto.randomUUID(), title },
now
);
const next = [...current, newItem];
await this.repo.saveAll(next);
return next;
}
}
crypto.randomUUID()はブラウザ環境だとOKだけど、テストで不安なら「ID生成もDI」するとさらに安定するよ🧠✨
FakeRepository(テスト用)🧸
// tests/fakes/FakeTodoRepository.ts
import { ITodoRepository } from '../../src/repository/ITodoRepository';
import { TodoItem } from '../../src/model/TodoItem';
export class FakeTodoRepository implements ITodoRepository {
private items: TodoItem[] = [];
public savedSnapshots: TodoItem[][] = [];
constructor(initial: TodoItem[] = []) {
this.items = [...initial];
}
async loadAll(): Promise<TodoItem[]> {
return [...this.items];
}
async saveAll(items: TodoItem[]): Promise<void> {
this.items = [...items];
this.savedSnapshots.push([...items]);
}
}
✅ Serviceテスト(1本目)✍️
// tests/service/TodoService.test.ts
import { describe, it, expect } from 'vitest';
import { TodoService } from '../../src/service/TodoService';
import { FakeTodoRepository } from '../fakes/FakeTodoRepository';
import { TodoItem } from '../../src/model/TodoItem';
describe('TodoService.add', () => {
it('追加すると、保存されて、一覧が1件増える✅✨', async () => {
const now = new Date('2026-01-14T00:00:00+09:00');
const repo = new FakeTodoRepository([
TodoItem.create({ id: 'a', title: '既存' }, now)
]);
const service = new TodoService(repo, () => now);
const result = await service.add('新規');
expect(result).toHaveLength(2);
expect(repo.savedSnapshots).toHaveLength(1);
expect(repo.savedSnapshots[0]).toHaveLength(2);
expect(result[1].title).toBe('新規');
});
});
16.8 境界値テストの作り方(初心者でもすぐ強くなる)📏✨
境界値っていうのは「ギリギリ」のところだよ〜🧠💡 たとえば👇
- タイトル長さ:0文字 / 1文字 / 最大文字数ちょうど
- 日付:今ちょうど / 1ms前 / 1ms後
- 件数:0件 / 1件 / たくさん(必要になったら)
AIに出してもらうプロンプト例🤖💬
(そのままコピペOK)
- 「TodoItemの不変条件テストケースを、正常/異常/境界値で10個出して」🧠📋
- 「TodoService.add の失敗パターンを洗い出して(保存失敗、重複、無効入力など)」🕵️♀️💥
- 「この仕様(貼る)に対して、最小のユニットテストセットを提案して」✂️✅
👉 ただし!AI案は “盛りがち” だから、今の章で守りたい最小に削るのが勝ち✨✂️
16.9 よくあるミス集(先に潰す)🧯💥
- テストが日付や乱数でブレる
→
nowProvider/idGeneratorをDIして固定しよ🧊 - 1テストで確認しすぎて、壊れた時どこが原因かわからない → “1テスト1理由” が気持ちいい🥰
- UI(DOM)まで一緒にテストして辛くなる
→ まずModel/Serviceだけ守るのが正解🙆♀️💘
(DOMテストが必要になったら
jsdom/happy-domを使う感じだよ) (vitest.dev)
16.10 ミニ演習(この章のゴール達成セット)📝✨
演習A:Modelテストをもう1本追加🛡️
- done切替や編集メソッドがあるなら、それを1本テスト✅
例)
toggleDone()が true/false 反転する、など🔁
演習B:Serviceテストをもう1本追加🧪
- 例)無効タイトルで追加したらエラーになる🚫
- 例)Repository保存が失敗した時の扱い(throwする/Resultで返す)を決めてテスト📌
演習C:カバレッジを出して眺める📊👀
npm run test:coverage
- VitestはV8カバレッジが基本だよ〜📈 (vitest.dev)
16.11 まとめ🎀✨
- まず守るべきは Model / Service(ロジックの心臓部)🫀🛡️
- テストは「未来の自分を助ける保険」💝
- 正常/異常/境界値 の3点セットができたら、もう強い💪✨
次の章(第17章)では、完成+ふりかえりで「MVCを説明できる」状態まで仕上げるよ〜🎓🌸