観測された事象と影響範囲
「またか」その疲労感、開発者なら誰もが経験するものです。
CI/CDパイプラインで発生するFlaky Testは、開発者の貴重な時間を奪うだけでなく、システムの健全性に対する疑念を増幅させます。
一度失敗し、再実行すれば成功するテストは、一見無害に見えますが、それは潜在的な時限爆弾です。
真のバグが紛れ込んでも見過ごされ、デプロイの判断は遅延し、チーム全体の信頼性と生産性が蝕まれていきます。
この不安定さは、やがて本番環境の予測不能な障害へと繋がる可能性を秘めているのです。
対象のイシュー詳細
不安定な単体テスト: McpStdioStateHandlerのsigtermが猶予期間後に発生
Original: Flaky unit test: McpStdioStateHandler sigterm after grace
CIで不安定に失敗し、再実行で成功する単体テストの信頼性問題
観測されたエラー構造
// 予測されるFlaky Testの再現モデル
// プロセス終了時の非同期リソース解放とテストの競合
let resourceInitialized = false;
let resourceReleased = false;
const initializeResource = async () => {
return new Promise(resolve => {
setTimeout(() => {
resourceInitialized = true;
console.log("Resource initialized.");
resolve();
}, 100); // 非同期初期化
});
};
const releaseResource = async () => {
return new Promise(resolve => {
setTimeout(() => {
resourceReleased = true;
console.log("Resource released.");
resolve();
}, 150); // 非同期解放
});
};
// テスト対象の関数
const doWork = async () => {
if (!resourceInitialized) {
throw new Error("Resource not initialized!");
}
// ... 何らかの処理 ...
return true;
};
// プロセス終了時のSIGTERMハンドラ
process.on('SIGTERM', async () => {
console.log('SIGTERM received, attempting to gracefully shut down...');
if (!resourceReleased) {
// ここでリソース解放が間に合わない場合、テストは失敗する可能性
await releaseResource(); // 非同期でリソース解放
}
process.exit(0);
});
// シミュレーション実行 (テストランナーがこれを実行する場合)
(async () => {
await initializeResource();
try {
const result = await doWork();
console.log("Work done:", result);
// テストの終了前にSIGTERMが来ると、テストフレームワークのクリーンアップと競合
// 予測されるクラッシュログ構造の例:
// Error: Resource not initialized!
// または、テストランナーがクリーンアップエラーで終了
// Test suite failed to run
// A worker process has exited unexpectedly. This is likely due to a test failing to clean up its environment.
} catch (e) {
console.error("Error during work:", e.message);
}
})();
原因の技術的深掘り
このFlaky Testの根源は、多くの場合、非決定的な環境依存性とプロセスライフサイクル管理のズレにあります。
特に「sigterm after grace」という記述は、CI環境下でのプロセス終了シグナル(SIGTERM)受信後の挙動に問題があることを強く示唆しています。
アプリケーションやテストランナーがSIGTERMを受け取った際、リソースのクリーンアップや非同期処理の完了を待つ「graceful shutdown」のメカニズムが、テストフレームワークの `afterAll` や `beforeEach` などと競合している可能性が高いのです。
例えば、テストが外部APIコールやDB接続といった非同期リソースを扱っている場合、その初期化や解放処理がプロセスの強制終了シグナルよりも遅れると、テスト環境が不完全に残されたり、テスト自体が意図せず失敗したりします。
これにより、特定のタイミングでしか発生しない競合状態(Race Condition)が生じ、テスト結果が非決定的なものとなるのです。
技術検証と解決策(ワークアラウンド)
Flaky Testは、単なるCI/CDのノイズではありません。
それは、あなたのシステムの基盤に潜む非決定的な脆弱性、そして将来的な本番環境での予測不能な障害への警告です。
この問題への理想的なアプローチは、テスト環境の分離、非同期処理の厳密な管理、そしてプロセスライフサイクルの明確な定義です。
そして、こうした開発プロセスの信頼性を追求することは、Webサービス公開やサーバー運用の基盤を構築する上で最も重要な要素となります。
真に安定稼働するサービスを実現するためには、堅牢なCI/CDパイプラインだけでなく、その下支えとなるセキュアでスケーラブルなインフラ基盤が不可欠です。
不確実性を排除し、予測可能な環境を手に入れることが、あなたのサービスを成功に導く唯一の道筋となるでしょう。
# Flaky Testの再現とデバッグのために、テストランナーのオプションを調整し詳細ログを出力するコマンド例
$ DEBUG_MODE=true npm test -- --runInBand --forceExit
$ # テスト環境でSIGTERMをシミュレートし、挙動を確認する(開発環境でのみ実行)
$ kill -SIGTERM $(pgrep -f "node your_test_runner.js")
// 予測されるワークアラウンドコード:プロセス終了ハンドリングとテストリソースの厳密な管理
let resourceInitialized = false;
let resourceReleased = false;
const initializeResource = async () => {
return new Promise(resolve => {
setTimeout(() => {
resourceInitialized = true;
console.log("Resource initialized.");
resolve();
}, 100);
});
};
const releaseResource = async () => {
return new Promise(resolve => {
setTimeout(() => {
resourceReleased = true;
console.log("Resource released.");
resolve();
}, 150);
});
};
const doWork = async () => {
if (!resourceInitialized) {
throw new Error("Resource not initialized!");
}
return true;
};
// グレースフルシャットダウンを確実に実行する関数
const gracefulShutdown = async () => {
console.log('Initiating graceful shutdown...');
if (!resourceReleased) {
await releaseResource();
}
console.log('Resources released, exiting.');
process.exit(0);
};
// テスト実行環境では、テストフレームワークのクリーンアップ機構に委ねるべき。
// 例: Jestの場合、各テストスイートでafterAllを適切に利用する。
// アプリケーションプロセスとテストプロセスでのSIGTERMハンドリングを明確に分離し、
// テストランナーが子プロセスのテストを適切に終了させるよう設計されているか確認する。
// テスト内の非同期処理にタイムアウトを設定し、デッドロックを避けることも重要。
// 問題がプロセス間通信や共有リソースにある場合、モック化や隔離された環境での実行が有効。