BluePeriod Docs
開発

テスト規約と実践ガイド

テスト規約と実践ガイド

BluePeriodにおけるテストの規約、パターン、ディレクトリ構造を定義する。開発者がテストを書く・読む・実行する際の参照先として機能する。


1. テストフレームワーク

Vitestvitest)を使用する。

  • Viteベースの高速テストランナー。Next.js / Reactエコシステムで広く採用されている
  • Jest互換API(describeitexpectvibeforeEachなど)
  • ファイルスコープのモックホイスティング(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 モックの基本原則

  1. I/Oだけをモックする: DB通信や外部APIなどの副作用のみを vi.mock() で差し替え、ロジックは本物を使う
  2. 最小限のモック: そのテストに必要な関数・exportだけをモックし、残りは vi.importActual で本物の挙動を残す
  3. 依存の根本原因を狙う: 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/ にモックセットアップの共通ヘルパーを配置する。各テストでの重複定義を避け、保守性を向上させる。

ヘルパー内容
createSupabaseChainSupabase thenable chain builder
createLocalStorageMock / injectLocalStorageMockテスト環境用localStorageモック
createMockGetSetJotai get/setモック(Getter/Setter型付き)
createTestStoreJotai 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 });

createTestStoreatomWithImmer の型シグネチャ不一致をカプセル化し、テスト本文から as any を排除する。


6. CI/CD統合

GitHub Actions(.github/workflows/ci.yml)でPR作成時にテストが自動実行される。checkジョブ内で bun next:test(= cd next-app && bunx vitest run)が走り、テスト失敗時はマージ不可となる。


7. テスト実行の確認項目

PRの際、以下を満たすことを確認する:

  1. bun next:test で全テストがパスすること(既存テストの失敗がないこと)
  2. bun next:check が成功すること(テスト追加による型エラーがないこと)
  3. 追加・変更したServiceメソッドに対してテストが存在すること
  4. 新規作成した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にテスト結果を記載する。


関連

On this page