はじめに
コーディングエージェントに頼めば、コーヒーを飲み終える前に100件のテストが通る状態になります。テスト件数は増え、カバレッジは緑になり、プルリクエストは丁寧に見えます。ただ、そのうち何件がほかのテストと違うことを確認しているのかは、誰も教えてくれません。
私はVitestの重複テストを探す静的解析ツールvitest-dedupをメンテナンスしています。つまり利害関係者です。だからこそ、推測ではなく測りたいと考えました。この問題の難しさは、似ているテストを見つけることではありません。似ているだけのテストを、同じテストと取り違えないことにあります。重複なら片方を消せば済みます。しかし、別のコードパスを通る「似ている」テストを消すと、スイートは緑のままカバレッジだけが失われます。取るべき対処が逆になります。
そこで小さな実験をしました。コーディングエージェントが2つの小さなモジュールに対して、2回のセッションで139件のテストを書きました。怪しいペアはすべて手作業で分類し、3つの検出器を比較しました。単純なトークンベースのクローン検出器、vitest-dedup 0.5.0、そして比較用の0.4.0です。0.5.0は10月17日に私がリリースしました。手順はすべて再現でき、コマンドは記事の最後にまとめています。結果は、私の予想とは違うものでした。
2つのテストが「同じ」になる4つのパターン
数える前に、何を数えるのかを決めておきます。2つのテストの関係は4通りあり、それぞれ取るべき行動が違います。
1つ目は同一のテストです。本体と文脈が同じで、タイトルや変数名だけが違います。片方を消せます。
2つ目は準重複です。本体は違うものの同じ振る舞いをしていて、ツールでは等価だと証明できません。人が見て判断します。
3つ目はパラメータ化の候補です。構造が同じで、リテラルの値だけが違います。どちらのケースも必要なことが多く、1つのtest.eachテーブルにまとめるべきものです。片方を消すとカバレッジが減ります。
4つ目が厄介です。似ている、あるいはテキストまで同じなのに、別の振る舞いを検証しているテストです。これを指摘するツールは、単にうるさいだけではありません。本物のチェックを消す削除を勧めていることになります。
vitest-dedupはこの区別を出力に組み込んでいます。各指摘には判定が付きます。redundant(片方を消せる)、consolidatable(テーブルにまとめ、全行を残す)、またはレビューを促すものです。実験で分かるとおり、指摘の件数よりこの判定のほうが重要です。
単純な検出器が間違えるところ
クローン検出の古典的な方法はトークンを比べます。Type-1クローンはトークンが完全に一致するものです。Type-2クローンは、識別子とリテラルをプレースホルダーに置き換えたうえで一致するものです。テストのコールバックに対して両方を実行する60行ほどの検出器を書きました。タイトルは無視します。汎用のクローン検出器や手早いスクリプトをテストフォルダーに向けた場合の代わりとして、妥当な比較対象です。
この節の例は、「似ている」と「等価」を見分けるために手で書いたトラップです。エージェントが書いたテストは後で扱います。
まず1つ目のトラップです。テキストが同一の2つのテストを見てください。
describe('frozen clock', () => {
const clock = { now: () => 0 }
it('keeps the bucket full', () => {
const bucket = new TokenBucket(3, 1, clock)
expect(bucket.available()).toBe(3)
})
})
describe('clock that jumps an hour per read', () => {
let t = 0
const clock = { now: () => (t += 3_600_000) }
it('keeps the bucket full', () => {
const bucket = new TokenBucket(3, 1, clock)
expect(bucket.available()).toBe(3)
})
})
1つ目は、時間が止まっていれば補充されないことを確認しています。2つ目は、大量の補充が容量で頭打ちになることを確認しています。どちらも3をアサートしていますが、理由はまったく違います。単純な検出器はこれを完全な重複と報告します。本体のテキストは同じでも、clockはそれぞれ別の束縛を指しています。そして、その束縛こそがテストの中身です。
フックでも同じことが起きます。本体が同一の2つのitでも、new Cart(0.1)を作るbeforeEachの下と、new Cart()の下では別のテストです。Type-1はこれも重複とみなします。
vitest-dedup 0.5.0はどちらのペアも報告しません。キャプチャされた変数は、使われる場所で比較に加わります。初期値がたまたま一致しても、どの外側の変数から来たのかを保持しています。フック、モック、フィクスチャはテストの文脈の一部です。文脈が違えば、本体が一致していても等価とはみなしません。
vitest-dedupが報告するのは本物の重複だけです。同一のテストで、片方のcartをbasketに改名しただけのペアです。
逆向きの失敗もよくあります。エージェントは名前を変えるからです。振る舞い以外をすべて変えたペアを見てみましょう。
it('emits one event per order', () => {
const events: string[] = []
class SpyBus {
publish(name: string) {
events.push(name)
}
}
const bus = new SpyBus()
bus.publish('order.placed')
expect(events).toEqual(['order.placed'])
})
it('publishes the order.placed event', () => {
const published: string[] = []
class RecordingBus {
publish(topic: string) {
published.push(topic)
}
}
const recorder = new RecordingBus()
recorder.publish('order.placed')
expect(published).toEqual(['order.placed'])
})
Type-1の比較では、このペアはまったく見つかりません。vitest-dedup 0.4.0は同じ振る舞いの並びを見つけたものの、ローカルクラスの等価性を証明できませんでした。そのため、レビュー用のnear-duplicate-test警告を出しました。0.5.0はローカルクラスをモデル化します。フィールド、メソッド、コンストラクタ、プライベート名、各インスタンスのキャプチャまで扱います。一貫した改名なら比較できるので、判定は証明済みのduplicate-testになります。
ローカルクラスとクロージャが難しい理由
テストコードは小さなローカルクラスとクロージャであふれています。偽の時計、スパイ用のバス、記録用のリポジトリなどです。「同じに見える」と「同じである」がずれるのは、まさにこういう場所です。
ローカルクラスはメソッドだけで決まりません。メソッドのテキストが一致しても、2つのクラスは別のクラスオブジェクトです。同一性やinstanceofを確認するテストなら区別できます。
コンストラクタには、定義時の効果と生成時の効果があります。この2つは別のタイミングです。まとめてしまうと、片方のテストだけが観測する副作用を見落とします。その結果、2つのテストが同じに見えてしまいます。
プロパティの種類も効いてきます。次の2つのオブジェクトは、出力するとほとんど同じに見えます。
const fromGetter = { get limit() { return 3 } } // fromGetter.limit === 3
const fromMethod = { limit() { return 3 } } // fromMethod.limit is a function
どちらを作るかだけが違う2つのテストを、0.4.0はduplicate-testのエラーとして扱っていました。0.5.0はプロパティ記述子の種類を区別し、何も報告しません。
クロージャは可変な状態を持ち込みます。テストが配列をキャプチャし、ヘルパーがそこへpushするとします。この場合、比較は初期値だけでなく配列の履歴を追う必要があります。キャプチャしたオブジェクトが解析できないコードへ渡れば、それについての主張はすべて推測になります。
この最後の点は、私が最も重視している設計判断です。解析器がモデル化できないものとして、インスタンス生成後のプロトタイプ書き換え、デコレーター、リフレクションによるアクセスなどがあります。こうしたものがあれば、依存するテストは未解決として比較から外します。表面のテキストで比べることはしません。そのときは実行全体を不完全としてマークします。指摘の欠如は、重複の不在を示す証拠にならなくなるからです。同じファイル内の無関係なテストは通常どおり比較します。
実験
対象は2つの小さなTypeScriptモジュールです。1つはショッピングカート(商品、クーポン、税、小数点以下2桁への丸め)です。もう1つは、時計を注入できるトークンバケット方式のレートリミッターです。どちらも40〜60行ほどです。
各モジュールについて、新しいコーディングエージェント(Claude)のセッションに、あえてありふれたプロンプトを渡しました。このモジュールの網羅的なVitestスイートを書き、すべて通るようにする、という内容です。続いて、別の新しいセッションに追加の依頼を出しました。どのチームでも一度は書いたことがある依頼です。レビュアーがテスト不足だと言っているので、既存のテストを残したままエッジケースを追加する、というものです。生成されたファイルは手で編集していません。
結果は139件のテストで、すべて通りました。1回目のセッションで93件、2回目で46件です。これとは別のファイルに、先ほどのような手書きのトラップを10件加えました。
そのうえで、どれかの検出器が指摘したペアをすべて分類しました。両方のスイートを最初から最後まで読み、何も指摘されていない冗長性も探しました。各ペアへの問いは単純です。片方を消したとき、唯一のチェックを失う振る舞いがあるかどうかです。
検出器が見つけたもの
エージェントが書いた139件のテストに対する結果は次のとおりです。
| 単純な検出器(Type-1) | 単純な検出器(Type-2) | vitest-dedup 0.4.0 | vitest-dedup 0.5.0 | |
|---|---|---|---|---|
| 報告された完全一致・改名の重複 | 0 | リテラル違いと区別できない | 0 | 0 |
| 類似グループまたは指摘 | 0 | 11グループ(34件) | 19件 | 18件 |
| 無関係なテストが混ざったグループ | 0 | 3(別のメソッドを呼んでいる) | 1(実際は別物の準重複) | 0 |
| 私なら却下するテーブル化の提案 | なし | なし | 2 | 2 |
最初の驚きは、エージェントがコピー&ペーストをしなかったことです。139件の中に、完全一致の重複と改名しただけの重複は、1件もありませんでした。繰り返されていたのは形です。準備、実行、検証の流れが同じで、数値だけが違っていました。
vitest-dedup 0.5.0はparameterization-candidateを18件報告し、対象は31件のテストでした。すべて警告で、「1つのtest.eachテーブルにまとめ、各ケースを1行として残す」という対処が付いています。私は15件に同意し、1件は判断が分かれ、2件を却下しました。
受け入れた典型例は、3つの税計算のテストです。税率、価格、期待値だけが違います。
// before: three tests with the same shape
it('applies tax to the subtotal', () => {
const cart = new Cart(0.1)
cart.addItem(item('a', 100))
expect(cart.total()).toBe(110)
})
// ...'doubles the amount at a tax rate of 1', 'rounds the final total to cents'
// after: one table, every case kept as a named row
it.each([
{ case: 'applies tax to the subtotal', taxRate: 0.1, price: 100, expected: 110 },
{ case: 'doubles the amount at a tax rate of 1', taxRate: 1, price: 25, expected: 50 },
{ case: 'rounds the final total to cents', taxRate: 0.0825, price: 19.99, expected: 21.64 },
])('$case', ({ taxRate, price, expected }) => {
const cart = new Cart(taxRate)
cart.addItem(item('a', price))
expect(cart.total()).toBe(expected)
})
まとめた後もスイートは149件のまま全件通り、これらのテストに関わる2件の指摘は消えます。
却下した2件からは学ぶことがあります。片方のペアでは、カートが受け取るSKUは'a'と'a'です。もう片方では'a'と'b'です。構造上はリテラルの違いにすぎません。しかし振る舞いとしては、2行をマージするか、2行を合計するかの違いです。テーブルにすること自体は可能です。ただ、SKUの列がコードパスを選んでいるという事実は隠れてしまいます。どの指摘も、テストを消せるとは主張していません。そのため、ここで判断を誤っても、失うのはカバレッジではありません。出来の悪いリファクタリング案で済みます。
0.4.0は同じ18件に加えて、準重複を1件報告しました。対象は「accepts fractional capacity」と「can take exactly the full capacity」という名前の2つのテストです。両者は別の振る舞いで、0.5.0はこのペアを報告しません。
単純なType-2検出器は、おおむね同じクラスターを見つけました。ただし、すべての識別子をプレースホルダーに置き換えます。そのためcart.total()とcart.discount()が同じに見え、11グループのうち3つで別のメソッドを呼ぶテストが混ざりました。「リテラルが違う」ことと「呼んでいる関数が違う」ことも区別できません。この区別こそ、まとめるべきか触らずにおくべきかの分かれ目です。
誰も指摘しなかった冗長性
手作業で数えると、チェックを失わずに消せるテストは10件で、スイートの約7%でした。予想より少ない数です。件数より、それらがどこから来たのかのほうが興味深い結果でした。
一部は包含関係です。後のテストが同じ手順を、より多くの確認点で実行しています。2回目のセッションは、時計が巻き戻るケースについて、4つのタイムスタンプを順にたどるテストを書きました。これで、1回目のセッションが書いた巻き戻りのテスト2件が冗長になりました。数値と順序が違うため、構造の比較ではつながりません。
一部は意味の無い次元です。「機能間の相互作用」を求められた2回目のセッションは、「100%クーポンと税率1で0になる」のようなテストを書きました。税が掛かる合計は、すでに0です。追加した次元は結果を変えられず、このテストは100%クーポンのケースを確認し直しているだけです。このパターンは3件あり、うち2件が2回目のセッションのものでした。気づくには算術の理解が必要で、テスト本体の静的な比較で主張できる範囲を超えています。
レビューの目安を挙げておきます。追加した変数が、テストで0に固定された値へ掛け算や足し算をするだけなら、そのテストは基本ケースの確認し直しです。
残りの2件は、vitest-dedup自身が見つけました。私がデフォルトで無効にして出荷しているルールによるものです。subsumed-testは、あるテストの振る舞いが別のテストの厳密な部分列になっているときに報告します。有効にすると3つのペアを指摘しました。
そのうち2つは正しく、どちらも私が手作業で見落としていたものでした。「does not change the cart when an add is rejected」は、「adds a new item」と同じ追加と2つのアサーションを行っています。つまり後者は何も加えていません。
3つ目は誤りでした。負の固定額クーポンの検証が、負の割合クーポンの検証で網羅されていると扱ったのです。両者は別の入力クラスです。3回に1回誤ることこそ、このルールをデフォルトで無効にしている理由です。レビューのきっかけには良いのですが、CIのゲートには向きません。
今回の実験では、手作業で見つけた7つの冗長グループのうち5つに、2回目のセッションのテストが含まれていました。「エッジケースを追加して」という依頼は、新しいテストと同じくらい再テストを生みました。同じファイルへエージェントを何度も走らせるなら、まずそこを確認してください。
結局、何件が違うテストだったのか
今回の実験では、エージェントが書いたテストの約93%が、ほかのどのテストも確認していない振る舞いを確認していました。ただ、本当に注目すべき数字は別にあります。テーブルにまとめられる31件と、注意深く読んで初めてレビュアーが消せる10件です。前者はツールがプルリクエストのたびに見つけられます。後者には今も人が必要です。そこでツールにできる最も役に立つことは、どの指摘が証明で、どの指摘が問いなのかをはっきり伝えることです。
知っておくべき制限
vitest-dedupは静的解析ツールです。設定とテストファイルを読むだけで、インポートや実行はしません。だからどのプルリクエストでも安全に実行でき、同時にそれが限界にもなります。0.5.0を出すとき、私は既知の誤検知を3つ残したまま出荷すると決めました。コンストラクタとキャプチャ状態への対応を遅らせたくなかったからです。3つとは、非同期イテレーション、追跡できないか深すぎるキャプチャコード、パラメータ化したオブジェクトキーのエンコードです。誰も苦労して見つけずに済むよう、0.5.0のリリースノートに明記しました。1つ目のfor awaitループはmainで修正済みです。次のバージョンに含まれます。
この実験は小さく、明らかな偏りがあります。エージェントは1つ、モジュールは2つで、ツールと分類はどちらも私が作りました。割合は法則ではなく、このコーパスの記述として読んでください。生の出力とペアごとの判定はリポジトリに置いてあります。個々の判断に異論があれば確かめられます。
新しい重複だけでゲートする
既存のスイートで、いきなり重複ゼロのゲートを有効にするのは難しいものです。ベースラインを使う運用では、まず--update-baselineで今の指摘を一度記録します。以降は--baseline dedup-baseline.jsonを付けて実行し、追加された分だけをゲートにかけます。
試しに、実験のベースラインを記録してから変更を加えました。以前のテストが見えていないエージェントがやりがちな変更です。既存のカートのテストを改名してコピーし、新しいdescribeブロックに置きました。ゲートはその新しい重複1件で失敗しました。記録済みの19件は表示されたまま、判定からは外れています。
jsonlレポーターは、エージェントが読むことを想定して作っています。テストの作成と整理を、同じエージェントに任せられます。CIでゲートを動かす場合は、GitHub Actionを使ってください。同じベースラインを入力として受け取り、実行前にバイナリのチェックサムとビルドの来歴を検証します。
再現手順
npm ci # Node 24 LTS
npx vitest run # 149 tests
npx vitest-dedup . --no-metrics # default rules
npx vitest-dedup . --no-metrics --rule subsumed-test=warning
node scripts/naive-dedup.mjs tests/*.test.ts # naive token detector
npx vitest-dedup demo --no-metrics # renamed local classes, 0.5.0
npx vitest-dedup@0.4.0 demo --no-metrics # same pair, 0.4.0
モジュール、生成されたテスト、プロンプト、単純な検出器、すべての生の出力はvitest-dedup-materialsにあります。リポジトリとツールのREADMEは英語です。vitest-dedupはMITライセンスのRust製ツールです。npx vitest-dedup .なら、インストールせずに実行できます。
エージェントに書かせたスイートで試したら、4つのパターンのうちどれが一番多かったか、ぜひ教えてください。今回の結果からの予想ですが、コピーよりテーブル化の候補のほうが多くなるはずです。


