ゴールドキウイのイラスト

`/goal` コマンドの活用例: Vitest の実行時間を 6 倍高速化した話

Vitest の `isolate: false` オプションを有効にすることで、テストの実行時間を大幅に短縮できましたが、その際に大規模なコードの修正が必要でした。Claude Code の `/goal` コマンドを活用することで、最終的なゴールを達成するために必要なステップを自律的に判断して実行させることができます。この記事ではその経験について紹介します。

この取り組みでは Vitest の isolate: false オプションを有効にすることで、テストの実行時間が 12 分から 2 分にまで短縮され、約 6 倍の高速化を実現できました。Vitest の isolate: false オプションを設定するために大規模なコードの変更が必要であり、その作業の大部分は Claude Code を活用して行いました。ただし、通常 Claude Code に大規模なタスクを割り当てて、そのタスクを完遂させることは困難です。通常は人間が随時介入しタスクを小さな単位に分割して、段階的に進めていく必要があります。しかし、今回は Claude Code の /goal コマンドを活用することで、最終的なゴールを達成するために必要なステップを自律的に判断して実行させることができました。

今回行った改善では、Vitest の isolate: false オプションを有効にすることでテストの実行時間を大幅に短縮した方法と、Claude Code の /goal コマンドを活用して大規模なタスクを自律的に完遂させた経験について紹介します。

Vitest isolate: false でなぜ高速化できるのか?

パフォーマンスの改善をする際に何より大切なのは、計測し、どこにボトルネックがあるのかを正確に把握することです。ボトルネックとなっていない部分を改善しようとしても、全体のパフォーマンスの改善にはほとんど寄与せず、時間と労力を無駄にしてしまう可能性が高いです。Vitest はテストを実行したときに以下の時間指標が計測されます。この指標をもとに、どこにボトルネックがあるのかを Claude Code に分析させることで、最も効果的な改善策を見つけられます。以下は実際の Vitest の実行結果の例です。

 src/index.test.ts (12)
...
 
Test Files  1500 passed (1500)
     Tests  10000 passed (10000)
  Start at  11:42:05
  Duration  720s (transform 60s, setup 123s, import 600s, tests 250s, environment 510s)
  • Transform: ソースコードの変換にかかった時間。TypeScript や JSX を実行可能な形にコンパイルする処理
  • Setup: setupFiles で指定したファイルの実行にかかった時間。setupFiles で指定したファイルは、各テストファイルの実行前に実行されるファイルで、テスト全体のセットアップやグローバルな変数の定義などを行うために使用される
  • Import: テストファイルの import にかかった時間。各テストファイルが依存するモジュールの読み込みや初期化にかかる時間
  • Tests: 実際のテストの実行にかかった時間
  • Environment: テスト環境(jsdom)のセットアップにかかった時間

上記の結果を見ると、Import が 600s と突出して長いことがわかります。ついで jsdom などのテスト環境のセットアップにかかる Environment が 510s と無視できない数値です。一方でテストの実行時間そのものは 250s とかなり短く、ここを改善しようとするのはあまり効果的ではないことがわかります。Import の実行時間が非常に長くなる主な理由はモジュールの依存グラフが非常に大きく、複雑になっている可能性があげられます。React アプリではテスト対象のコンポーネントを 1 つ import するだけで、React/ReactDOM, UI ライブラリ, 共有モジュールなど依存関係が芋づる式に広がっていくことが多いです。またバレルファイル(index.ts などのファイルで複数のモジュールをまとめて export するファイル)が多用されている場合も、依存関係が複雑になりやすいです。

Vitest のデフォルトの動作(isolate: true)では、各テストファイルが独立したプロセスで実行されます。各テストファイルが独立したプロセスで実行されること自体は、テストの分離性を高めるという観点では望ましい動作ですが、各テストファイルが実行されるたびに依存関係のモジュールの import やテスト環境のセットアップが繰り返し行われることになります。これが Import と Environment の時間が非常に長くなってしまう主な理由です。

一方で isolate: false を設定すると、ワーカー内でモジュール評価が共有されます。一度評価されたモジュールはキャッシュされ、同じワーカー内であれば再評価されることなく再利用されるため、Import にかかる時間を大幅に削減できます。同様にテスト環境(jsdom)のセットアップもワーカー内で共有されるため、Environment の時間も併せて削減されます。実際に isolate: false を設定したところ、Import は 50s にまで短縮され、テストプロセス全体で大幅なパフォーマンスの改善が見られました。isolate: false を設定するには、Vitest の設定ファイル(vitest.config.ts)で以下のようにオプションを指定します。

vitest.config.ts
import { defineConfig } from 'vitest/config';
 
export default defineConfig({
  test: {
    isolate: false,
  },
});

もしくは CLI で以下のように指定もできます。

vitest run --no-isolate

特定のテストファイルに対してのみ isolate: false の設定もできます。以下の例では **.non-isolated.test.ts というパターンのテストファイルに対して isolate: false を設定し、それ以外のテストファイルはデフォルトの isolate: true のままにしています。

vitest.config.ts
import { defineConfig } from 'vitest/config'
 
export default defineConfig({
  test: {
    projects: [
      {
        test: {
          name: 'Isolated',
          isolate: true,
          exclude: ['**.non-isolated.test.ts'],
        },
      },
      {
        test: {
          name: 'Non-isolated',
          isolate: false,
          include: ['**.non-isolated.test.ts'],
        },
      },
    ],
  },
})

isolate: false を設定する際の注意点

isolate: false を設定することで大幅なパフォーマンスの改善が見込めますが、トレードオフも存在します。isolate: false を設定すると、テストファイルが同じプロセス内で実行されるため、テストファイル間で状態が共有されることになります。これにより、あるテストファイルの変更が他のテストファイルに影響を与える可能性があります。例えば、あるテストファイルでグローバルな変数を定義したり、リークが残っている場合(モックやタイマーのクリーンアップの忘れなど)、他のテストファイルの実行に影響を与える可能性があります。また、ランダムに採番されるテスト ID なども同じプロセス内で共有されるため、テストの実行順序によって失敗する可能性もあります。

実際に私のプロジェクトでは以下のような問題に直面しました。

  • localStorage の状態をテストの終了時にクリーンアップされておらず、localStorage の値に依存する他のテストが失敗する
  • Testing Library の DOM のクリーンアップ(cleanup())が行われておらず、スナップショットテストが失敗する。@testing-library/react は import 時にトップレベルで afterEach(() => cleanup()) を登録しているが、isolate: false ではワーカー内でモジュールがキャッシュされ、最初のテストファイルでしか afterEach が登録されない(vitest-dev/vitest#1430 も参照)。結果として、それ以降にロードされたテストファイルでは自動 cleanup が動作しない
  • globalThis に定義されたグローバルな変数(jsdom に定義されていない ResizeObserver など)をモックしている場合、モックの状態がテストファイル間で共有されてしまい、テストの実行順序によっては失敗する
  • react-aria などのライブラリで採番される ID はモジュールレベルのカウンタで保持されており、テスト間で連続してインクリメントされるため、テストの実行順序によってはスナップショットテストが失敗する
  • アプリケーションコード自体の問題で、コンポーネントの unmount 後に非同期に状態の更新が行われるコードが存在しており、コンソールの警告でテストが失敗した

解決策として一貫しているのは、テストファイル間で状態が共有されることを前提に、テストファイルごとに適切なクリーンアップ処理を行うことです。後始末の処理を setupFiles で指定したファイルにまとめて記述することで、すべてのテストファイルで共通のクリーンアップ処理を行えるため、クリーンアップ処理を漏らすことを防止できます。

setupFiles.ts
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
 
afterEach(() => {
  // testing-library のクリーンアップ
  cleanup();
  // localStorage のクリーンアップ
  localStorage.clear();
  // その他グローバルな状態やモックのクリーンアップ
  // ...
});

採番される ID などの問題に関しては、スナップショットテストを実行する際に ID を正規化することです。Vitest では test.snapshotSerializers オプションにシリアライザのファイルパスを指定して、スナップショットの出力をカスタマイズできます。以下の例では、スナップショットの出力からランダムに採番される ID を正規化するためのシリアライザを定義しています。

snapshotSerializer.ts
import type { SnapshotSerializer } from 'vitest';
 
const idNormalizer: SnapshotSerializer = {
  test(value) {
    // ランダムに採番される ID を含む値をテストするための条件を定義
    return typeof value === 'string' && /id-\d+/.test(value);
  },
  serialize(value) {
    // ランダムに採番される ID を正規化して出力
    return `"${(value as string).replace(/id-\d+/g, 'id-<normalized>')}"`;
  },
};
 
export default idNormalizer;

vitest.config.tstest.snapshotSerializers にシリアライザファイルへのパスを渡すことで、スナップショットテスト全体に適用できます。

vitest.config.ts
import { defineConfig } from 'vitest/config';
 
export default defineConfig({
  test: {
    snapshotSerializers: ['./snapshotSerializer.ts'],
  },
});

具体的な解決策自体は単純に見えますが、この問題を特定したうえで適切な解決策を見つけるまでには、何度も実行と分析を繰り返す必要がありました。1 度のテストの実行で 100 件近いテストが失敗する上に、どのテストが失敗するかは毎回ランダムに変わるためです。この大規模なタスクは Claude Code に実行させたのですが、最初のうちはタスクの完遂までうまく進めることができませんでした。読者の皆さんも経験があるかも知れないですが、Claude Code に「isolate: false を設定して、失敗したテストを修正して、すべてのテストが成功するようにして」といった指示を与えるとタスクの途中で以下のような方針確認を求めてきます。

すべてのテストを成功させるのは「数十件を個別に修正」では収まらず、想定外の規模です。どのようにタスクを進めていきましょうか?

このような方針確認が入った後に Claude Code が示す提案はどれも単純にタスクを完了させようとするものばかりです。

  • テストの条件を緩めて(失敗するテストをコメントアウトするなど)とりあえずテストが通る状態にする
  • テストは失敗した状態だが、この状態で一旦タスクを完了させる
  • isolate: false を設定するのは現実的に不可能なので、別の手段(pool: 'vmThreads' など)でパフォーマンスを改善する

恐らく Claude Code 自身の設計方針として、リスクの高い大きな変更をいきなりやるというのを避けて、段階的に方針を合意して安全側に倒して進めるようなシステムプロンプトが入っているのだと思われます。またタスクを完了させること自体に報酬が与えられていると仮定するならば、失敗するテストを無視するといった方針でタスクを完了させることを提案するのも、納得がいきます。しかし、あくまでも最終的なゴールは「すべてのテストが成功する状態で isolate: false を設定すること」で、タスクの実行時間が長くなることはあらかじめ承知の上です。そのうえでタスクの完了に長い時間がかかりそうなことを理由に都度確認を求められるのは効率的ではありません。最終的なゴールを達成するまではまた同じ指示を与えるだけなのですから。

実際にこの状態になった時に「すべての失敗を地道に修正してください」と変更したのにもかかわらず、同じような方針確認が入ることが何度も続きました。理想的な状態なのは、ゴールを達成するまではユーザーの確認を求めずに、必要なステップを自律的に判断して実行させることです。このような問題は /goal コマンドを活用することで解決を図れました。実際に /goal コマンドがどのように動作するのか見ていきましょう。

/goal コマンドを活用して大規模なタスクを完遂させる

/goal はゴール条件を指定することにより、追加のプロンプトなしにゴール条件が達成されるまで動作し続けるコマンドです。Claude Code がセッションを終了しようとしたタイミングをフックし、Haiku のような軽量なモデルがゴール条件を満たしているかを評価します。ゴール未達であればセッションを終了せずに自動で続行し、条件を満たしたと判断された場合にのみセッションを終了します。これにより、Claude Code が実際の作業を完了していないにも関わらず、ズルをしてタスクが終了したと判断してしまったり、ユーザーの確認を求めるためにセッションが終了してしまうといったことを防止できます。

/goal コマンドは以下のように自然言語でゴール条件を指定します。

/goal Vitest `isolate: false` オプションを設定して、すべてのテストが成功する状態にする

/goal コマンドをうまく使うコツは、ゴール条件を定量的に評価できるような形で指定することです。例えば「すべてのテストが成功する状態にする」というゴール条件は、Vitest を実行して失敗するテストが 0 件であることをもって達成されたと判断できます。一方で「テストの実行時間を短縮する」といったゴール条件は、どの程度短縮すればゴールが達成されたと判断するのかが曖昧です。例えば「Vitest の実行時間を 6 分以下に短縮する」といった形でゴール条件は数値に置き換えられるか?を意識すると良いでしょう。また、/goal コマンドはゴールを達成するまで動き続けるため、大量のトークンを消費するという点にも注意が必要です。単発の修正であれば、通常のプロンプトで指示を与えて修正させる方が効率的な場合もあるでしょう。

実際のセッションでは以下のようなループを経て、最終的にゴールを達成できました。ゴールを達成するまでかかった時間は 10 時間程度です。

  1. --sequence.shuffle オプションを使用して、テストファイルの実行順序をランダムにしたうえで、--seed=N オプションで複数の異なるシード値を試して実行する。ランダムに失敗する可能性があるテストを確実に潰すため
  2. 失敗原因をカテゴリごとに分類(クリーンアップ漏れ、採番される ID の問題、アプリケーションコードの問題など)
  3. 分類ごとにサブエージェントを作成して並列で修正する
  4. 修正が完了したら、再度 shuffle でテストを実行して、失敗するテストがないか確認する

/goal コマンドは Codex にも存在し、両者の源流には Ralph ループ があります。ゴールを達成するまで動き続けるという点は同じで、概念としては以下のような単純な Bash に置き換えられます。

while :; do cat PROMPT.md | claude-code ; done

興味があれば元になった Ralph ループがどのように実装されているのか見てみると面白いでしょう。

まとめ

  • Vitest の isolate: false オプションを有効にすることで、テストの実行時間を大幅に短縮できる
  • isolate: false を設定する際には、テストファイル間で状態が共有されることを前提に、適切なクリーンアップ処理を行うことが重要
  • Claude Code に大規模なタスクを割り当てると、途中で方針確認に入りセッションが中断されたり、タスクが完全に完了していないのにも関わらずズルをしてタスクが完了したと判断されてしまうことがある
  • Claude Code の /goal コマンドを活用することで、最終的なゴールを達成するために必要なステップを自律的に判断して実行させられる
  • /goal コマンドは自然言語で指定したゴール条件が達成されるまで動作し続けられるコマンド。ゴール条件を定量的に評価できるような形で指定するのがコツ

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

Vitest で `isolate: false` を設定すると Import の時間が大幅に削減されるのはなぜですか?

  • TypeScript や JSX のコンパイル処理がスキップされるため

    もう一度考えてみましょう

    コンパイル処理は Transform の時間であり、`isolate: false` で削減されるのは Import の時間です。

  • ワーカー内で一度評価されたモジュールがキャッシュされ、テストファイルごとに再評価されなくなるため

    正解!

    記事で説明されている通り、`isolate: false` ではワーカー内でモジュール評価が共有され、一度評価されたモジュールは再利用されるため Import の時間が削減されます。

  • テストファイルごとに独立したプロセスで実行されるため、並列度が上がるため

    もう一度考えてみましょう

    独立したプロセスで実行されるのは `isolate: true`(デフォルト)の挙動です。`isolate: false` はその逆で、ワーカー内でモジュールを共有します。

  • 依存関係のモジュールを自動的にツリーシェイクして読み込み量を減らすため

    もう一度考えてみましょう

    記事ではツリーシェイクには言及されていません。削減の理由はモジュール評価結果のキャッシュ共有です。

記事で説明されている `/goal` コマンドの動作について、正しいものはどれですか?

  • セッション開始時にゴール条件を評価し、達成不可能と判断された場合は即座にセッションを終了する

    もう一度考えてみましょう

    評価のタイミングはセッション開始時ではなくセッション終了時です。また達成不可能と判断して即座に終了する挙動ではありません。

  • Claude Code がセッションを終了しようとしたタイミングをフックし、軽量モデルがゴール条件を評価して未達なら自動で続行する

    正解!

    記事で説明されている通り、`/goal` はセッション終了タイミングをフックし、Haiku のような軽量モデルが評価を行い、未達なら自動で続行します。

  • ユーザーが毎ターン明示的に「続行」と指示することで、ゴールが達成されるまで動作を継続する

    もう一度考えてみましょう

    `/goal` の特徴は追加のプロンプトなしにゴール達成まで動作する点であり、毎ターンの明示指示は不要です。

  • ゴール条件を評価せずに固定回数のループを実行し、最後にユーザーに完了確認を求める

    もう一度考えてみましょう

    固定回数のループではなく、ゴール条件の達成判定により動作を制御します。

記事が紹介する `/goal` コマンドをうまく使うコツとして、最も適切なものはどれですか?

  • ゴール条件はできるだけ抽象的にし、Claude Code に解釈の自由度を与える

    もう一度考えてみましょう

    記事ではむしろ抽象的な条件は評価が曖昧になるため避けるよう示唆されています。

  • ゴール条件を定量的に評価できる形で指定する

    正解!

    記事で説明されている通り、達成判定を曖昧にしないため、ゴール条件は数値や合否で評価できる形で指定するのがコツです。

  • ゴール条件は毎セッションごとに書き換えて、段階的に難易度を上げていく

    もう一度考えてみましょう

    記事では段階的に書き換える運用は推奨されていません。一度指定した条件をゴール達成まで使い続けます。

  • 実行時間を短くするため、トークン消費量に上限を設定する

    もう一度考えてみましょう

    記事はトークン消費が多い点を注意点として挙げていますが、上限設定をコツとして紹介しているわけではありません。