Flaky Integration Tests の安定化に向けた改良
本PRは、Kamal の統合テストが不安定になる要因を排除し、エラーメッセージの抑制とタイムアウト判定の可搬性を向上させた点に焦点を当てています。テスト実行時の期待される失敗を静粛化し、Docker コンテナのヘルスチェックと再試行ロジックを強化することで、CI 環境での信頼性を向上させました。
背景
Kamal の CI では、統合テストが時折失敗し、原因が「期待されたエラー出力」や「コンテナ起動遅延」に起因することが判明していました。これに対処するためには、テストコード側で出力を抑制し、コンテナのヘルス状態を明示的に待機する仕組みが必要でした。
技術的な変更
SSHKit スレッド例外抑制
lib/kamal/sshkit_with_ext.rb の CompleteAll#execute 内で、各スレッドの report_on_exception フラグを false に設定しました。
Thread.current.report_on_exception = false
このフラグを無効化することで、テスト実行中に期待された例外が標準エラーストリームに出力されることを防ぎ、ログが汚染されるのを回避しています。
Git 初期化の静粛化
test/cli/main_test.rb で、リポジトリ初期化コマンドを git init -q -b main に変更しました。
`git init -q -b main`
-q オプションにより出力が抑制され、テスト実行時のノイズが減少します。
SSH 設定の非推奨警告テスト
test/configuration/ssh_test.rb では、capture_io を用いて標準エラー出力を取得し、非推奨のインライン key_data 使用に関する警告メッセージを検証しています。
_out, err = capture_io { assert_equal [ "-----BEGIN OPENSSH PRIVATE KEY-----" ], config.ssh.options[:key_data] }
assert_match(/Inline key_data usage is deprecated/, err)
この追加により、警告が正しく出力されることがテストで保証されます。
アクセサリテストのレスポンス待機ロジック
test/integration/accessory_test.rb では、wait_for_netcat_response ヘルパーを導入し、期待ステータスコードが得られるまでポーリングするロジックを実装しました。
def wait_for_netcat_response(expected, timeout: 20)
timeout_at = Time.now + timeout
response = netcat_response
while response.code != expected && timeout_at > Time.now
sleep 0.1
response = netcat_response
end
debug_response_code(response, expected)
response.code
end
これにより、テストがタイムアウトで失敗するケースが減少し、失敗理由が「タイムアウト」か「期待外れ」かを明確に判別できます。
Docker Compose のヘルス依存付加
test/integration/docker-compose.yml の全サービス定義に depends_on と condition: service_healthy を追加しました。
depends_on:
shared:
condition: service_healthy
この変更は、shared ボリュームが正常にヘルスチェックを通過するまで他サービスの起動を遅延させ、コンテナ間の競合を防止します。
コンテナ起動リトライとヘルスチェック強化
test/integration/integration_test.rb に compose_up_with_retry メソッドを導入し、docker_compose "up --build -d" が例外を投げた際に一度だけ再試行します。
def compose_up_with_retry
docker_compose "up --build -d"
rescue RuntimeError => e
raise if @compose_up_retried
@compose_up_retried = true
puts "compose up failed, retrying once: #{e.message.lines.first&.strip}"
docker_compose "down -t 1", raise_on_error: false
retry
end
wait_for_healthy も改良され、コンテナステータスを詳細に取得し、unhealthy や exited などのハードエラーを検出した場合はログを出力した上で即座に例外を発生させます。また、タイムアウト時に「起動遅延」か「ハードエラー」かをメッセージに分けて提示します。
def wait_for_healthy(timeout: 30)
timeout_at = Time.now + timeout
loop do
containers = container_statuses
break if containers.all? { |c| c["Health"] == "healthy" }
broken = containers.select do |c|
c["Health"] == "unhealthy" || %w[ exited dead restarting ].include?(c["State"])
end
if broken.any?
dump_container_logs(broken)
raise "Container hard error (retry will not help): #{describe_containers(broken)}"
end
if timeout_at < Time.now
starting = containers.reject { |c| c["Health"] == "healthy" }
dump_container_logs(starting)
raise "Container not healthy after #{timeout} seconds (slow boot, retry may help): #{describe_containers(starting)}"
end
sleep 0.1
end
end
標準出力のリダイレクト
test/output/file_logger_test.rb と test/output/otel_logger_test.rb では、テスト実行中に $stdout を StringIO に差し替え、テスト終了後に元に戻す処理を追加しました。
@original_stdout, $stdout = $stdout, StringIO.new
...
$stdout = @original_stdout
この処理により、ロガーが出力する情報がテスト結果に混入しないようにしています。
設計判断
本改修は、テストコード側で「失敗が予測可能」かつ「再現性のある」形に整えるという設計方針に沿っています。例外のレポート抑制、Docker のヘルス依存付加、リトライロジックの導入は、既存の機能に最小限の侵入で実装され、既存の振る舞いを変えずに信頼性だけを向上させるというトレードオフが選択されています。
まとめ
このPRは、Kamal の統合テストがフレーク(不安定)になる根本原因を排除し、エラーログの抑制、ヘルスチェックの明示、リトライ機構の追加という三本柱で安定化を実現しました。テストコードの可搬性が高まり、CI 環境での継続的検証が信頼できるものとなります。