ローカル(TTY)ではCLI出力のテキストから成功判定されるが、CI(非TTY)ではJSON形式に変わり判定が失敗する様子を図解。CLI出力がTTY有無で変化し、起動判定ロジックが意図せず失敗する罠を示している。

はじめに

E2Eテストのセットアップスクリプトで、ローカルツールの起動完了を待つ処理を書いたことがある方は多いと思います。手軽な実装として、CLIの出力を文字列マッチでパースして「特定のキーワードが出たら起動完了」と判定するやり方があります。

ところが、この方式には見落としやすい落とし穴があります。CLIによっては、ターミナルから直接叩いたとき(TTY接続あり)と、スクリプトから execSync 経由で呼んだとき(TTY接続なし)とで、出力形式そのものを変えてしまうものがあるのです。手元で動作確認したときの出力を前提に判定ロジックを書くと、CIやスクリプト経由の実行だけが延々とタイムアウトするまで失敗し続けるという、原因の見えにくい不具合になります。今回は、ローカル開発環境の起動待ち判定を題材に、この種の不具合をどう切り分け、どう直したかを整理します。

こんな人におすすめ

  • CLIツールの出力を文字列マッチで待ち受ける処理を書いている方
  • ローカルでは通るのにCI環境だけでE2Eがタイムアウトして困っている方
  • 起動判定やヘルスチェックのロジックをテスト可能な形に整理したい方
  • 複数のCI用スクリプトを抱えていて、環境変数の受け渡し漏れが不安な方

罠1: CLIは実行経路によって出力形式を変えることがある

今回のケースでは、ローカル開発用データベースのCLIが提供する status コマンドの出力に、Project URL のような特定の文字列が含まれることを前提に起動完了を判定していました。ターミナルで手動実行すればその文字列は確かに出力されます。

ところが execSync から非TTYで呼び出すと、同じコマンドでもCLIが人間向けの整形済みテキストではなく、JSON形式の出力に自動的に切り替わっていました。当然ながらチェック対象の文字列はJSONのどこにも現れず、判定ロジックは永遠にfalseを返し続けます。ローカルでツール自体は正常に起動しているのに、判定だけが失敗するという厄介な症状の正体はこれでした。

ここから得られる教訓はシンプルです。動作確認は、実際にコードから呼び出す経路で行う必要があるということです。手動実行とスクリプト実行が同じ結果を返すとは限りません。

罠2: 部分一致判定は同名プレフィックスに釣られる

出力形式をJSONに固定すればそれで解決、というわけでもありません。includes() のような部分一致でキーを探す実装は、似た名前の別フィールドに誤ってマッチするリスクを抱えています。たとえば API_URL を探すつもりが SOME_API_URL のような接頭辞付きの変数名にもマッチしてしまう、といった具合です。

この問題を避けるため、行頭アンカー付きの正規表現に寄せました。

// CLIの出力から「必要なリソースが揃って起動している」ことを判定する。
// 出力の書式に依存しないこと(TTY有無で書式が変わるCLIがあるため)。
export function isReady(statusEnvOutput: string): boolean {
  const hasApi = /^API_URL="?\S+/m.test(statusEnvOutput);
  const hasDb = /^DB_URL="?postgresql:\/\//m.test(statusEnvOutput);
  return hasApi && hasDb;
}

m フラグと ^ を組み合わせることで、行の先頭に一致した場合のみキーとして認めます。これだけで、値の中に偶然キー名が混ざっているケースを排除できます。

対策: 出力形式を固定し、判定ロジックを純粋関数として切り出す

もう一つの対策は、CLI側に構造化出力のオプションがあるなら、それを明示的に指定して書式を固定することです。多くのCLIは -o json や -o env のような出力形式指定オプションを持っており、これを使えばTTY検出に依存しない安定した出力を得られます。

const result = execSync('some-cli status -o env', { encoding: 'utf-8', stdio: 'pipe' });
if (isReady(result)) {
  console.log('準備完了');
  return;
}

さらに、判定ロジック自体を execSync の呼び出しから切り離し、「文字列を受け取って真偽値を返すだけ」の純粋関数にしておくと、テストが書きやすくなります。実際にCLIを起動しなくても、クォートの有無や非既定ポート、部分一致による誤検出といったエッジケースを網羅的に検証できます。

it('行頭以外に現れる同名文字列に釣られない', () => {
  expect(isReady('SOME_API_URL="http://x"\nSOME_DB_URL="postgresql://[REDACTED]"')).toBe(false);
});

副作用のある処理とロジック本体を分離するという、テスト設計の基本的な考え方が、こうした「出力パースの誤検出」問題にもそのまま効いてくる点は、改めて意識しておきたいところです。

つまづきやすいポイント

  • 手元のターミナルでの動作確認結果を鵜呑みにせず、実際にコードが呼び出す経路(execSync や spawn 経由)で再確認する
  • 部分一致(includes())による文字列判定は、想定外のキーにマッチする余地がないか一度疑ってみる
  • CI用のスクリプトが用途別・環境別に複数存在する場合、環境変数の受け渡しに差分がないか定期的に見直す。今回もモックフラグの渡し忘れによる別の失敗が併発していました
  • 一つの不具合を直すとその先に隠れていた別の不具合が表面化することがあるため、直す範囲を明確に区切って進める

まとめ

CLIの出力を文字列マッチで待ち受ける処理は手軽な反面、実行経路によって出力形式が変わるという見えにくい前提に依存しがちです。今回のケースでは、出力形式をオプションで明示的に固定し、判定ロジックを純粋関数として切り出してテストすることで、再発しにくい形に落ち着けました。同じような「ローカルでは動くのにCIだけ失敗する」症状に心当たりがあれば、まず実行経路の違いを疑ってみる価値があります。