テスト規約と実践ガイド
テスト規約と実践ガイド
BluePeriodにおけるテストの規約、パターン、ディレクトリ構造を定義する。開発者がテストを書く・読む・実行する際の参照先として機能する。
1. テストフレームワーク
Vitest(vitest)を使用する。
- Viteベースの高速テストランナー。Next.js / Reactエコシステムで広く採用されている
- Jest互換API(
describe、it、expect、vi、beforeEachなど) - ファイルスコープのモックホイスティング(
vi.mock())により、テスト間の完全な分離を保証する vitest.config.tsでパスエイリアス(@/*)を解決する- UIモード(
@vitest/ui)を標準搭載
実行コマンド
このプロジェクトはモノレポ構成(plot-to-prose/ がルート、next-app/ がアプリケーション)である。テスト対象コードは next-app/ に存在する。
# ルートから(どこでも実行可能)
bun next:test # next-app/ の全テストを実行
# next-app/ ディレクトリ内から
cd next-app
bunx vitest run # 全テストを1回実行
bunx vitest # ファイル変更を監視してテストを再実行
bunx vitest --ui # UIモードでテストを確認
bunx vitest run --sequence.shuffle # 実行順序をランダム化(隔離性の検証)
bunx vitest run src/lib/features/<path>/__tests__/xxx.test.ts # 特定ファイルのみ実行重要: bun test は使用しないでください。Bunの内蔵ランナーが使用され、vi.hoisted などのVitest APIに非対応になるため、必ず bunx vitest run または bun next:test を使用してください。
2. テストの分類
テストピラミッド
┌─────────────────────────────────────┐
│ E2Eテスト(将来) │
│ ┌───────────────┐ │
│ │ 統合テスト │ Tool + Service │
│ └───────────────┘ │
│ ┌───────────────┐ │
│ │ ユニットテスト │ Service・純粋関数│
│ └───────────────┘ │
└─────────────────────────────────────┘| 分類 | 対象 | 目的 |
|---|---|---|
| ユニットテスト | Service層、純粋関数 | 個々の関数が正しい入出力を満たすことの保証 |
| 統合テスト | Tool定義(DynamicStructuredTool) | Tool → Service の連携が正しいことの保証 |
| E2Eテスト | 将来検討 | ユーザー操作的なシナリオ |
現在はユニットテストの蓄積を最優先とする。
3. ディレクトリ規約
テストファイルは対象ソースコードと同じ階層に __tests__/ ディレクトリを置く。
src/lib/features/
editorial/services/
prompt-service.ts
manuscript-service.ts
__tests__/
prompt-service.test.ts
manuscript-service.test.ts
characters/
service.ts
__tests__/
character-service.test.ts命名規則: <対象ファイル名>.test.ts
4. テストの書き方
4.1 基本構造
すべてのテストは Arrange → Act → Assert の3フェーズで構成する。
import { describe, it, expect, vi } from "vitest";
describe("PromptService.applyStructuredEdits", () => {
it("説明文", async () => {
// Arrange: 入力データとモックを用意
const mockGet = vi.fn(() => fakePrompts);
const mockSet = vi.fn();
// Act: テスト対象を呼び出し
const result = await PromptService.applyStructuredEdits(mockGet, mockSet, "p1", aditOutput);
// Assert: 結果を検証
expect(result).toBe("期待されるテキスト");
});
});4.2 検証すべきこと
| パターン | 検証内容 |
|---|---|
| 正常系 | 正しい入力 → 正しい結果が返る |
| 境界値 | 空文字、単一要素、大量データ |
| エラー系 | 存在しないID → エラーが投げられる |
| 副作用 | set(更新処理)が正しい回数・引数で呼ばれる |
4.3 テストの説明文
it() の説明文は「期待される振る舞い」を簡潔に記述する。基本的にはグローバルな文脈理解の迅速化のため英語で書く。
it("Throwing an error with a non-existent ID")
it("The ability to correctly apply multiple snippets.")5. モック戦略
Service層のテストでは、外部依存(Jotai atom、Dexie/IndexedDB)をモック化して対象を隔離する。
5.1 モックの基本原則
- I/Oだけをモックする: DB通信や外部APIなどの副作用のみを
vi.mock()で差し替え、ロジックは本物を使う - 最小限のモック: そのテストに必要な関数・exportだけをモックし、残りは
vi.importActualで本物の挙動を残す - 依存の根本原因を狙う: Dexie(
@/lib/db)がインポート連鎖の根本原因。@/lib/dbをモックすれば、多くのatomモジュールはそのままインポート可能になる
5.2 JotaiベースのService
Jotaiの get/set をモック関数として渡す。atom参照そのものはモック不要。
import { vi } from "vitest";
import type { Getter, Setter } from "jotai";
const fakePrompts = [
{ id: "p1", content: "Hello world", role: "system", enabled: true }
];
const createMockGetSet = () => ({
get: ((_: unknown) => fakePrompts) as Getter,
set: vi.fn() as unknown as Setter,
});5.3 DexieベースのService
@/lib/db モジュールを vi.mock() で置き換える。vi.mock() はファイルスコープでホイスティングされるため、テスト間で完全に独立する。
import { vi } from "vitest";
vi.mock("@/lib/db", () => ({
db: {
characters: {
get: vi.fn((_id: string) => Promise.resolve(fakeCharacter)),
put: vi.fn(() => Promise.resolve()),
},
},
}));vi.mock() のファクトリ関数はファイルトップレベルに自動ホイスティングされる。ファクトリ内で可変変数(Map等)を使う場合は vi.hoisted() でラップする:
const { savedData } = vi.hoisted(() => ({
savedData: new Map<string, Character>(),
}));
vi.mock("@/lib/db", () => ({
db: {
characters: {
get: vi.fn((id: string) => Promise.resolve(savedData.get(id))),
},
},
}));Dexieサービスが依存するJotai atom更新(loadCharactersAtom等)や touchGlobalUpdate も同様にモック化する。
DexieモックのMapベース永続化: CoverImageService のテストでは、Map<string, StoredRecord> を使って複数CRUD操作間でデータを永続化するパターンを採用。putでMapに書き込み、getでMapから読み出し、deleteでMapから削除することで、実DBなしにCRUD一貫性を検証できる。
5.4 importOriginalによる部分モック
モジュール全体をフェイクに置き換えるのではなく、本物のモジュールを読み込んで一部だけを上書きする:
vi.mock("@/stores/projectStateAtoms", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/stores/projectStateAtoms")>();
return {
...actual,
structuredPlotAtom: { toString: () => "structuredPlotAtom" },
manuscriptsAtom: { toString: () => "manuscriptsAtom" },
manuscriptContentChangeAtom: mockManuscriptContentChangeAtom,
};
});5.5 SupabaseベースのService
Supabaseのクエリビルダーはfluentパターンを採用している。vi.mock()ではなく、thenableなチェーンオブジェクトを直接構築して注入する。
import { vi } from "vitest";
function createSupabaseChain(resolvedValue: { data: unknown; error: unknown }) {
const chain = {
then(resolve: (v: unknown) => void, reject?: (e: unknown) => void) {
if (resolvedValue.error) { reject?.(resolvedValue.error); return; }
resolve(resolvedValue);
},
catch(reject: (e: unknown) => void) { if (resolvedValue.error) reject(resolvedValue.error); },
select: vi.fn(() => chain),
insert: vi.fn(() => chain),
update: vi.fn(() => chain),
upsert: vi.fn(() => Promise.resolve({ data: null, error: null })),
delete: vi.fn(() => chain),
eq: vi.fn(() => chain),
order: vi.fn(() => chain),
single: vi.fn(() => Promise.resolve(resolvedValue)),
};
return chain;
}
const chain = createSupabaseChain({ data: fakeData, error: null });
const sb = { from: vi.fn(() => chain) };5.6 純粋関数はモック不要
AditService.applySnippets など、外部状態に依存しない純粋関数はそのまま呼び出してテストする。モック化は不要。
テスト容易性のための純粋関数抽出パターン: DOM依存のコード(Canvas、FileReader等)を含む関数は、テスト可能な純粋計算部分を分離することでテストカバレッジを確保できる。例えば image-processing.ts では、リサイズ計算を calculateDimensions(width, height, maxDimension) として抽出し、DOM依存のcompressImageから呼び出す構成にした。これにより純粋関数のみをテスト可能。
5.7 共通テストヘルパー
lib/test-helpers/ にモックセットアップの共通ヘルパーを配置する。各テストでの重複定義を避け、保守性を向上させる。
| ヘルパー | 内容 |
|---|---|
createSupabaseChain | Supabase thenable chain builder |
createLocalStorageMock / injectLocalStorageMock | テスト環境用localStorageモック |
createMockGetSet | Jotai get/setモック(Getter/Setter型付き) |
createTestStore | Jotai Store の型安全ラッパー(get / setImmer / invoke) |
import { createTestStore } from "@/lib/test-helpers";
const store = createTestStore();
store.setImmer(structuredPlotAtom, initialValue);
const result = store.get(structuredPlotAtom);
await store.invoke(createPlotAtom, { parentId: null, structure });createTestStore は atomWithImmer の型シグネチャ不一致をカプセル化し、テスト本文から as any を排除する。
6. CI/CD統合
GitHub Actions(.github/workflows/ci.yml)でPR作成時にテストが自動実行される。checkジョブ内で bun next:test(= cd next-app && bunx vitest run)が走り、テスト失敗時はマージ不可となる。
7. テスト実行の確認項目
PRの際、以下を満たすことを確認する:
bun next:testで全テストがパスすること(既存テストの失敗がないこと)bun next:checkが成功すること(テスト追加による型エラーがないこと)- 追加・変更したServiceメソッドに対してテストが存在すること
- 新規作成したJotai Atomのwrite関数に対してテストが存在すること
8. Jotai Atom のテスト規約
Atomのwrite関数は get/set を通じて他のAtomとやり取りする。テストでは get/set をモックし、Atomの依存関係を隔離する。
8.1 テスト対象の判断基準
| Atom種別 | テスト要否 | 理由 |
|---|---|---|
純粋な状態Atom(atom(initialValue)) | 不要 | 値の保持のみでロジックなし |
| 読み取り専用派生Atom | 不要 | 純粋な計算は読み取り側で検証 |
| Write-only Atom(副作用あり) | 必須 | ロジック・分岐・他Atomへの副作用を検証 |
8.2 モックパターン
import { describe, it, expect, vi } from "vitest";
// Atom依存を部分モック(本物 + 上書き)
vi.mock("@/stores/projectStateAtoms", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/stores/projectStateAtoms")>();
return {
...actual,
structuredPlotAtom: { toString: () => "structuredPlotAtom" },
manuscriptContentChangeAtom: mockManuscriptContentChangeAtom,
};
});8.3 Store ベースのテスト
Atomの連鎖を実際に動かして検証したい場合は createTestStore() を使う。DB依存(@/lib/db)をモックすれば、本物のatomをインポートして store 経由で操作できる。
import { createTestStore } from "@/lib/test-helpers";
vi.mock("@/lib/db", () => ({ db: {} }));
const { structuredPlotAtom } = await import("@/stores/projectStateAtoms");
const store = createTestStore();
store.setImmer(structuredPlotAtom, { language: "ja", parts: [] });8.4 検証項目
- 正常系: 正しい入力 →
setが正しい引数で呼ばれる - 分岐:
isOriginalの真偽でsourceLangの計算が変わる - 副作用: 対象Atomへの
set呼び出し回数が期待通り
8.5 テストの配置
src/stores/__tests__/<atom-file-name>.test.ts
9. テストがスキップされた場合の振り返りテンプレート
新機能実装後にテストが書かれなかった場合、以下の観点で原因を分析し、テスト戦略の改善に活かす。
| 観点 | チェック項目 |
|---|---|
| カバレッジの認識 | 「テストすべき対象」の認識が実装者とドキュメントで一致していたか |
| 規約の可視性 | テスト規約が実装前のチェックリストに含まれていたか |
| インフラの準備 | 必要なモックヘルパーやテストテンプレートが存在したか |
| タイミング | 「実装後にテスト」ではなく「実装と同時にテスト」が可能な体制か |
運用: document_maintenance.md のフェーズ3(Execution)に「テストの作成」を含め、Reportにテスト結果を記載する。
関連
- 06_development_guidelines — コーディング規約全般
- 18_agent_tool_integration_guide — エージェントツール統合ガイド