Flaky Integration Tests の安定化に向けた改良

basecamp/kamal

本PRは、Kamal の統合テストが不安定になる要因を排除し、エラーメッセージの抑制とタイムアウト判定の可搬性を向上させた点に焦点を当てています。テスト実行時の期待される失敗を静粛化し、Docker コンテナのヘルスチェックと再試行ロジックを強化することで、CI 環境での信頼性を向上させました。

背景

Kamal の CI では、統合テストが時折失敗し、原因が「期待されたエラー出力」や「コンテナ起動遅延」に起因することが判明していました。これに対処するためには、テストコード側で出力を抑制し、コンテナのヘルス状態を明示的に待機する仕組みが必要でした。

技術的な変更

SSHKit スレッド例外抑制

lib/kamal/sshkit_with_ext.rbCompleteAll#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_oncondition: service_healthy を追加しました。

depends_on:
  shared:
    condition: service_healthy

この変更は、shared ボリュームが正常にヘルスチェックを通過するまで他サービスの起動を遅延させ、コンテナ間の競合を防止します。

コンテナ起動リトライとヘルスチェック強化

test/integration/integration_test.rbcompose_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 も改良され、コンテナステータスを詳細に取得し、unhealthyexited などのハードエラーを検出した場合はログを出力した上で即座に例外を発生させます。また、タイムアウト時に「起動遅延」か「ハードエラー」かをメッセージに分けて提示します。

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.rbtest/output/otel_logger_test.rb では、テスト実行中に $stdoutStringIO に差し替え、テスト終了後に元に戻す処理を追加しました。

@original_stdout, $stdout = $stdout, StringIO.new
...
$stdout = @original_stdout

この処理により、ロガーが出力する情報がテスト結果に混入しないようにしています。

設計判断

本改修は、テストコード側で「失敗が予測可能」かつ「再現性のある」形に整えるという設計方針に沿っています。例外のレポート抑制、Docker のヘルス依存付加、リトライロジックの導入は、既存の機能に最小限の侵入で実装され、既存の振る舞いを変えずに信頼性だけを向上させるというトレードオフが選択されています。

まとめ

このPRは、Kamal の統合テストがフレーク(不安定)になる根本原因を排除し、エラーログの抑制、ヘルスチェックの明示、リトライ機構の追加という三本柱で安定化を実現しました。テストコードの可搬性が高まり、CI 環境での継続的検証が信頼できるものとなります。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
0e97e74d

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文があり、背景・技術的変更・設計判断・まとめの各セクションが揃っていて、結論はリード文の単なる繰り返しではない。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックは `言語:ファイルパス` 形式で正しく記述され、PRリンクも [#1881](URL) 形式で正しくハイパーリンク化されている。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

内容はエンジニア向けで、専門用語が中心。初心者向けの余計な説明はない。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論→各論→結論の流れになっており、段落はトピックセンテンスで始まり、1段落1トピック、長さも適切に分割されている。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事中のすべてのコードスニペットは提供されたDiffの変更内容と一致しており、ファイル名・行内容とも合致している。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

SSHKit、capture_io、health check などの用語は正しく使用され、誤用は見られない。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的な変更の効果や理由はPRの説明とDiffに基づき正確に記述されている。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事の主張はすべてPRタイトル・説明・Diffで裏付けられており、根拠のない推測や外部知識は含まれない。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号やタイムアウト秒数など数値はDiffと一致し、誤りはない。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルはPRの目的(flaky integration tests の診断・安定化)を正確に表現している。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

LTSやリリース日程等、PRに記載されていない外部情報は一切含まれていない。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現はPRの説明と一致しており、誤った時間的歪曲はない。