Claude Codeで特性テストを書く — 入力の網羅と乱数・日時の固定
テストが無い関数の今の出力をそのまま期待値にする特性テスト。入力パターンの洗い出し、乱数と日時の固定、スナップショット化までをClaude Codeに任せる手順です。
特性テスト(characterization test)は、テストが無い関数の「いまの出力」をそのまま期待値として記録するテストです。正しいかどうかは問わず、書き換えの前後で結果が変わっていないことだけを機械に判定させます。Claude Codeに任せる場合、難所は書くこと自体ではありません。入力をどこまで洗い出すか、実行のたびに変わる乱数と日時をどう止めるか。この2点が決まれば、あとはスナップショットに落とすだけです。
この記事では、Date.now() と Math.random() を内部で使う請求書生成関数を例に、洗い出し・固定・記録の順でClaudeに作らせる流れを組みます。リファクタリング全体の進め方はClaude Codeリファクタリングを安全に進める手順で扱っているので、ここでは特性テストの作り方に絞ります。
特性テストは何を固定するテストか
特性テストが記録するのは仕様ではなく挙動です。税計算が仕様と食い違っていても、その食い違いごと固定します。直すのは書き換えが終わってからで、別の変更として扱います。混ぜると、差分が出たときに「書き換えの副作用」なのか「バグ修正の効果」なのか判別できなくなります。
考え方の源流は、出力のスナップショットを取って「変わっていないこと」を確認するApproval Testsです。このライブラリのトップページは、単体テストの assertEquals(5, person.getAge()) を複合オブジェクトにも広げるものとして、Approvals.verify(person) の形を紹介しています。期待値を手で書かずに、結果そのものを承認する発想です。
Claude Codeの公式ドキュメントにも、近い流れがあります。リファクタリングの節は「同じ振る舞いを保ったまま書き換える」ことを求め、直後に「リファクタ後のコードに対するテストを実行する」手順を置いています。テストの節は、テストが無いコードを探し、足場を作り、境界条件のケースを足し、実行して失敗を直す4段で組まれています。特性テストはこの2つをつなぐ位置にあります。書き換えの前に、いまの挙動に対するテストを緑にしておく。
例に使う関数と、テストを書けなくしている2つの要因
題材は次の関数です。請求書IDに現在時刻と乱数が混ざり、発行日時も実行時刻から作られます。
// src/billing/invoice.ts
export type Item = { name: string; price: number; qty: number }
export function buildInvoice(items: Item[], taxRate: number, coupon?: string) {
const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0)
const discount = coupon === 'HALF' ? subtotal / 2 : 0
const tax = Math.floor((subtotal - discount) * taxRate)
return {
id: `INV-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
issuedAt: new Date().toISOString(),
subtotal,
discount,
tax,
total: subtotal - discount + tax,
}
}このまま expect(buildInvoice(...)).toMatchSnapshot() と書くと、実行のたびに id と issuedAt が変わり、2回目の実行で必ず落ちます。期待値が決まらないテストは安全網になりません。だから先に時刻と乱数を止めます。
手順1: 入力パターンをClaudeに洗い出させる
最初に頼むのはテストコードではなく、入力の一覧です。コードを読ませ、分岐と境界から入力を列挙させます。ファイルに出させると、人間が目で確認できます。
src/billing/invoice.ts の buildInvoice について、特性テスト用の
入力パターンを洗い出して docs/invoice-cases.md に表で書いてください。
まだテストは書かないでください。
分岐ごとに1行以上、境界値(0、負数、空配列、端数が出る組み合わせ)を含めること。
各行に「その入力で何を確かめるのか」を1文で添えてください。「テストは書かないでください」と添えるのは、洗い出しと実装を同じ往復に混ぜないためです。一覧を人間が見て、足りない観点を足してから次へ進めます。例のコードなら、見るべき観点は次のあたりです。
- クーポンあり・なしで
discountの分岐が変わる - 税率が0のとき、
taxが0になる itemsが空配列のとき、すべての金額が0になる- 端数が出る組み合わせ(小計が奇数で
HALF、税率0.1など)で、Math.floorの切り捨てが効く qtyが0や負数の行が混ざったとき、小計がどうなるか
最後の項目のように、仕様として怪しい入力こそ記録する価値があります。Claudeは既存のテストを読んで流儀を合わせ、境界値や異常系を見つける用途に向いています。見逃しやすい入力の提案を頼めることは、公式のテスト節にも書かれています。ただし網羅の最終判断は人間の仕事で、一覧にない入力は固定されません。表を読むときは、書き換えで触る行が通る入力が入っているかを先に見てください。触らない部分の入力は、後回しで構いません。
手順2: 乱数と日時を固定する
入力が決まったら、実行ごとに変わる要素を止めます。日時にはVitestの偽タイマーを使います。ドキュメントの例は vi.useFakeTimers() で偽の時刻を有効にし、vi.setSystemTime(date) で時刻を指定し、テスト後に vi.useRealTimers() で戻す形です。乱数は Math.random を vi.spyOn で差し替えて、固定値を返させます。
// src/billing/invoice.characterization.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { buildInvoice } from './invoice'
describe('buildInvoice の現状の挙動', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-15T09:00:00Z'))
vi.spyOn(Math, 'random').mockReturnValue(0.5)
})
afterEach(() => {
vi.mocked(Math.random).mockRestore()
vi.useRealTimers()
})
// 実際の入力は docs/invoice-cases.md の表と1対1にする
it('クーポンなし・税率10%', () => {
expect(buildInvoice([{ name: 'A', price: 1000, qty: 2 }], 0.1)).toMatchSnapshot()
})
})固定する値には落とし穴が2つあります。
落とし穴
ローカル時刻を読む関数は、マシンのタイムゾーンで結果が変わる
ドキュメントの例は new Date(2000, 1, 1, 13) のようにローカル時刻でDateを作り、getHours() で判定しています。コードが getHours() や toLocaleString() を使うなら、開発機とCIでタイムゾーンが違うだけでスナップショットが食い違います。固定するのは時刻だけでなく、テスト実行時のタイムゾーンも含めます。TZ を揃えて実行するか、UTC基準のISO文字列で時刻を渡す形に寄せてください。
もう1つは固定先の選び方です。乱数を 0.5 に固定すると、Math.floor(0.5 * 1000) は500になり、IDは常に INV-1705309200000-500 に決まります。一方で、乱数の値によって分岐が変わる関数では、固定値1つでは一方の枝しか記録できません。その場合は mockReturnValueOnce を並べるか、乱数ごとにテストを分けるよう、洗い出しの段階で指示します。
Claudeに固定を任せるときは、どの環境に依存しているかを探させる一文が効きます。
buildInvoice が実行ごとに結果を変えうる要因を、時刻・乱数・環境変数・
タイムゾーン・ファイル読み込みの観点で挙げ、テスト側で固定する方法を
提案してください。コード本体は変更しないでください。「コード本体は変更しないでください」が重要です。特性テストの段階で本体に手を入れると、固定の対象そのものが動いてしまいます。時刻や乱数を引数で注入できる形にするリファクタリングは、テストが緑になったあとの仕事です。
手順3: スナップショットに落として緑にする
入力と固定が揃ったら、洗い出した表の全行を it.each かループでテストに展開し、toMatchSnapshot() で記録します。
npx vitest run src/billing/invoice.characterization.test.ts初回の実行ではスナップショットファイルが作られ、緑になります。2回目以降は保存済みの内容と比較して、一致すれば通り、食い違えば落ちます。落ちたときは、バグを見つけたか、実装が変わってスナップショットを更新すべきかの二択です。
スナップショットは差分レビューの対象にもなります。ドキュメントは、スナップショットファイルをコード変更と一緒にコミットし、コードレビューで確認するよう勧めています。Claudeが生成した期待値を人間が読む機会は、このコミットのレビューにしかありません。
JSONで見づらいときは、toMatchFileSnapshot() で拡張子付きの別ファイルに出せます。Vitestが管理する __snapshots__/*.snap のパスをここに指定してはいけない、とも書かれています。
Claudeに -u を使わせない
書き換え中にテストが落ちたとき、最短で緑に戻す手段が vitest -u です。更新フラグを付けると、いまの出力で期待値が上書きされ、テストは必ず通ります。つまり、安全網の目的がそのまま消えます。Claudeが「テストを通すため」に使わないよう、CLAUDE.md に書いておきます。
## 特性テストの扱い
- *.characterization.test.ts と対応する .snap は、リファクタリング中に編集しない
- スナップショットが落ちたら、-u や --update を付けず、差分を報告して止まる
- 期待値を更新してよいのは、人間が差分を確認して指示したときだけCI側にも歯止めがあります。Vitestは process.env.CI が真のとき、スナップショットを書き込みません。不一致、未作成、廃止されたスナップショットのいずれも失敗として扱われます。ローカルで -u を打ってしまっても、CIで期待値が自動的に差し替わる経路は無い、ということです。
緑になったテストが本当に守れているか
緑になったことは、守れていることの証明ではありません。確かめ方は2つあります。
1つ目は、本体に意図的な変更を入れて、テストが赤になるか見る方法です。たとえば Math.floor を Math.round に変えて、端数を扱う行が落ちるか確認します。落ちなければ、その分岐を踏む入力が足りません。2つ目は、ミューテーションテストで機械的に確かめる方法です。変異を自動で入れて、生き残りを数えられます。手順はStrykerのミューテーションテストでClaudeに弱いテストを補強させる記事にまとめています。
もう1つ、見落としやすいのが「余計なテストを足されない」ことです。特性テストの依頼でClaudeが関数を直し始めたり、仕様を推測したテストを混ぜたりすると、固定した挙動が汚れます。余計な修正やテストの追加を抑える指示文は、Claude Fable 5.1の余計な修正とテスト追加を抑える指示文が参考になります。
書き換えに入る前の確認
固定漏れの確認は、Claudeに実行させて結果を読ませると速く済みます。次のように頼みます。
特性テストを続けて2回実行して、結果を報告してください。
2回目に落ちたら、実行ごとに変わっている値(時刻・乱数・順序・環境)を
スナップショットの差分から特定してください。テストの期待値は更新しないでください。特性テストが整ったら、書き換えの前に3点だけ確認します。
- 洗い出しの表の行数と、テストケースの数が一致している
- スナップショットを2回続けて実行して、どちらも緑になる(固定漏れがあると2回目で落ちる)
CLAUDE.mdに、スナップショットを更新しない規則が入っている
ここまで済めば、書き換えの指示は短くて済みます。「buildInvoice を書き換えて、特性テストを通したまま終えてください。通らなくなったら更新せず止まってください」。あとはplan modeで触る範囲を決め、小さな単位で確認しながら進めます。
よくある質問
既にテストがある関数にも特性テストは要りますか
既存のテストが書き換えで触る分岐を踏んでいるなら、足す必要はありません。踏んでいない分岐だけ、手順1の洗い出しで見つけて補います。
乱数のシードを固定する方法と、Math.random の差し替えはどちらがよいですか
コードが Math.random を直接呼んでいるなら、差し替えのほうが対応が単純です。乱数ライブラリのシードを外から渡せる設計なら、シード指定でも同じことができます。どちらも、固定したい値がテストの中に見えている形にしておくと、レビューで追えます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。