メインコンテンツへスキップ
qwqb.net

根拠あるテストをいい感じに Claude Code で自動生成できないか試した

·
ProgrammingTypeScript

はじめに

テストを書いていると「なぜこのテストケースを選んだのか」「他に必要なケースはないのか」という問いにうまく答えられない場面があります。カバレッジが高くても、それは行を通過しただけであって、テストの正しさを保証しているわけではありません。

自分は Claude Code を最近使っていて、テスト生成を頼むと「それっぽいテスト」は出てきます。たとえば calculateDiscount という割引計算の関数に対して、スキルなしでテスト生成を頼むとこんな感じです。

describe('calculateDiscount', () => {
  describe('正常系', () => {
    it('クーポンなしの場合、割引なしで合計金額を返す', () => {
      expect(calculateDiscount(100, 2, '')).toBe(200);
    });

    it('SAVE10 クーポンで 10% 割引', () => {
      expect(calculateDiscount(100, 1, 'SAVE10')).toBe(90);
    });

    it('SAVE20 クーポンで 20% 割引', () => {
      expect(calculateDiscount(100, 1, 'SAVE20')).toBe(80);
    });

    it('price が 0 の場合は 0 を返す', () => {
      expect(calculateDiscount(0, 5, 'SAVE10')).toBe(0);
    });
  });

  describe('異常系', () => {
    it('price が負の場合にエラーを投げる', () => {
      expect(() => calculateDiscount(-1, 1, '')).toThrow('price must be non-negative');
    });

    it('quantity が 0 の場合にエラーを投げる', () => {
      expect(() => calculateDiscount(100, 0, '')).toThrow('quantity must be a positive integer');
    });
  });
});

正常系・異常系に分かれていて、テストとしては動きます。ただ、「なぜ price が 100 で quantity が 2 なのか」「price と quantity と coupon の組み合わせはこれで足りているのか」に対する答えがどこにもありません。テストケースの選定が書いた人の勘に依存しています。

そこで、テストの根拠を体系的に担保するスキルを作って試してみることにしました。

この記事では、Claude Code のカスタムスキルの仕組みに触れたことがある方を想定して進めます。

テストに根拠を持たせる考え方

この問題に対して、3 つのアプローチを階層的に組み合わせる方法を考えました。契約の定義が土台にあり、プロパティベーステストが契約を検証し、N-wise が組み合わせの網羅性を補完する構成です。

契約の定義

関数の事前条件・事後条件・不変条件を明文化します。たとえば「price は 0 以上」「結果は price × quantity 以下」「クーポンなしなら結果は price × quantity と一致する」のような約束事です。テストコードの冒頭にコメントとして記述することで、後続のテストが何を検証しているかの根拠になります。

プロパティベーステスト

契約で定義した条件を「性質」として表現し、ランダムな入力で大量に検証します。人間が思いつかない入力パターン(極端に大きな数、空文字列など)を自動で試行してくれるのが強みです。スキルでは fast-check を使うよう指定しています。

N-wise 組み合わせテスト

パラメータが複数ある関数では、全組み合わせを網羅するとケース数が爆発します。ペアワイズ(2-wise)で少数のテストケースに絞りつつ、高い障害検出率を狙います。

全てのコードに 3 つ全部を適用するわけではなく、対象に応じて判断します。プロパティベーステストは純粋関数や数値計算に、N-wise はパラメータが 3 つ以上ある関数に適用する設計です。

カバレッジの扱いも変えています。このスキルではカバレッジを「テストの十分性の指標」ではなく「契約や性質の定義漏れを検出する診断ツール」として使います。未到達パスがあったときに「行を埋めるテスト」を足すのではなく、「契約の定義漏れか」「性質の定義漏れか」「組み合わせの不足か」「防御的コードか」の 4 分類で判断します。

検証 1: 純粋関数

スキルが実際にまともなテストを生成するか確認するために、検証用のリポジトリを用意しました。calculateDiscount 関数を実装し、スキルを使ってテストを生成・実行します。

テスト対象の関数はシンプルです。価格・数量・クーポンコードを受け取り、割引後の合計金額を返します。

export function calculateDiscount(price: number, quantity: number, couponCode: string): number {
  if (price < 0) {
    throw new Error('price must be non-negative');
  }
  if (!Number.isInteger(quantity) || quantity < 1) {
    throw new Error('quantity must be a positive integer');
  }

  let discountRate = 0;
  if (couponCode === 'SAVE10') {
    discountRate = 0.1;
  } else if (couponCode === 'SAVE20') {
    discountRate = 0.2;
  }

  return price * quantity * (1 - discountRate);
}

パラメータが 3 つあり、バリデーションもあるので、3 つのアプローチの検証にちょうどいいサイズです。

生成されたテスト

スキルが生成したテストを見てみます。まずファイル冒頭の契約定義です。

// === Contract: calculateDiscount(price, quantity, couponCode) ===
// Precondition:  price >= 0
// Precondition:  quantity >= 1, quantity is integer
// Postcondition: result >= 0
// Postcondition: result <= price * quantity
// Invariant:     calculateDiscount(price, qty, "") === price * qty
// Invariant:     calculateDiscount(0, qty, coupon) === 0
// Invariant:     calculateDiscount(price, qty, "SAVE10") === price * qty * 0.9
// Invariant:     calculateDiscount(price, qty, "SAVE20") === price * qty * 0.8
describe('calculateDiscount', () => {
  // ... テストケース ...
});

Precondition は呼び出し側が満たすべき条件、Postcondition は戻り値が必ず満たす条件、Invariant は特定の入力に対して常に成り立つ関係です。この契約コメントがあるだけで、後続のテストが何を検証しているかが一目でわかります。レビューする側は契約の妥当性を判断すれば、個別のテストケースを一つずつ追う必要がなくなります。

プロパティベーステストでは、契約から導かれる性質に加えて「単調性」と「割引順序」もテストしています。

// 性質: 価格が高いほど、結果も大きい(quantity・coupon 固定)
it('result increases as price increases with same quantity and coupon', () => {
  fc.assert(
    fc.property(
      fc.float({ min: 0, max: 50_000, noNaN: true }),
      fc.float({ min: 0, max: 50_000, noNaN: true }),
      fc.integer({ min: 1, max: 1000 }),
      fc.constantFrom('', 'SAVE10', 'SAVE20'),
      (priceA, priceB, quantity, coupon) => {
        const resultA = calculateDiscount(priceA, quantity, coupon);
        const resultB = calculateDiscount(priceB, quantity, coupon);

        if (priceA <= priceB) {
          expect(resultA).toBeLessThanOrEqual(resultB + 0.001);
        }
      },
    ),
  );
});

「priceA が priceB より小さいなら、結果もそうであるべき」という性質を検証しています。具体値のテストでは拾いきれない性質です。

ペアワイズのテーブルもコメントで可視化されています。

// --- Pairwise: price_class × quantity_class × coupon_code ---
describe('Pairwise: price_class × quantity_class × coupon_code', () => {
  // === Pairwise Coverage Table ===
  // Parameters: [priceClass, quantityClass, couponCode]
  // Values:
  //   priceClass:    [zero, small, large]
  //   quantityClass: [one, small, large]
  //   couponCode:    ["", "SAVE10", "SAVE20"]
  //
  // 2-wise: 9 test cases cover all 2-parameter combinations
  // | # | priceClass | quantityClass | couponCode |
  // |---|------------|---------------|------------|
  // | 1 | zero       | one           | ""         |
  // | 2 | zero       | small         | "SAVE10"   |
  // | 3 | zero       | large         | "SAVE20"   |
  // | 4 | small      | one           | "SAVE10"   |
  // | 5 | small      | small         | "SAVE20"   |
  // | 6 | small      | large         | ""         |
  // | 7 | large      | one           | "SAVE20"   |
  // | 8 | large      | small         | ""         |
  // | 9 | large      | large         | "SAVE10"   |
  const cases = [
    { price: 0, quantity: 1, coupon: '', expected: 0, desc: 'zero/one/no-coupon' },
    { price: 0, quantity: 5, coupon: 'SAVE10', expected: 0, desc: 'zero/small/SAVE10' },
    { price: 0, quantity: 100, coupon: 'SAVE20', expected: 0, desc: 'zero/large/SAVE20' },
    { price: 9.99, quantity: 1, coupon: 'SAVE10', expected: 9.99 * 0.9, desc: 'small/one/SAVE10' },
    { price: 9.99, quantity: 5, coupon: 'SAVE20', expected: 9.99 * 5 * 0.8, desc: 'small/small/SAVE20' },
    { price: 9.99, quantity: 100, coupon: '', expected: 9.99 * 100, desc: 'small/large/no-coupon' },
    { price: 99999.99, quantity: 1, coupon: 'SAVE20', expected: 99999.99 * 0.8, desc: 'large/one/SAVE20' },
    { price: 99999.99, quantity: 5, coupon: '', expected: 99999.99 * 5, desc: 'large/small/no-coupon' },
    { price: 99999.99, quantity: 100, coupon: 'SAVE10', expected: 99999.99 * 100 * 0.9, desc: 'large/large/SAVE10' },
  ];

  // ... テストケース ...
});

全組み合わせ 27 通りのところを 9 ケースでペアワイズカバレッジを達成しています。テストコードを読まなくても、このテーブルだけでどの組み合わせがカバーされているか把握できるのが良いところです。

検証結果とフィードバック

出力を検証した結果、スキルのルールはおおむね正しく機能していました。ただ、いくつか逸脱も見つかりました。

Arrange-Act-Assert のフェーズ分離コメントを挿入する条件を、当初は「6 行以上のテスト」としていました。しかし、事前条件違反テストのような構造が自明なケースにも不要なコメントが入ってしまいます。最終的に「8 行以上」「Arrange が 3 行以上」「モック/スタブが含まれる」のいずれかに変更しました。

スキルの改善は別の Claude Code の会話で行い、検証用リポジトリではテスト生成と実行に専念する形で進めました。問題を発見するたびにスキルへフィードバックするサイクルを回せたのは、この分離のおかげだと思います。

検証 2: 状態を持つクラス

純粋関数だけでは検証が偏ります。次に、ショッピングカート(add/remove/clear/total)を実装し、同じスキルでテストを生成してみました。状態遷移を伴うコードで 3 つのアプローチがどう機能するかの確認です。

純粋関数との大きな違いは、契約がメソッドごとに分かれ、かつメソッド間の関係を記述する必要がある点です。生成された契約定義を見てみます。

// === Contract: ShoppingCart ===
//
// add(name, price, quantity):
//   Precondition:  price >= 0
//   Precondition:  quantity >= 1, quantity is integer
//   Precondition:  name.trim() !== ""
//   Postcondition: has(name) === true
//   Postcondition: itemCount() increases by quantity
//   Postcondition: total() increases by price * quantity
//   Invariant:     adding same name twice merges quantities (does not create duplicate)
//
// remove(name, quantity?):
//   Precondition:  item must exist in cart (has(name) === true)
//   Precondition:  quantity (if given) must be >= 1
//   Postcondition: if quantity === undefined or quantity >= existing.quantity → item is deleted
//   Postcondition: if 1 <= quantity < existing.quantity → existing.quantity decreases by quantity
//
// clear():
//   Postcondition: itemCount() === 0, uniqueItemCount() === 0, total() === 0
//
// total():
//   Postcondition: result >= 0
//   Invariant:     total() === Σ(item.price * item.quantity) for all items
//
// itemCount():
//   Invariant:     itemCount() === Σ(item.quantity) for all items
//
// uniqueItemCount():
//   Invariant:     uniqueItemCount() === number of distinct item names
//
// getItem(name):
//   Postcondition: returns undefined if item does not exist
//   Postcondition: returns a defensive copy (mutation does not affect cart)
describe('ShoppingCart', () => {
  // ... テストケース ...
});

「add の後に has が true を返す」「remove の後に item_count が減る」といった状態遷移の事後条件が自然に導出されています。純粋関数のときは入出力の関係だけでしたが、状態を持つクラスでは操作の前後関係が契約に含まれるようになります。

プロパティベーステストでは、add と remove の組み合わせに対するラウンドトリップの検証も生成されていました。

// 性質: add → remove(全数量) で元の空状態に戻る
it('add then remove restores empty state', () => {
  fc.assert(
    fc.property(validNameArb, validPriceArb, validQuantityArb, (name, price, quantity) => {
      const c = new ShoppingCart();

      c.add(name, price, quantity);
      c.remove(name);

      expect(c.has(name)).toBe(false);
      expect(c.itemCount()).toBe(0);
      expect(c.total()).toBe(0);
    }),
  );
});

ランダムな name・price・quantity で add した後に remove すると、カートが空に戻ることを検証しています。状態遷移の「逆操作」が正しく機能するかをプロパティベーステストで担保できるのは、具体値テストにはない強みです。

カバレッジ診断

全テストがパスした上で、カバレッジ診断を実行しました。この検証で一番確認したかったのは、カバレッジ未到達パスの分類が正しく機能するかです。calculateDiscount のときはカバレッジ 100% だったので、この診断フローを通過していませんでした。

ショッピングカートでは total() 内に if (sum < 0) return 0 という防御的ガードを意図的に入れています。add の事前条件で price >= 0 かつ quantity >= 1 が保証されているため、正常な操作では sum が負になることはありません。

// === カバレッジ診断結果 ===
// 計測ツール: @vitest/coverage-v8
// 実行コマンド: task testing:typescript:coverage
//
// 計測結果: 96.92% Stmts / 96.66% Branch / 100% Funcs / 96.92% Lines
//
// 未到達パスの分析:
//   [分類D] src/cart.ts:55-57 — total() 内の sum < 0 ガード
//     → 防御的コード。price >= 0 かつ quantity >= 1 が事前条件で保証されているため、
//       正常な操作では sum が負になることはない。テスト対象外として記録。
//   分類A〜C(契約・性質・組み合わせの定義漏れ): なし
//
// カバレッジの数値自体は目標ではなく、分類A〜Cが
// 解消されていることが重要です。

この分岐が分類 D(防御的コード、テスト対象外)として正しく記録されていました。「事前条件で保証されているから到達しない」という判断を、スキルがカバレッジ結果から導出できたのは良い結果です。なお、残りの 3 分類(契約の定義漏れ・性質の定義漏れ・組み合わせの不足)は今回の検証では発生しませんでした。意図的にバグを仕込んで分類を試すところまでは手が回っていないので、ここは今後の検証課題です。

今後の課題

2 つの検証で「純粋関数」と「状態遷移」のパターンはクリアできましたが、実プロジェクトに持ち込むにはまだ足りない部分があります。

一番大きいのはビジネスドメインの読み込みです。calculateDiscount のような自己完結した関数なら契約定義は簡単です。しかし実プロジェクトでは「この金額は税込か税抜か」「この状態遷移は業務上ありえるのか」といった判断が入ります。ドメイン知識がないと契約がそもそも書けません。CLAUDE.md やスキルの参照ファイルにドメインルールを書いておけば Claude が読める形にはなります。ただ、暗黙知として開発者の頭の中にしかないルールをどこまで言語化できるかは別の問題です。

既存テストとの合流も未検証です。すでにテストがあるプロジェクトでスキルを走らせたとき、既存の describe 構造を壊さずに 3 つのアプローチを差し込めるのか。全部書き直しになるなら実運用では使えません。

生成テストのメンテナンス性も考えどころです。3 つ全部が適用される関数だと 1 関数あたり 100 行超のテストが生成されます。これを人間がメンテするのは現実的ではありません。一つのアイデアとして、生成テストは discount.ai.test.ts のような命名で人間のテストと完全に分離し、コードが変わったらスキルで再生成して上書きする運用を考えています。人間はメンテしない、使い捨ての検証レイヤーという位置づけです。CI の実行時間や人間のテストとの重複など詰めるべき点はありますが、方向性としては悪くないかなと思っています。

まとめ

契約定義・プロパティベーステスト・N-wise 組み合わせテストの 3 つのアプローチを Claude Code のカスタムスキルとして実装し、純粋関数と状態遷移クラスの 2 パターンで検証しました。

契約コメントやペアワイズテーブルの可視化でテストの根拠が明示されるようになった点と、カバレッジ診断で防御的コードの検出と分類 A〜C の不在判定が正しく機能した点が収穫でした。実運用に向けた課題はまだ残っていますが、アプローチ自体には手応えがあったので、引き続き模索していきたいと思います。

今回作成したスキルは以下からダウンロードできます。

本記事で作成・使用したスキルのダウンロード

このスキルは記事執筆時点のバージョンであり、今後のメンテナンスは行いません。利用する際はスキルの内容を確認し、セキュリティチェックは各自で必ず行ってください。