Claude Code などの AI コーディングエージェントにコード修正を任せると、修正のたびに npm test で全テストスイートを実行しようとする場面によく遭遇します。人間であればテスト出力を流し読みして失敗箇所だけを拾えますが、エージェントは標準出力をすべて読み、コンテキストウィンドウに蓄積した上で次の行動を決めます。1,000 件のテスト結果のうち本当に必要なのが「変更に関係する数件」だけだとすれば、残りはトークンの浪費であり、エージェントの判断材料を薄めるノイズです。
この課題はツール側でも公式に認識されています。Vitest 4.1 では agent レポーターが追加されました。失敗したテストとそのエラーだけを表示し、成功したテストの出力やログを抑制することでトークン消費を削減するモードで、AI コーディングエージェント内での実行を検知すると自動的に有効化されます(Vitest 4.1 リリースブログ)。
ただし、出力を削るだけでは「全テストを実行する時間」は変わりません。本記事では一歩進めて、実行するテスト自体を変更に関係するものだけに絞る方法を、小さなサンプルプロジェクトを作りながら確認していきます。Vitest を主軸に、Jest でも同じことが標準機能だけで実現でき、追加ツールは不要であることを示します。
この記事でわかること
- Vitest の変更検知ベースのテスト実行を、手元で再現しながら確認する手順
- 依存関係の推移的な追跡がどう働くか、どこで効かなくなるか
- AI コーディングエージェント(Claude Code)・pre-commit hook・CircleCI への組み込み方
- Jest で同じことを実現する場合の CLI オプションの対応
検証環境
| 対象 | バージョン |
|---|---|
| Node.js(ローカル) | v22.22.2 |
| Node.js(CircleCI) | cimg/node:22.14 |
| Vitest | 4.1.8 |
サンプルプロジェクトを用意する
依存関係の追跡を観察できる最小構成として、「cart.ts が price.ts に依存し、greet.ts は独立している」という 3 ファイル構成を作ります。
mkdir vitest-demo && cd vitest-demo
npm init -y
npm install -D vitest
mkdir src
ソースファイルを 3 つ作成します。
export function taxIncluded(price: number): number {
return Math.floor(price * 1.1);
}
import { taxIncluded } from './price';
export function cartTotal(prices: number[]): number {
return prices.reduce((sum, p) => sum + taxIncluded(p), 0);
}
export function greet(name: string): string {
return `Hello, ${name}!`;
}
それぞれに対応するテストを作成します。
import { expect, test } from 'vitest';
import { taxIncluded } from './price';
test('taxIncluded adds 10% tax', () => {
expect(taxIncluded(100)).toBe(110);
});
import { expect, test } from 'vitest';
import { cartTotal } from './cart';
test('cartTotal sums tax-included prices', () => {
expect(cartTotal([100, 200])).toBe(330);
});
import { expect, test } from 'vitest';
import { greet } from './greet';
test('greet returns greeting', () => {
expect(greet('CircleCI')).toBe('Hello, CircleCI!');
});
変更検知は git の情報を使うため、リポジトリとして初期化してコミットしておきます。
git init
git add -A && git commit -m "initial"
まず全件実行して、3 つのテストファイルが走るベースラインを確認します。
$ npx vitest --run
RUN v4.1.8 /home/user/vitest-demo
✓ src/price.test.ts (1 test) 4ms
✓ src/cart.test.ts (1 test) 3ms
✓ src/greet.test.ts (1 test) 3ms
Test Files 3 passed (3)
Tests 3 passed (3)
--changed で変更に関係するテストだけを実行する
src/price.ts に何らかの変更を加えてから(コミットはせずに)、--changed を付けて実行します。
$ npx vitest --changed --run
RUN v4.1.8 /home/user/vitest-demo
✓ src/price.test.ts (1 test) 3ms
✓ src/cart.test.ts (1 test) 3ms
Test Files 2 passed (2)
Tests 2 passed (2)
実行されたのは 2 ファイルだけです。注目すべきは cart.test.ts が含まれている点です。cart.test.ts は price.ts を直接インポートしていません。cart.test.ts → cart.ts → price.ts という静的インポートの連鎖を Vitest が辿り、推移的に影響を受けるテストとして選択しています。一方、依存関係のない greet.test.ts はスキップされました。
--changed は値なしの場合、未コミットの変更(ステージング済み・未ステージングの両方)を対象にします。--changed HEAD~1 で直前のコミット、--changed origin/main でブランチ間の差分を基準にすることもできます(Vitest CLI)。なお、差分が何もない状態で実行すると No test files found, exiting with code 0 と表示され、正常終了します。
vitest related: ファイルを明示して関連テストを実行する
git の差分ではなく、特定のソースファイルを起点に関連テストを実行することもできます。
$ npx vitest related src/price.ts --run
✓ src/price.test.ts (1 test) 3ms
✓ src/cart.test.ts (1 test) 3ms
Test Files 2 passed (2)
結果は --changed と同じく推移的な依存を含む 2 ファイルです。--changed と related は同じ依存グラフの仕組みを使うため、選択されるテストは一致します。こちらは後述する lint-staged との組み合わせで使います。Vitest はデフォルトでウォッチモードに入るため、エージェントや hook から呼ぶ場合は --run を付けて 1 回実行で終了させる点に注意してください。
agent レポーターで出力をさらに絞る
実行するテストを絞った上で、出力もエージェント向けに最適化できます。--reporter=agent を付けると、成功したテストの個別出力が抑制されます。全件成功時はサマリーのみになります。
$ npx vitest --changed --run --reporter=agent
RUN v4.1.8 /Users/hidetaka/sandboxes/vitest-run-changed-test
Test Files 2 passed (2)
Tests 2 passed (2)
テストが失敗した場合は、失敗したテストだけが詳細付きで出力されます。次は taxIncluded の税率を 10% から 8% に変更し、price.test.ts(110 を期待)と cart.test.ts(330 を期待)の期待値と食い違わせたときの出力です。
❯ src/price.test.ts (1 test | 1 failed) 3ms
× taxIncluded adds 10% tax 3ms
❯ src/cart.test.ts (1 test | 1 failed) 3ms
× cartTotal sums tax-included prices 3ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/price.test.ts > taxIncluded adds 10% tax
AssertionError: expected 108 to be 110 // Object.is equality
- Expected
+ Received
- 110
+ 108
FAIL src/cart.test.ts > cartTotal sums tax-included prices
AssertionError: expected 324 to be 330 // Object.is equality
- Expected
+ Received
- 330
+ 324
Test Files 2 failed (2)
Tests 2 failed (2)
price.ts を変更したことで、直接依存する price.test.ts と推移的に依存する cart.test.ts の 2 件が選択され、独立した greet.test.ts はスキップされています。エージェントが次の行動を決めるのに必要な情報(どこが・なぜ失敗したか)だけが残る形です。AI コーディングエージェント内での実行を Vitest が検知した場合はこのレポーターが自動で有効になりますが、スクリプトに明示しておけば検知に依存せず確実に適用できます。
package.json の変更は全件実行にフォールバックする
依存パッケージの更新は影響範囲を静的に追跡できないため、全テストを回すべきです。これは設定不要で、Vitest の forceRerunTriggers のデフォルト値 ['**/package.json/**', '**/vitest.config.*/**', '**/vite.config.*/**'] がこの挙動を提供します。これらのパターンに一致するファイルが変更に含まれる場合、--changed は差分の推移的追跡ではなく全テスト実行にフォールバックします。手元で確かめる場合は、package.json に空行を 1 つ加えて npm run test:changed を実行すると 3 ファイルすべてが走ることを確認できます。
テストが JSON や CSV などのデータファイルを読み込んでいる場合、それらは静的インポートではないため追跡できません。forceRerunTriggers にパターンを追加して、全件実行へフォールバックさせます。
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
forceRerunTriggers: [
'**/package.json/**',
'**/vitest.config.*/**',
'**/fixtures/**', // テストが読み込むデータファイル
],
},
})
Jest で同じことを実現する場合
本記事のローカル・CI 検証は Vitest で行いましたが、Jest にも同等の機能が標準で用意されています。CLI オプションの対応は次のとおりです(Jest CLI Options)。
| 目的 | Vitest | Jest |
|---|---|---|
| git 差分に関連するテストだけ実行 | vitest --changed |
jest --onlyChanged |
| 特定ファイル起点で関連テストを実行 | vitest related <file> |
jest --findRelatedTests <file> |
| ブランチ差分を基準に実行 | vitest --changed origin/main |
jest --changedSince=origin/main |
| 対象テストが無くても成功終了 | (既定で exit 0) |
--passWithNoTests |
Jest 固有の注意点として、関連テストが 1 件も見つからない場合の挙動に差があります。--onlyChanged は差分なしの状態で正常終了しますが、--findRelatedTests にテストのないファイルを渡すと終了コード 1 で失敗します。lint-staged やエージェントから機械的に呼び出す用途では --passWithNoTests を付けておきます。また --changedSince は、現在のブランチが指定ブランチから分岐している場合、対象がローカルで加えた変更のみになる点に注意が必要です(詳細は公式 CLI ドキュメントを参照してください)。
組み込みパターン 1: AI コーディングエージェント
エージェントが迷わず最小実行を選べるように、package.json に専用スクリプトを定義します。
{
"scripts": {
"test": "vitest --run",
"test:changed": "vitest --changed --run --reporter=agent"
}
}
その上で、CLAUDE.md(または各エージェントの指示ファイル)に実行ルールを書いておきます。
## テスト実行ルール
- コード変更後の確認は `npm run test:changed` を実行する
- package.json または設定ファイルを変更した場合のみ `npm test` で全件実行する
このルールが実際に効くかを、Claude Code で確認しました。テストコマンド名を含めず「src/price.ts の税率を 10% から 8% に変更して、影響がないか確認してください」とだけ指示したところ、エージェントは price.ts を変更した後、CLAUDE.md の指示に従って npm run test:changed を自ら選択して実行しました。どのコマンドを使うかは指示していません。
このときの test:changed の出力が次です。10%→8% の変更で price.test.ts と cart.test.ts の期待値と食い違うため 2 件とも失敗し、独立した greet.test.ts はスキップされています。エージェントは「影響がある」ことを、変更に関係する 2 ファイルだけを走らせて確認できています。
> vitest --changed --run --reporter=agent
RUN v4.1.8 /Users/hidetaka/sandboxes/vitest-run-changed-test
❯ src/price.test.ts (1 test | 1 failed) 3ms
× taxIncluded adds 10% tax 3ms
❯ src/cart.test.ts (1 test | 1 failed) 3ms
× cartTotal sums tax-included prices 3ms
Test Files 2 failed (2)
Tests 2 failed (2)
期待値を 8% 側に修正すれば、同じ選択のまま 2 件が成功し、サマリーのみが出力されます。
> vitest --changed --run --reporter=agent
RUN v4.1.8 /Users/hidetaka/sandboxes/vitest-run-changed-test
Test Files 2 passed (2)
Tests 2 passed (2)
CLAUDE.md に 1 行のルールを書くだけで、エージェントがコード変更後に全件実行ではなく変更関連実行を選ぶことが確認できました。Jest を使う場合は test:changed を jest --onlyChanged --passWithNoTests に置き換えます。
組み込みパターン 2: pre-commit hook
lint-staged は対象ファイルのパスをコマンド末尾に渡すため、related / --findRelatedTests にそのまま引数として届きます。ステージングされたファイルに関連するテストだけがコミット前に実行され、全テストスイートの完了を待たずにコミットできます。
export default {
'*.{js,ts}': 'vitest related --run',
}
Jest を使う場合は jest --findRelatedTests --passWithNoTests を指定します。
組み込みパターン 3: CircleCI でのプルリクエスト時の選択的実行
プルリクエストのワークフローでは、main ブランチとの差分に関連するテストだけを実行します。以下は実際に動作を確認した設定です。
version: 2.1
jobs:
test-changed:
docker:
- image: cimg/node:22.14
steps:
- checkout
- run:
name: Install dependencies
command: npm ci
- run:
name: Fetch base branch
command: git fetch origin main
- run:
name: Run tests related to changes
command: npx vitest --changed origin/main --run --reporter=agent
test-all:
docker:
- image: cimg/node:22.14
steps:
- checkout
- run:
name: Install dependencies
command: npm ci
- run:
name: Run all tests
command: npm test
workflows:
pr-checks:
jobs:
- test-changed:
filters:
branches:
ignore: main
main-full:
jobs:
- test-all:
filters:
branches:
only: main
ポイントは 2 つあります。
-
git fetch origin mainを明示的に実行する:--changed origin/mainが差分を計算するには、ジョブ環境内に比較先の ref が必要です -
main ブランチでは全件実行する: main への push では
origin/mainとの差分がほぼ無く、--changedの対象が空になりえます。そのため main ではtest-all(npm test)で全件を回し、セーフティネットにします
実際の挙動
この設定を実リポジトリで動かし、次の 2 パターンを確認しました。greet.ts とそのテストだけを変更した feature ブランチと、それを main にマージした後の実行です。
| ブランチ | ワークフロー | ジョブ | 実行されたテストファイル |
|---|---|---|---|
| feature ブランチ | pr-checks | test-changed |
greet.test.ts の 1 件のみ |
| main(マージ後) | main-full | test-all | 3 件すべて |
PR 側では変更した greet.ts に関連する greet.test.ts の 1 件だけが実行され、price.test.ts / cart.test.ts はスキップされました。main 側では 3 件すべてが実行されています。選択実行が意図どおり効いていることは、この「1 件 vs 3 件」というファイル数の差で確認できます。
なお、本記事のサンプルはテストが数 ms 規模のため、CI の所要時間差は npm ci などのセットアップが支配的で、選択実行そのものの短縮効果は現れません。時間短縮が意味を持つのは、実行に時間のかかるテストを多数抱えた実プロジェクトです。本サンプルで示せるのは「変更に関係するファイルだけを正しく選択する」という選択の正確さです。
変更検知が効かないケース
静的解析ベースである以上、追跡できない依存があります。
// 追跡可能: 静的インポート
import { taxIncluded } from './price'
// 追跡不可能: 動的インポート
const module = await import(getModulePath())
// 追跡不可能: ファイルシステム経由の読み込み
const config = fs.readFileSync('./config.json')
これらに依存するテストは変更検知から漏れます。対策は 2 段構えです。
- Vitest なら
forceRerunTriggersに該当ファイルのパターンを追加して全件実行へフォールバックさせる - CI で main ブランチ・定期実行の全テストをセーフティネットとして維持する
モノレポで「アプリ A の変更時はアプリ A のテストだけ」といったプロジェクト単位の制御が必要になったら、Nx や Turborepo の affected 検出が次の選択肢になります。ただし単一パッケージのリポジトリであれば、本記事で確認した標準機能で十分です。
まとめ
AI コーディングエージェントにとって、テスト出力はコンテキストウィンドウという有限のリソースを消費する入力です。Vitest が agent レポーターを標準搭載したように、「エージェントに渡す情報を絞る」ことはツールエコシステム側でも前提になりつつあります。
本記事のサンプルプロジェクトは 10 分程度で再現できます。手元で推移的な依存追跡の挙動を確認したら、package.json にスクリプトを 1 行足し、CLAUDE.md に実行ルールを 1 行書くところから始めてください。Claude Code はその 1 行のルールに従い、変更後に変更関連実行を自律的に選びました。ローカルのエージェント、pre-commit hook、CircleCI の PR ワークフローと段階的に広げつつ、main ブランチと定期実行の全テストをセーフティネットとして残す。この構成が、選択の正確さと検出力を両立する現実的な落としどころです。

