おそらく一生忘れないであろう、ある朝の出来事です。
私たちは新しい決済機能を完成させたばかりでした。実際の「お金」が絡むため、絶対に失敗が許されない類の機能です。デプロイする前、念には念を入れてテストスイート全体をもう一度実行しました。すべてパスしました。CIもパス。「問題なし」、すべてのチェック項目がそう告げていました。
それでも、バグはすり抜けたのです。
私がこの話をしているのは、自分のミスを告白するためではありません。どの教材もここまで明確には教えてくれなかった「テスト」に関する教訓を得たからです。そして、このバグがどのように発見され、特定され、対処されたかという過程が、「優れたプロセスが実際に何を守ってくれるのか」を雄弁に物語っているからです。これについては後述します。
まずは、バグそのものについて。
それは、真っ赤なエラー画面が出るような、わかりやすい障害ではありませんでした。クライアントから報告された症状はたった1行、「カードが追加できない」というものでした。その同じカードが他の場所では全く問題なく使えるにもかかわらず、です。コントローラーはエラーをキャッチし、一般的なメッセージを返していたため、外部からは何が間違っているのか明確なシグナルはありませんでした。そこで私たちはいつものように、調査し、トレースし、修正にかかりました。
深く掘り下げてみると、エラーは決済ゲートウェイでのメンバー作成(Member creation)のステップで発生していることが判明しました。つまり、実際の請求処理にすら到達していなかったのです。そして、カード自体とは何の関係もありませんでした。
私がこの問題について深く考えさせられたのは、バグそのものが理由ではありません。
「すべてのテストをパスしたのなら、一体それらは何をテストしていたのか?」という疑問からです。
この記事は、実際のプロジェクトのコード(整理・匿名化済み)を交えながら、私がたどり着いたその答えです。以前、私の同僚がこのブログで、マネジメントの視点からAIを使ったコーディングについて書き、私が後で借りることになる比喩を使っていました。「AIは優秀な副操縦士(Co-pilot)だが、機長(Captain)としては最悪だ」と。これは、ターミナルの内側から語る同じ教訓です。今回は実際のコードと、検証すべき現実の状況とともに。
「すべてパスした」の罠
典型的なテストをお見せしましょう。
この機能はバックグラウンドジョブとして実行されます。決済サービスを呼び出し、決済をキャプチャし、請求書(Invoice)を作成します。テストは次のようになります。
PHP
$mockGmoService = $this->createMock(PaymentGatewayService::class);
$mockGmoService->method('directCapturePayment')->willReturn([
'success' => true,
'entry' => ['success' => true, 'data' => ['accessID' => 'ACC123']],
'exec' => ['success' => true, 'data' => ['tranID' => 'TRN123']],
]);
$job->handle($mockGmoService, $mockPaymentService);
$this->assertDatabaseCount('ad_invoices', 1);
$invoice = AdInvoice::first();
$this->assertEquals(INVOICE_STATUS_PAID, $invoice->status);
一見すると、全く問題なさそうです。モックサービスを作成し、「directCapturePaymentが呼ばれたらこれを返せ」と指示し、ジョブを実行して、請求書が正しく作成されたかを確認しています。
テストはパスします。常にパスするでしょう。
すべてのテストはグリーン(成功)です。しかし、実際の動作を検証しているものは一つもありません。
そこが問題なのです。

モックをよく見てください。私は手動でdirectCapturePaymentがsuccess、entry、exec、そしてexec.data.tranIDを含む構造を返すように宣言しています。しかし、その構造はどこから来たのでしょうか?私がテストを書いた瞬間の、サービスに対する「私の記憶」からです。モックは実際のサービスを一切確認しません。私が定義したシナリオをただ再生するだけです。
言い換えれば、モックは「質問」であり、同時に「答え」なのです。
このテストは、ジョブがサービスを正しく呼び出しているかを検証しているわけではありません。私が作り上げた全く同じ配列をジョブが処理できるかを検証しているだけです。これは鏡のようなものです。私はその鏡を覗き込み、自分自身の思い込みが反射しているのを見て、「うん、正しいな」と言っているのです。
そして、これは特殊なケースではありません。そのレイヤー全体を通して、ほとんどすべてのテストが同じパターンに従っています。サービスをモック化し、その出力を定義し、その出力に対してアサーションを行うのです。
PHP
$this->gmoMemberServiceMock
->shouldReceive('searchCard')
->with($memberId, $cardSeq)
->andReturn([
'success' => true,
'data' => [['cardSeq' => '1', 'cardNo' => '411111******1111']],
]);
$result = $this->paymentService->searchCard($memberId, $cardSeq);
$this->assertTrue($result['success']);
メカニズムは同じです。success/dataの形は、テスト内で私によって定義されています。実際のサービスが本当に何を返すのか—テストは知る由もありませんし、気にも留めません。
2つの緑の島、そしてその間にある溝
これが1つ目の要素です。2つ目は、実際にエラーを引き起こした原因です。
決済サービス(モック化されている側)には、独自のテストスイートがあります。そしてそのスイートもパスしています。ある時点で、そのサービスの実際の出力が変更されました。キーの名前が変更されたり、ネストの構造がわずかに変わったりしたのです。変更を加えた開発者は、論理的な行動をとりました。新しい出力に一致するようにサービスのテストを更新したのです。それらのテストは再びパスしました。
もうお分かりでしょう。
サービス側では、コードが変更され、テストが更新され、グリーンになりました。呼び出し側(決済ジョブ)では、依然として古い構造をモック化しています。モックは古い思い込みを凍結したスナップショットだからです。こちらのテストもグリーンのままです。
両側に2つの緑の島があります。
しかし、その間には何もありません。
「実際のサービスが返すもの」が「呼び出し元が期待するもの」と一致しているかを確認するテストがないのです。その整合性(テスト用語で言う契約(Contract))は、1つのテストも赤(失敗)になることなく、静かにズレていきました。
これが、「すべてパスした」ことと「本番環境で壊れた」ことが同時に真実になり得る理由です。
テストは確かにパスしました。単に、一番重要なことをテストしていなかっただけです。
グリーンのテストは、システムが正しいことの証明ではありません。コードが、あなたがテストに組み込んだ「思い込み(前提)」と一致していることの証明に過ぎないのです。もしその思い込みが間違っていれば、テストは喜んで間違ったことを「正しい」と確認してくれます。
同じ問題、もう一つ上のレイヤー
最も興味深い部分は、コードの中にさえありませんでした。
この決済フローを適切にテストするには、完全に新規のアカウントが必要です。決済ゲートウェイでのメンバー作成は新規アカウントでのみトリガーされるため、まさに検証が必要なパスだったからです。問題はこれでした。私たちのテストプロセスには、テストに使用したアカウントが「本当に新規」なのか、それとも表面上は新規に見える「カードを削除しただけの古いアカウント」なのかを確認するステップがありませんでした。
検証なしでは、「テストして問題はなかった」ことと、「実際に正しいシナリオをテストした」ことは、外から見れば同じに見えても、全く別のことです。
ここで私は腑に落ちました。これは、モックと同じ問題が、別のレイヤーで起きているだけなのだと。
モックは、検証なしにサービスが古い形を返すと思い込んでいます。テストプロセスは、同じく検証なしにアカウントが新規であると思い込んでいます。
どちらのケースでも、検証されていない前提(Unverified assumption)が、誰にも疑われることなくすべてのチェックポイントを通過してしまいます。ターミナルの緑色と、「問題なさそう」というテストの実行結果は、根本的には同じものです。つまり「検証なき安心感」です。
これはもはや、単なるテストやプロセスの問題ではありません。同じ失敗がレイヤーをまたいで繰り返されているのです。検証されていない前提がすべての制御ポイントをすり抜けていく。それが現実から乖離していても、システムは依然としてグリーンを報告します。それはシステムが「信じていること」に基づいたグリーンであって、実際に起きていることではありません。
もちろん、どこですり抜けたのかを追跡しました。修正するには理解する必要がありますから。しかし、「誰がこれを通したのか」という問いに時間をかけすぎたくはありませんでした。その問いが次の改善につながることは稀だからです。バグがすべてのチェックポイントを同時に通過する場合、それは通常「1つのチェックポイントが失敗した」のではなく、「本来そこにあるべきレイヤーがシステムに欠けている」ことを意味します。個々のゲートを修正しても意味がありません。欠けているレイヤーを追加する必要があるのです。
落下を防いだセーフティネット
バグが本番環境まで到達したと言いました。大惨事になったと思うかもしれません。
しかし、影響を受けた顧客は一人もいませんでした。
それは私たちが運が良かったからではなく、コードを1行も書く前にチームが下した決定のおかげでした。
これが決済機能の最初のリリースであったため、すべてのテストがパスしたとしても、未知の問題が「必ず発生する」と私たちは想定していました。そのため、全員に公開するのではなく、段階的にリリースしました。バックエンドを本番環境にデプロイし、テストのためにステージングのフロントエンドからそこを向くように設定しました。この時点では、アプリやウェブ上の実際のユーザーは新しい決済フローにアクセスできません。
バグは、まさにそのバッファゾーンで表面化しました。
ユーザーではなく、ネットが受け止めたのです。
最終的なテストを私たちではなくクライアントが行った理由についてですが、このフローを完全に実行するには、実際のカード取引が必要です。開発者の個人のカードに請求する正当な理由はありません。これは最初から合意されていました。本番環境でのテストは、クライアントが自身のデータを使用し、自身のスケジュールで行うと。慎重なリリースとは、土壇場の対応ではなく、事前に構築された双方の合意なのです。
振り返ってみると、教訓は「私たちのテストはひどく、危うく大惨事を引き起こすところだった」ではありません。
教訓はこれです。「パスしたテストを正しさの十分な証明として決して扱わなかったため、現実世界のセーフティネットを配置できた」。その健全な懐疑心(検証ツールが嘘をついているかもしれないという疑い)こそが、深刻なインシデントになり得た事態を、穏やかなログレビューに変えてくれたのです。
多層防御(Defense in depth)はやりすぎではありません。最初の防衛線であるテストがあなたを裏切ったまさにその日のために存在するのです。
AIが間違ったものを守る時
これらのテストのほとんどは、AIの支援を受けて書かれました。速くてクリーンでした。サービスを渡せば、適切な名前のメソッドと十分なカバレッジを備えた完全なテストファイルを生成してくれます。まるで優秀なチームメイトがもう一人いるような感覚です。
根本原因を見つけた後、私はAIのところに戻り、静的な出力をモック化するのではなく、実際のサービスをインポートして実際の動作を検証するようにこれらのテストを書き直すよう頼みました。
AIは拒否しました。
エラーとしてではなく、理由をつけて拒否したのです。外部サービスのモック化はベストプラクティスであること、ユニットテストは隔離された状態を保つべきであること、実際のシステムに触れるべきではないことなどを主張しました。
ここが重要なポイントです。AIは間違っていませんでした。
教科書通りなら、その通りです。高速で隔離されたユニットテストのために外部サービスをモック化することは、標準的なプラクティスです。
しかし、AIはどんな本にもコード化できないものに盲目です。それが「コンテキスト(文脈)」です。
AIが頑なにモック化を主張した境界線は、無害な依存関係などではありませんでした。そこは「リアルなお金が流れる場所」だったのです。そして、その「正しいユニットテストのプラクティス」こそが、結合バグがすり抜ける決定的な隙間となりました。「ユニットテストは実際のシステムに触れるべきではない」というルールは10回中9回は機能します。しかし今回は、モックが手の届かない場所まで到達する追加のレイヤーが必要な「10回目のケース」だったのです。
AIは、あなたがどちらのケースにいるのかを知りません。
「モック化しても安全な場所」と「ここでモック化するとお金を失う場所」を区別できないのです。AIは間違った場所でルールを正しく適用し、自信満々にそれを擁護します。その自信こそが、そもそも私がグリーンのテストを信じてしまった原因でもあります。
ルールが適用できない時を見極めるのは、人間の仕事です。
それは副操縦士に委ねるべきことではありません。
AIの限界や、開発者がAIの提案を鵜呑みにせずどう向き合うべきかについては、「AIが開発者の技術習得をどう変えたか」でも詳しく触れています。
では、「修正する」とは実際にはどのようなことか?
明確にしておきますが、教訓は「モックを使うな」ということではありませんし、絶対に「AIを使ってテストを書くな」ということでもありません。モックは有用です。テストを実行するたびに実際の決済ゲートウェイにアクセスするべきではありません。
問題はモック化することではなく、そこで止まってしまうことです。
同じプロジェクト内に、私が本当に信頼しているテストがあります。それはこのようなものです。
PHP
$job->handle($mockGmoService, $mockPaymentService);
$this->assertDatabaseCount('ad_invoices', 1);
$invoice = AdInvoice::first();
$this->assertEquals(INVOICE_STATUS_PAID, $invoice->status);
$this->assertNotNull($invoice->paid_at);
$this->purchase->refresh();
$this->assertNotNull($this->purchase->last_billing_date);
Mail::assertSent(AdPaymentSuccessMail::class);
違いはアサーション(検証)にあります。
このテストは、結果をでっち上げてそれを検証したりはしません。実際のデータベース(RefreshDatabase)に対して実行し、実際の副作用をチェックしています。請求書は書き込まれたか?ステータスはPAIDか?支払い日は設定されたか?メールは実際に送信されたか?これらはモックでは説得力を持って偽造できないものです。請求書のロジックが壊れれば、このテストは赤になります。
この経験から、自分なりのいくつかの原則にたどり着きました。
- 境界線ではモックを使用するが、その境界線を実際に越えるテストのレイヤーを少なくとも1つ持つこと。 決済の統合においては、通常、プロバイダーのサンドボックスに対して実行されるテストを少なくとも1つ用意することを意味します。古いモックの構造に依存するのではなく、レスポンスの契約が変更されたときにそれをキャッチするのに十分なものです。モックが鏡なら、契約テスト(Contract test)は窓です。自分自身の思い込みの代わりに、現実世界を見せてくれます。
- ロジックを再実装するテストには注意すること。 このプロジェクトには、実際の価格計算関数を呼び出すのではなく、テスト内で価格計算の公式を複製してアサーションを行っているテストがありました。これらのテストは、質問と答えを同時に書いているため、本番環境のロジックが間違っていても常にパスします。本物のテストはロジックを「呼び出す」のであって、「再現する」のではありません。
- そして最も難しい原則(技術的なものではないため):「グリーン」であるという感覚に、テストが実際に何を検証しているかの理解を置き換えさせないこと。 クリーンなグリーンの画面は快適です。質問するのをやめさせてくれます。しかし、「もし意図的にこのコードを壊したら、このテストは失敗するだろうか?」という一つの質問は、100個のグリーンチェック以上の価値があります。
再び、副操縦士と機長について
私は今でもAIにテストを書かせています。速いですし、やはり便利です。変わったのはツールではなく、「AIが書き終えた」ことと「完了した」こと、あるいは「テストがグリーンである」ことと「コードが正しい」ことを混同しなくなったことです。
AIは優秀な副操縦士です。私より速く書き、タイポも少なく、構文をよく覚えています。
しかし、どの境界線にお金が絡んでいるかは知りません。これが最初のリリースであり、特別な注意が必要であることも知りません。つい先週、自分が書いたモックが現実から乖離してしまったことも知りません。
それらは委譲できるものではありません。
グリーンのテストは休息への招待状です。
時にはその休息にふさわしいこともあります。
しかし、休む前に一つだけ質問してください。
「このグリーンはシステムが正しいことを教えてくれているのか、それとも私が最初から信じていたことと一致しているだけなのか?」
この2つは同じではありません。
そして時に、その2つの間の距離は、ちょうど「1つの決済フロー」分だったりするのです。
(この記事は、「AIを使ってどのように適切にテストを書くか」よりも、問題そのものに焦点を当てました。それは別の記事にする価値がありますし、正直なところ、私自身まだ模索中のテーマでもあります。なので、それについては次の記事に残しておき、適切に整理して書くという公開の約束とさせてください。それではまた。)
堅牢なシステム構築をお考えですか? 本記事で触れたような、エッジケースを見据えたテスト設計や安全なデプロイメント戦略は、ビジネスを守る上で欠かせません。リンノエッジのシステム開発サービスでは、単なる「動くコード」ではなく、検証された前提に基づく信頼性の高いシステムを提供しています。開発プロジェクトに関するご相談は、ぜひお気軽にお問い合わせください。