AI製コードのテストが実行のたびに変わる:不安定さの原因を切り分ける
AIを使いこなすClaudeで執筆し、GPTで自動校閲・修正しています。

同じコードなのに、テストが通ったり落ちたりする。AIに書かせたコードで起きると、どこから疑えばよいか迷います。結論から言うと、原因の多くは「テストの外にある値が毎回変わっている」ことです。疑う順番は、時刻、乱数、実行順、外部通信です。この4つを一つずつ固定し、固定した途端に結果が安定したら、そこが原因です。 この記事では、まず失敗を再現する方法を示します。次に原因別の固定方法を表にまとめ、AIに調査と修正を頼む依頼文を載せます。コード例はNode.js標準のテストランナー(node:test)を想定した「説明用の例」です。この記事のために実行した結果ではないので、手元の環境で確かめてください。
最初にやること:落ちる条件を記録する
原因を探す前に、「何回に1回落ちるか」と「落ちたときの出力」を残します。AIに「直して」とだけ頼むと、落ちない回を見て「問題なし」と答えがちです。
# 説明用の例:最大20回、落ちたら止める(bash)
for i in $(seq 1 20); do
node --test > "run_$i.log" 2>&1 || { echo "失敗: $i回目"; break; }
done回数は目安です。見るのは次の3点です。
- 落ちるテスト名は毎回同じか、日によって変わるか
- 単体で実行すると落ちないのに、全体では落ちるか
- 落ちる時間帯や日付に偏りがあるか(深夜0時前後、月末など)
テスト名が毎回同じで単体でも落ちるなら、時刻・乱数・外部通信が疑わしくなります。全体でだけ落ちるなら実行順や共有状態です。これは最初の目安で、確定は次の「一つずつ固定する」で行います。
原因別の固定のしかた表
- 時刻、疑うサイン:日付や時間帯で結果が変わる。期限、経過時間、「今日」を使う処理、固定のしかた:現在時刻をテストで決めた値に置き換える。Node.jsならmock.timersでDateを模擬する、固定できたかの確認:時刻を固定して20回通る。固定する日時を変えると期待値も変わる
- 乱数、疑うサイン:落ちる回が不規則。シャッフル、抽選、ID生成を含む、固定のしかた:乱数を返す関数を引数で受け取り、テストでは決まった値を渡す、固定できたかの確認:固定値で毎回同じ結果になる
- 実行順・共有状態、疑うサイン:単体では通り、全体だと落ちる。前のテストの影響を受ける、固定のしかた:順序をランダム化して再現し、同じ順序を再生する。共有変数やファイルをテストごとに初期化する、固定できたかの確認:同じ順序の再生で必ず落ち、初期化後は通る
- 外部通信、疑うサイン:ネットワーク状況、相手サービスの応答で変わる。タイムアウトが出る、固定のしかた:通信する関数を引数で受け取り、テストでは偽の応答を返す、固定できたかの確認:ネットワークを切っても結果が変わらない
時刻を固定する
Node.jsのテストランナーのドキュメントでは、mock.timers.enable()のapisに'Date'を指定すると、Date.now()とnew Date()が模擬された時刻を返します。nowで開始時刻を指定でき、省略するとUnixエポックの0から始まる、と説明されています。tick()で時間を進められます。AIが書いたコードで「期限切れ判定」が日付で揺れているなら、次の形にします。
// 説明用の例(未実行)
import test from 'node:test';
import assert from 'node:assert/strict';
import { isExpired } from './coupon.js';
test('期限を過ぎたら無効になる', (t) => {
const start = Date.parse('2026-01-01T00:00:00Z');
const coupon = { expiresAt: start + 1000 };
t.mock.timers.enable({ apis: ['Date'], now: start });
assert.equal(isExpired(coupon), false);
t.mock.timers.tick(1000);
assert.equal(isExpired(coupon), true);
});nowを必ず指定してください。省略すると1970年になり、別の理由で落ちることがあります。固定後に安定したなら、元のテストは「実行時の現在時刻」に依存していたことになります。日付の表示形式を比べるテストでは、実行環境のタイムゾーン設定も揺れの候補です。
乱数を固定する
JavaScriptのMath.random()は、シード(乱数の種)を利用者が指定できません。MDNの説明でも、初期シードは実装が選び、利用者は選択も再設定もできないとされています。テストで同じ値を得たいなら、乱数を返す関数を外から渡せる形にします。
// 説明用の例(未実行)
export function pickWinner(users, random = Math.random) {
return users[Math.floor(random() * users.length)];
}
test('乱数0.5なら真ん中の人が選ばれる', () => {
assert.equal(pickWinner(['a', 'b', 'c'], () => 0.5), 'b');
});引数の既定値がMath.randomなので、本番の呼び出し側は変えずに済みます。「ときどき落ちる」テストが「どの値でも成り立つ性質」を調べているなら、固定値を複数用意します。例は0、0.999、0.5の3つです。端の値で配列の範囲外を指していないかも確かめられます。
実行順を固定して再現する
全体でだけ落ちる場合は、前のテストが残した状態が原因かもしれません。Node.jsのテストランナーには--test-randomizeと--test-random-seedがあります。ドキュメントによると、シードを指定すれば同じ順序を再生でき、実行時にはシードが診断メッセージとして表示されます。
# 説明用の例:順序を変えて実行し、落ちたら表示されたシードで再生する
node --test --test-randomize
node --test --test-random-seed=12345注意点があります。Node.jsの現行ドキュメントでは、このオプションはv26.1.0で追加され、安定度は「早期開発」とされています。サブテストをawaitで順番に実行している書き方では、順序は入れ替わりません。また、--watchや--test-rerun-failuresとの併用もできません。使う前にnode -vで版を確認し、その版のドキュメントで確かめてください。
落ちる順序を再現できたら、テスト間で共有している変数、ファイル、データベースの内容を探します。対策は各テストの前に初期化することです。AIが書いたコードでは、モジュール直下の配列やキャッシュが典型的な共有状態です。
外部通信を切り離す
APIを呼ぶ処理は、相手の応答速度や状態で結果が変わります。時刻や乱数と同じく、通信する関数を引数で受け取る形にします。
// 説明用の例(未実行)
export async function getRate(fetchFn = fetch) {
const res = await fetchFn('https://example.test/rate');
if (!res.ok) throw new Error('取得失敗');
return (await res.json()).rate;
}
test('レートを返す', async () => {
const fake = async () => ({ ok: true, json: async () => ({ rate: 150 }) });
assert.equal(await getRate(fake), 150);
});失敗時の振る舞いも偽の応答で試せます。ok: falseを返す偽物で例外になるかを見ます。実際のAPIとつながるかは、別の「結合テスト」として分けます。
AIに直させる依頼文の例
調べる段階と直す段階を分けると、AIが「リトライで隠す」「待ち時間を延ばす」といった対症療法に走りにくくなります。
調査の依頼(まだ直さない)
次のテストが成功したり失敗したりします。まだコードは直さず、原因を調べてください。
- 実行環境: Node.js(node -v の結果: ○○)
- 20回実行して○回失敗。失敗時の出力は下に貼ります
- 対象のテストと、テストされる関数のコードも貼ります
お願い:
1. 時刻・乱数・実行順・外部通信のうち、このコードが依存しているものを根拠の行つきで列挙する
2. それぞれ「固定すると結果が安定するか」を確かめる手順を1つずつ示す
3. 推測と確認できたことを分けて書く修正の依頼(原因が分かってから)
原因は「isExpired が現在時刻を直接読んでいること」と分かりました。
- 本番の挙動は変えない
- テストでは mock.timers で Date を固定する(開始時刻は明示)
- sleep の延長、リトライ、テストのスキップで解決しない
- 変更点を差分で示し、期待値をどう決めたかを説明する貼る情報は、失敗ログ、対象コード、Nodeの版までです。APIキー、顧客データ、社内URLは含めず、ダミー値に置き換えます。
人が確認すること
AIの修正案を受け取ったら、次の点を見ます。
- 期待値の出どころ:固定した値をそのまま期待値にコピーしただけになっていないか。時刻を変えたら期待値が変わるテストが、仕様に合っているか
- 本番コードの変更範囲:引数の既定値が元の動作と同じか(Math.randomやfetchのまま)
- 隠していないか:skipの追加、リトライ、待ち時間の延長が入っていないか
- 再確認:修正後に最初の繰り返し実行をやり直し、全部通るか。順序のシードも変えて試す
これでも揺れが残る場合は、4つ以外に、setTimeoutの待ち時間に頼った非同期処理や、並列実行されるテスト同士が同じファイルやポートを使っている可能性があります。並列数はNode.jsの--test-concurrencyで制限して試せます。同じ手順で、疑う項目を一つ固定しては結果を見る、を繰り返してください。

