tdd について
tddは、公開インターフェースを通じて利用者に見える振る舞いを検証する、テスト駆動開発向けのスキルです。RED→GREEN→リファクタリングを小刻みに進め、境界でのモックやコンパイル時の型テストも扱います。
主な機能
- 01公開インターフェースでの振る舞いテスト
- 02段階的なRED→GREENサイクル
- 03システム境界に限定したモック
- 04テストしやすいインターフェース設計
- 05コンパイル時型アサーションの案内
活用シーン
- 01新機能の実装前に振る舞いテストを作成
- 02複雑なロジックのテストを段階的に構築
- 03修正前にバグを再現するテストを追加
- 04ジェネリクスや複雑な型制約を確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ udecode/ dotai --skill tddインストール後、Claude Code で/tddを入力して読み込まれたか確認します。
詳細
テスト駆動開発
哲学
基本原則:テストでは、実装の詳細ではなく、公開インターフェースを通じて振る舞いを検証します。コードは全面的に変更できますが、テストは変える必要がありません。
良いテストは、統合テスト形式で、公開 API を通じて実際のコードパスを実行します。システムが「どのように」動作するかではなく、「何を」するかを記述します。良いテストは仕様書のように読めます。「有効なカートでユーザーがチェックアウトできる」とあれば、どのような機能があるかが明確にわかります。内部構造を気にしないため、こうしたテストはリファクタリング後もそのまま使えます。
// GOOD: Tests observable behavior
test("user can checkout with valid cart", async () => {
const cart = createCart();
cart.add(product);
const result = await checkout(cart, paymentMethod);
expect(result.status).toBe("confirmed");
});
// GOOD: Verifies through interface
test("createUser makes user retrievable", async () => {
const user = await createUser({ name: "Alice" });
const retrieved = await getUser(user.id);
expect(retrieved.name).toBe("Alice");
});
良いテストの特徴:
- ユーザーや呼び出し元が重視する振る舞いをテストする
- 公開 API のみを使用する
- 内部のリファクタリング後も使える
- どのようにではなく、何をするかを記述する
- テストごとに論理的なアサーションが 1 つある
悪いテストは実装と密接に結びついています。内部の協調クラスをモックしたり、プライベートメソッドをテストしたり、外部の手段(インターフェースを使わずデータベースに直接クエリするなど)で検証したりします。警告サインは、振る舞いが変わっていないのに、リファクタリングでテストが失敗することです。
// BAD: Tests implementation details
test("checkout calls paymentService.process", async () => {
const mockPayment = jest.mock(paymentService);
await checkout(cart, payment);
expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
});
// BAD: Bypasses interface to verify
test("createUser saves to database", async () => {
await createUser({ name: "Alice" });
const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
expect(row).toBeDefined();
});
危険信号:
- 内部の協調クラスをモックする
- プライベートメソッドをテストする
- 呼び出し回数や順序をアサートする
- 振る舞いを変えずにリファクタリングしただけでテストが失敗する
- テスト名が何をするかではなく、どのようにするかを表している
実装より先にテストを書くことをおすすめします。すでにコードを書いている場合は、後からテストを付け足すのではなく、テストから新たに始めることを検討してください。後から書いたテストは、必要なことではなく、作ったものを検証しがちです。
モック
モックを使うのは、システム境界に限ります。
- 外部 API(決済、メールなど)
- データベース(場合によっては。テスト用データベースを優先)
- 時刻や乱数
- ファイルシステム(場合によっては)
次のものはモックしないでください。
- 自分が作成したクラスやモジュール
- 内部の協調クラス
- 自分が制御できるもの
依存性注入を使います — 外部依存関係は内部で作成せず、渡すようにします。
// Easy to mock
function processPayment(order, paymentClient) {
return paymentClient.charge(order.total);
}
// Hard to mock
function processPayment(order) {
const client = new StripeClient(process.env.STRIPE_KEY);
return client.charge(order.total);
}
SDK スタイルのインターフェースを優先します — 外部操作ごとに個別の関数を用意します。
// GOOD: Each function is independently mockable
const api = {
getUser: (id) => fetch(`/users/${id}`),
getOrders: (userId) => fetch(`/users/${userId}/orders`),
createOrder: (data) => fetch("/orders", { method: "POST", body: data }),
};
// BAD: Mocking requires conditional logic inside the mock
const api = {
fetch: (endpoint, options) => fetch(endpoint, options),
};
テスト容易性を考慮したインターフェース設計
-
依存関係は作成せず、受け取る
// Testable function processOrder(order, paymentGateway) {} // Hard to test function processOrder(order) { const gateway = new StripeGateway(); } -
副作用を起こさず、結果を返す
// Testable function calculateDiscount(cart): Discount {} // Hard to test function applyDiscount(cart): void { cart.total -= discount; } -
表面積を小さくする — メソッドが少ないほど必要なテストが減り、パラメーターが少ないほどテストのセットアップが簡単になります。
深いモジュール(『A Philosophy of Software Design』より)とは、小さなインターフェースと豊富な実装を備えたモジュールです。設計時には、メソッドを減らせないか、パラメーターを簡素化できないか、内部に隠せる複雑さを増やせないかを考えてください。
アンチパターン:水平分割
テストをすべて先に書き、その後で実装をすべて書くのはやめてください。 これは「水平分割」と呼ばれ、RED を「テストをすべて書くこと」、GREEN を「コードをすべて書くこと」として扱います。
これでは質の悪いテストが生まれます。
- まとめて書いたテストは、実際の振る舞いではなく、_想像上の_振る舞いをテストする
- ユーザー向けの振る舞いではなく、データ構造や関数シグネチャなど、物事の_形_をテストしてしまう
- 実際の変更に対してテストが鈍感になり、振る舞いが壊れても合格し、振る舞いに問題がなくても失敗する
- 実装を理解する前にテスト構造を確定させてしまい、見通しを誤る
正しい方法:トレーサーバレットを使って垂直に分割します。テストを 1 つ書き、実装を 1 つ行う。これを繰り返します。各テストでは、前のサイクルから得た学びを反映します。
WRONG (horizontal):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
RIGHT (vertical):
RED→GREEN: test1→impl1
RED→GREEN: test2→impl2
RED→GREEN: test3→impl3
...
ワークフロー
1. 計画
コードを書く前に、次のことを行います。
- 必要なインターフェースの変更をユーザーに確認する
- テスト対象とする振る舞い(優先順位を含む)をユーザーに確認する
- 深いモジュール(小さなインターフェースと豊富な実装)にできる箇所を特定する
- テスト容易性を考慮してインターフェースを設計する
- テスト対象の振る舞い(実装手順ではない)を列挙する
- 計画についてユーザーの承認を得る
次のように質問します。「公開インターフェースはどのような形にすべきですか?テストで最も重視すべき振る舞いは何ですか?」
すべてをテストすることはできません。 最も重要な振る舞いをユーザーに確認してください。考えられるすべてのエッジケースをテストするのではなく、重要な経路と複雑なロジックにテストの労力を集中させます。
2. トレーサーバレット
システムについて一つのことだけを確認するテストを一つ書きます。
RED: Write test → run test → confirm it FAILS correctly
GREEN: Write minimal code → run test → confirm it PASSES
- テストがすぐに成功しましたか? 既存の動作をテストしています。テストを修正してください。
- アサーションの失敗ではなくエラーが発生しましたか? まずエラーを修正してください。エラーが発生することと、テストが失敗することは同じではありません。
これがトレーサーバレットです。経路がエンドツーエンドで機能することを証明します。
3. 段階的なループ
残りの動作について、次の手順を繰り返します。
RED: Write next test → run test → confirm it FAILS correctly
GREEN: Write minimal code → run test → confirm it PASSES
ルール:
- 一度に一つのテストだけを書く
- 現在のテストに合格するために必要な分だけコードを書く
- 今後のテストを先取りしない
- 観察可能な動作にテストを絞る
4. リファクタリング
すべてのテストに合格したら、リファクタリングできる箇所を探します。
- 重複を抽出する
- モジュールを深くする(複雑さをシンプルなインターフェースの背後に移す)
- 自然な箇所に SOLID 原則を適用する
- 新しいコードから既存のコードについてわかることを検討する
- リファクタリングの各ステップ後にテストを実行する
リファクタリング候補:重複 → 関数/クラスを抽出、長いメソッド → プライベートヘルパーに分割、浅いモジュール → 統合または深化、機能羨望 → ロジックをデータのある場所に移動、プリミティブ型への執着 → 値オブジェクトを導入。
RED の間は決してリファクタリングしないでください。 まず GREEN にしてください。
サイクルごとのチェックリスト
[ ] Test describes behavior, not implementation
[ ] Test uses public interface only
[ ] Test would survive internal refactor
[ ] Code is minimal for this test
[ ] No speculative features added
[ ] Watched test fail before writing code
[ ] Failure was for expected reason (missing feature, not typo)
[ ] All other tests still pass
バグ修正の例
TDD はバグ修正にも適用されます。まず、バグを再現するテストを書きます。
# Bug: empty email passes validation
RED: test("rejects empty email", () => {
const result = validateEmail("");
expect(result.valid).toBe(false);
});
→ Run test → FAILS (empty string passes validation) ✓
GREEN: Add check: if (!email || !email.includes("@")) return { valid: false }
→ Run test → PASSES ✓
Verify all other validation tests still pass.
型テスト
コンパイル時の型アサーションです。実行時の処理はなく、bun typecheck を実行するだけです。実行時テストでは検出できない、ジェネリクス、条件型、型制約のリグレッションを検出します。
対象となる場合: ジェネリック API、ユーティリティ型、複雑な型推論、マップ型/条件型、無効な使い方でエラーが発生することの確認。対象外: string 型のプロパティが string を受け取れることを確認するような自明なもの。
ユーティリティ
Expect と Equal をエクスポートするファイルを探します。見つからない場合は作成します。
export function Expect<T extends true>() {}
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <
T
>() => T extends Y ? 1 : 2
? true
: false;
export type Not<T extends boolean> = T extends true ? false : true;
export type IsAny<T> = 0 extends 1 & T ? true : false;
export type IsNever<T> = [T] extends [never] ? true : false;
Expect<T extends true>— コンパイルエラーはテスト失敗を意味しますEqual<X, Y>— 型の完全一致(anyによる型の拡大を防ぎます)Not、IsAny、IsNever— エッジケースのガード(any/neverは単純な比較を破綻させます)
正のアサーション
import { Expect, Equal, Not, IsAny } from "./utils";
// Block scope each test to avoid name collisions
{
type Result = ReturnType<typeof myGenericFn<SomeInput>>;
Expect<Equal<Result, { id: string; name: string }>>;
Expect<Not<IsAny<Result>>>;
}
負のテスト
@ts-expect-error は、エラーが発生する行の直前の行に置く必要があります。必ず理由を記載してください。未使用のディレクティブはテスト失敗を意味します(制約が不足しています)。
// ✅ directive on line immediately before error
doSomething({
// @ts-expect-error - name must be string
name: 123,
});
// ❌ directive too far from error
doSomething({
// @ts-expect-error - name must be string
...defaults,
name: 123,
});
ヒント
declare constを使うと、実行時処理なしでモック値を宣言できます:declare const ctx: SomeCtx;- 型レベルのみのアサーションが必要な場合は
type _name = Expect<...>を使います(実行時にExpect()を呼び出す必要はありません) - 型のみのファイルでは、ファイルの先頭に
/* biome-ignore-all lint */を置くと、未使用変数の警告が抑制されます
bun typecheck で実行します。コンパイルできれば合格です。