深夜2時、本番環境のアラートが鳴り響きます。外部APIがタイムアウトを返し始め、リトライが暴発し、システム全体が連鎖的に停止しました。原因を調べると、外部サービスの一時的な遅延でした。たった数秒の遅延が、なぜシステム全体を止めたのか。答えは単純です。「外部APIが遅延したらどうなるか」を、誰もテストしていなかったからです。
私自身、このような障害を何度か経験してきました。コードをマージした翌朝にSlackが炎上していたこともあります。「なぜこのケースを考えなかったのか」と自分を責めながら、ホットフィックスを書いた夜もあります。そのたびに思います。あのとき、たった1つのテストを書いていれば。
これは「異常系テスト」の不足が引き起こした障害です。正常系のテストは比較的書きやすいです。入力があり、期待する出力があり、それを検証します。しかし、プロダクション環境で本当に問題になるのは異常系です。ネットワークが切断されたとき、システムはどう振る舞うべきか。データベースがタイムアウトしたとき、ユーザーには何を伝えるべきか。想定外の入力が来たとき、エラーメッセージは適切か。こうした問いに答えるのが、異常系テストです。
そして今、この異常系テストの世界が大きく変わりつつあります。2024年、AIがOpenSSLに20年間潜伏していた脆弱性を発見しました。人間が書いたファジング(ランダムなデータを入力してバグを探す手法)では見つけられなかった欠陥です。GoogleOSS-FuzzはAIによるファズターゲット生成で26件の脆弱性を発見し、既存の人間作成ターゲットから最大29%のカバレッジ向上を実現しました。人間だけでは見つけられなかった異常を、AIが発見する時代になりました。
本記事では、この変化を踏まえて異常系テストの考え方をまとめました。まず基本的な技法を押さえ、その上でAIやカオスエンジニアリングといったものを紹介します。あの深夜のアラートを、未来の自分や読者が経験しなくて済むように。
本記事を読む前に、いくつか断っておきたいことがあります。
私はテストの専門家ではありません。日々コードを書きながら、「この処理が失敗したらどうなるだろう」と考える、一人のエンジニアです。ここに書いてあることは、思いついたものをまとめただけなので不足もあるでしょう。実装しながら単体テストやエラーハンドリングを考える際のヒントとして使ってもらえればと思います。
もう一つ、大事なことがあります。すべてのパターンをテストする必要はありません。過度なテストパターンは無駄な工数を生むだけではありません。テストが増えれば増えるほどCIの実行時間は長くなり、開発サイクルは遅くなります。テストコードを読んで理解するにも認知コストがかかります。テストが多すぎると、「このテストは何を確認しているのか」を把握するだけで疲弊してしまいます。
テストのROI(投資対効果)を意識し、リスクの高い箇所に集中することが重要です。とはいえ、最近は生成AIのモデル精度が上がったおかげで、文脈を読み取ってテストコードを生成してくれるようになりました。「何をテストすべきか」を判断し、AIに生成を任せる。その使い分けが、今のエンジニアに求められています。
そして、ここが難しいところなのですが、異常系テストがどれくらい必要かは、その人の経験に大きく依存します。本番障害で痛い目を見た人は「ここまでテストすべきだ」と感じます。幸運にも大きな障害を経験していない人は「そこまでやる必要があるのか」と思います。これは良い悪いではなく、思考の枠組みそのものが異なるのです。だからこそ、チームとして、組織として「どこまでの異常を許容するのか」を明確にしておく必要があります。暗黙の了解ではなく、言語化する。そうしないと、「テストが多すぎる」「テストが足りない」という不毛な議論が続くことになります。
では、本題に入りましょう。
異常系テストとは、システムが想定外の状況に遭遇したとき、適切にエラーハンドリングできるかを検証するテストです。正常系テストが「うまくいくパス」を確認するのに対し、異常系テストは「うまくいかないパス」を確認します。
異常系と一口に言っても、その原因はさまざまです。「どこから異常が来るか」という視点で整理すると、4つの観点に分けられます。
この順番には意味があります。入力値の異常は最も頻繁に発生し、テストも書きやすいです。状態の異常はビジネスロジックと密接に関わります。環境の異常はテストが難しいですが、本番では必ず起きます。競合の異常は最も見つけにくく、再現も難しいです。つまり、テストの書きやすさと、問題の発見しにくさは、おおむね逆の関係にあります。
それぞれについて見ていきましょう。
フォームに全角スペースだけを入力して送信したら、システムがエラーを吐いた。そんな経験はないでしょうか。あるいは、絵文字を含む名前を登録しようとしたら、データベースエラーが返ってきた。ユーザーは悪意を持っていたわけではありません。ただ、開発者が「想定していなかった」だけです。どれだけ想像力を働かせても、ユーザーは必ずその想像の外側から来ます。
ユーザーからの入力は信用できません。これはセキュリティの基本原則ですが、テストにおいても同様です。
境界値分析は、ソフトウェアテストの古典的な技法です。入力値の境界付近でエラーが発生しやすいという経験則に基づいています。
# 例: 文字数制限が255文字の場合- 255文字 → 成功するべき- 256文字 → エラーになるべき- 0文字(空文字) → 要件による
よくある境界値のテストケース:
この技法は同値分割法(Equivalence Partitioning)と組み合わせて使うことが多いです。同値分割法では、入力値を「同じ振る舞いをするグループ」に分割し、各グループから代表値を選んでテストします。たとえば「1〜255文字」「256文字以上」「0文字」の3グループに分け、それぞれの代表値と境界値をテストします。
現代のAPI開発では、境界値分析の対象は数値入力を超えて拡張されています。APIのページネーション制限(page size=99, 100, 101)、リクエストペイロードサイズ制限、タイムアウト閾値、レートリミットの境界などが現代的なBVA対象です。
境界値の中でも、特に扱いが難しいのが「空」という概念です。空値の扱いは設計上の判断が必要になります。
| 値 | 検討ポイント |
|---|---|
空文字"" | 許容するか、エラーにするか |
スペースのみ" " | トリムするか、エラーにするか |
| NULL | 必須項目か、オプショナルか |
| undefined | デフォルト値を使うか |
テストを書くことで、こうした設計の曖昧さが明確になることがあります。「空文字を許容するか」という問いに対して、チームで合意を取る機会になります。
ここで気づくべき重要なことがあります。
異常系テストの気付きにくい価値は、バグを見つけることではありません。設計を問い直すことです。
「この入力が来たらどうするか」という問いを立てることで、仕様の穴が見えます。テストを書く行為そのものが、システムの堅牢性を高めています。テストが通るかどうかは、実は二次的な問題なのです。
空値に続いて、もう一つ厄介なのが文字種です。日本語を扱うシステムでは、文字種のテストが特に重要になります。
これらの文字が入力されたとき、システムがどう振る舞うかを確認します。データベースの文字コード設定やAPIのエンコーディングによっては、予期しない動作をすることがあります。
ここまでは「意図しない入力」の話でした。しかし、世の中には「意図的に悪意のある入力」を送りつけてくる人もいます。セキュリティ関連の入力値テストは、インジェクション攻撃(悪意のあるコードを入力に紛れ込ませる攻撃)への耐性を確認します。OWASP Testing Guideは、このようなセキュリティテストの標準的な指針を提供しています。
<script>alert('XSS')</script> — Webページに悪意のあるスクリプトを埋め込む攻撃'; DROP TABLE users;-- — データベースを不正に操作する攻撃; rm -rf / — サーバーで不正なコマンドを実行させる攻撃../../../etc/passwd — 本来アクセスできないファイルを読み取る攻撃インジェクション攻撃への対策はセキュリティテストの領域でもありますが、異常系テストとして「不正な入力が来たときにシステムが適切にエラーを返すか」を確認しておくことは重要です。
エラー推測(Error Guessing)という経験ベースの技法も有効です。過去のバグ傾向から共通パターン(NullPointerException、ゼロ除算、日時パース問題など)を識別し、重点的にテストします。
ここまで紹介した技法は、人間がテストケースを考えるものでした。しかし、人間の想像力には限界があります。そこで注目されているのが、ランダムな入力を自動生成してバグを探すファジング(Fuzzing)です。
冒頭で触れたGoogleOSS-FuzzのAI活用は、まさにこの領域での成果です。AIが生成したファズターゲットにより26件の脆弱性が発見され、OpenSSLに20年間潜伏していた欠陥も見つかりました。人間が「こういう入力が来るかも」と想像する範囲を超えて、AIが異常な入力パターンを生成します。
もう一つ、Property-based testing(性質ベーステスト)という手法も企業での採用が加速しています。従来のテストは「入力Aに対して出力Bが返る」という具体的なペアを書きます。Property-based testingでは「どんな入力に対しても、この性質が成り立つ」という形で定義します。たとえば「リストをソートして逆順にしても、要素数は変わらない」といった性質です。
Python向けのHypothesisは週間300万ダウンロードを超え、numpyやastropyなどの科学ライブラリでバグを発見した実績があります。QuickCheck(Haskell)、fast-check(JavaScript)、proptest(Rust)など、各言語でエコシステムが成熟しています。
入力値の異常は、ユーザーから直接来るものでした。次に見るのは、システムの内部で起きる異常です。
「さっきまで動いていたのに」。この言葉に覚えはないでしょうか。ユーザーが画面を開いている間に、別のユーザーがデータを削除します。システムの状態は常に変化しています。画面に表示された瞬間、それはもう過去です。
リソースの状態に関するテストでは、以下のようなケースを考慮します。
存在しないIDでアクセスしたときに、適切なエラー(404 Not Foundなど)が返ることを確認します。
削除されたリソースに再度アクセスしたときの動作を確認します。ユーザーがブックマークしていたページが、管理者によって削除されていた。よくある話です。「404 Not Found」で終わりなのか、「このコンテンツは削除されました」と丁寧に伝えるのか。論理削除と物理削除では挙動が異なります。論理削除なら「削除済み」というステータスを返せます。物理削除ならレコード自体が存在しないため、404を返すことになります。どちらの設計を採用しているかで、テストの期待値も変わります。
処理中(アップロード中、変換中など)のリソースにアクセスしたときの動作を確認します。「まだ準備ができていない」ことをクライアントに適切に伝えられるか。
状態遷移が定義されているシステムでは、不正な遷移を試みたときの動作を確認します。
# 例: 注文のステータス遷移作成 → 確定 → 発送 → 完了# 不正な遷移作成 → 完了(確定と発送をスキップ)完了 → 作成(逆方向の遷移)
入力値の異常、状態の異常は、どちらもアプリケーション内部の話でした。しかし、システムは単独で動いているわけではありません。次は、システムの外側から来る異常を見ていきます。
環境の異常は、テストが最も難しい領域です。開発環境では再現しにくいですが、プロダクション環境では必ず発生します。ローカルで動いたからといって、本番で動く保証はありません。開発環境は、ある意味で嘘をつきます。ネットワークは常に安定し、データベースは常に応答し、ディスクは無限にあります。そんな理想的な環境でテストしても、現実の障害には備えられません。だからこそ、どういう異常が起こりうるかを知っておくことが重要です。
近年ではChaos Engineering(カオスエンジニアリング)という手法が注目されています。Netflixが提唱したこのアプローチでは、本番環境に意図的に障害を注入し、システムの回復力を検証します。AWS Fault Injection ServiceやAzure Chaos Studioといったクラウドサービスも登場しています。これは上級者向けの手法ですが、まずは以下のような基本的な異常パターンを理解しておきましょう。
対応方法としては、タイムアウト設定、リトライ、サーキットブレーカーなどがあります。サーキットブレーカーとは、外部サービスへのリクエストが連続して失敗したとき、一時的にリクエストを遮断する仕組みです。電気のブレーカーが過電流を検知して回路を遮断するのと同じ発想で、障害の連鎖を防ぎます。
対応方法としては、コネクションプールの適切な設定、リトライ、タイムアウトなどがあります。
対応方法としては、サーキットブレーカー、フォールバック、キャッシュなどがあります。
リソース枯渇は、テストで再現するより監視とアラートで早期に検知する方が現実的です。とはいえ、リソースが枯渇したときにシステムがどう振る舞うか(gracefulに停止するか、エラーメッセージを出すか)は、設計段階で決めておく必要があります。
「本番環境に障害を注入する? 正気か?」。最初は誰もがそう思います。しかし、問いを変えてみましょう。「本番で障害が起きたとき、それが予期せぬものであることと、計画されたものであること、どちらがマシか?」
カオスエンジニアリングの市場規模は2025年に23.6億ドル、2030年には35.1億ドルに達すると予測されています。もはやニッチな手法ではなく、エンタープライズ標準になりつつあります。
Kubernetes環境ではLitmusChaosとChaos Meshが代表的なツールです。LitmusChaosはCNCFインキュベーティングプロジェクトとして活発に開発が続いています。Chaos MeshはPodChaos、NetworkChaos、IOChaos、StressChaosなど多様な障害タイプを提供します。
GameDay(計画的なカオス実験演習)の実践も広がっています。まず最小の爆発半径(障害の影響範囲)から開始し、単一コンテナ→サービス→ゾーンと段階的にスケールアップします。本番環境を最初のターゲットにしてはなりません。
環境の異常に備えるには、コードにレジリエンスパターンを組み込む必要があります。先に紹介したサーキットブレーカーに加え、以下のパターンが重要です。
これらのパターンを実装したら、カオスエンジニアリングで実際に障害を注入し、期待通りに動作するか検証します。パターンを実装しただけでは不十分で、テストして初めて信頼できます。
ここまで、入力値、状態、環境の異常を見てきました。最後に残るのは、最も厄介な異常です。複数のユーザーが同時にシステムを使うときに起きる問題、競合の異常です。
「ローカルでは動いたのに」。開発者なら誰もが経験するこの言葉の裏には、しばしば競合の問題が潜んでいます。開発環境では自分一人しかアクセスしません。しかし本番環境では、何百人ものユーザーが同時にボタンを押します。本番は、常に渋滞しています。その渋滞の中で、単体テストでは見えなかった問題が姿を現します。
複数のユーザーやプロセスが同時にリソースにアクセスすると、競合が発生しえます。これは単体テストでは見つけにくく、負荷テストや本番環境で初めて発覚することも多いです。だからこそ、「競合が起きたらどうなるか」を事前に設計しておくことが重要です。
典型的なシナリオを考えてみましょう。
1. ユーザーAがデータを取得2. ユーザーBが同じデータを取得3. ユーザーAが更新を実行4. ユーザーBが更新を実行 → どうなるべきか?
ユーザーBの更新時点で、データはすでにユーザーAによって変更されています。このとき、システムはどう振る舞うべきでしょうか。主な対応方法は3つあります。
どの方法を採用するかは、ビジネス要件によります。在庫数のように「先の更新が失われると困る」データには楽観的ロックか悲観的ロック、ユーザーのプロフィールのように「最新の状態が正」でよいデータには最後の更新が勝つ方式、といった使い分けになります。
UIにおいて、ユーザーがボタンを連打した場合の動作を確認します。「送信ボタンを押したけど反応がない。もう一度押そう」。ユーザーは待ってくれません。ネットワークが遅いとき、ボタンが反応しないとき、人は本能的に連打します。「購入する」ボタンを連打したら2回購入されてしまった、という事故は避けたいところです。
対応方法としては、デバウンス(一定時間内の連続クリックを1回とみなす)や、送信中はボタンを無効化する二重送信防止の仕組みがあります。サーバー側でも、同一リクエストを検出するためにリクエストIDを使った冪等性の担保を検討します。
ここまで、4種類の異常(入力値、状態、環境、競合)を見てきました。これらの異常が発生したとき、システムは何らかのエラーを返す必要があります。では、どのようなエラーを返すべきでしょうか。次は、エラーレスポンスの設計について考えていきます。
異常系テストでは、「エラーが起きないこと」ではなく「適切なエラーが返ること」を検証します。エラーレスポンスの設計は、クライアント側のエラーハンドリングに大きく影響します。適切なエラーを返せば、呼び出し側は何が起きたかを判断し、適切に対処できます。
ステータスコードとは、サーバーがクライアント(ブラウザやアプリ)に返す3桁の数字です。この数字を見れば、リクエストが成功したのか、失敗したのか、何が原因なのかが分かります。
gRPCの場合(gRPCはGoogleが開発した高速な通信方式):
他ユーザーのリソースへのアクセスには、エラーコードの選び方に注意が必要です。ここには、多くの開発者が見落としている盲点があります。
素直に考えると、「存在するが権限がない」なら403 Forbiddenを返したくなります。HTTPの仕様としては正しいです。しかし、これには問題があります。攻撃者がIDを総当たりで試したとき、403が返れば「このIDのリソースは存在する」と分かってしまいます。つまり、正しいエラーコードを返すことが、セキュリティホールになるという逆説です。
そこで、他ユーザーのリソースへのアクセスには404 Not Foundを返すという設計があります。「存在するが権限がない」と「存在しない」を区別できないようにすることで、攻撃者に情報を与えません。GitHubのプライベートリポジトリも、この設計を採用しています。権限のないリポジトリにアクセスすると、「存在しない」と表示されます。
これは「嘘をつく」のではなく、「必要以上の情報を与えない」という設計です。エラーメッセージは親切であるべきですが、攻撃者にも親切である必要はありません。
異常の種類と、返すべきエラーレスポンスが分かりました。では、実際にどうやってテストを書けばいいのでしょうか。ここからは、異常系テストの書き方について説明します。
異常系テストで最も基本的なのは、「期待するエラーが返ること」の検証です。正常系では「期待する結果が返ること」を確認しますが、異常系では「期待するエラーが返ること」を確認します。
# 例: 存在しないリソースへのアクセスで404が返ることを検証deftest_get_not_found(): response = client.get("/resources/nonexistent-id")assert response.status_code ==404
各テストは独立して実行できるようにします。テスト間でデータを共有しません。
# テストごとに一意のIDを使用TEST_ID="test-$(date +%s)"
テスト終了後は作成したリソースを削除します。テストデータが残っていると、次回のテスト実行に影響を与える可能性があります。
Mike Cohnの伝統的なテストピラミッド(ユニット→インテグレーション→E2E)では「各要件に対し少なくとも2つのテスト、1つは正常系、1つは異常系」が原則です。Kent C. Doddsの「Testing Trophy」モデルでは、インテグレーションテストを重視します。「テストがソフトウェアの使用方法に似ているほど、より多くの信頼を与える」という原則のもと、インテグレーションテストはユニットテストが見逃すエラー(コンポーネント間の相互作用問題)を捕捉します。
「テストケースを考えるのが面倒」「どこまでカバーすればいいか分からない」。そんな悩みを抱えたことはないでしょうか。AIによるテスト生成は、この問題に一つの解を与えます。
NVIDIAのHEPHフレームワークはLLM(大規模言語モデル)を用いてドキュメントからテストを自動生成します。Diffblue CoverはJavaコードの静的解析からユニットテストを生成します。qodo(旧Codium)はコード動作を分析してエッジケースを含むテストケースを生成します。これらのツールはエラーシナリオ、境界条件、例外処理パスを自動的に導出します。
ただし、AIが生成したテストをそのまま使うのは危険です。「何をテストすべきか」の判断は人間がすべきであり、AIはその実装を支援するツールに過ぎません。
テストを書きました。カバレッジも高いです。しかし、そのテストは本当にバグを見つけられるのでしょうか。
Mutation testing(変異テスト)は、コードに意図的なバグを埋め込み、テストがそれを検出できるか評価する手法です。たとえばif (x > 0)をif (x >= 0)に変更します。この変更をテストが検出できなければ、そのテストには穴があります。
PITest(Java)、Stryker Mutator(JS/TS/C#)、cargo-mutants(Rust)などのツールがCI/CDへの統合を進めています。cargo-mutantsはRustConf 2024で発表され、ソースコード変更なしで任意のRustプロジェクトに適用できます。
すべての異常をテストする時間はありません。リスクベースで優先順位をつけます。
異常系テストは「何が起きたら困るか」を事前に洗い出し、システムが適切に対処できることを検証する作業です。
本記事で紹介した内容を振り返ると、以下の5点が重要になります。
異常系テストは面倒に感じることもあります。しかし、プロダクション環境で障害が発生してから対処するコストに比べれば、事前にテストを書くコストは安いです。深夜2時のアラート対応、原因調査、ホットフィックス、ポストモーテム。そのすべてを、1つのテストが防いでくれることがあります。
障害が起きたら、その教訓をテストとして残す。それが本当の意味での振り返りです。
異常系テストは、将来の自分を助けるための投資です。3ヶ月後の深夜2時、アラートが鳴らなかったとき、過去の自分へ感謝するでしょう。
大げさに考える必要はありません。次にコードを書くとき、1つだけ試してみてください。
「この処理が失敗したら、何が起きるか」を考える。
その問いを立てるだけで、異常系テストは始まっています。APIを呼ぶコードを書いたら、「このAPIがタイムアウトしたらどうなるか」と考えます。データベースに保存するコードを書いたら、「保存に失敗したらどうなるか」と考えます。その問いに対する答えをテストとして書く。それだけでいいのです。
完璧を目指す必要はありません。昨日より1つだけ、システムを堅牢にする。その積み重ねが、深夜のアラートを1回減らし、ユーザーの信頼を1つ守ります。今日書いた1つのテストが、3ヶ月後の深夜2時を救います。AIがテスト生成を支援してくれる時代だからこそ、この「問いを立てる力」は人間にしかできない価値になります。
3ヶ月後の深夜2時。あなたのスマートフォンは静かなままです。アラートは鳴りません。それは偶然ではありません。過去のあなたが書いた1つのテストが、その夜の安眠を守っています。
このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。