TT Lab
はじめる
学ぶ 学習パス コース

テストツール実戦

エラー応答の回帰を防ぐ契約テスト:設計原理

TT Labで続きを見る

一言でいうと

既知のエラー・未知のエラー・非公開情報・追跡ヘッダーをテストします。

なぜ必要なのか

エラーメッセージを変更した後、モバイルアプリのリトライのロジックが壊れました。テストは例外が発生したという事実しか確認しておらず、ステータスと公開本文の意味は比較していませんでした。内部の例外がユーザーにそのまま露出するリグレッションも、同じ隙間をすり抜けました。

どう動くのか

正常なcodeとunknownを表にして、statusと公開する文言を比較します。リクエストidは、長さの境界の両側を検査します。問題本文のキーの集合とContent-Typeを確認し、実際の例外経路で、ヘッダーと本文が同じ追跡値を持つかどうかを観測します。

학생 테스트 → 정상 구현: 실제 시험 모두 통과
           └→ 계약 위반 구현: 해당 동작에서 실패
수집 실패·0개 실행·강제 종료 ≠ 결함 검출

契約を読んで失敗を予測するワークシート

以下は、実装を丸ごと暗記するための答案ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を破っています。変更後でも、正常なケースが成功することがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが表れるかを予想し、実装した後で、その予想と結果を比較します。

1. 業務例外にcodeを残す(テスト)

提供されたservice.pyの次の公開契約をテストしてください: DomainError(code, message)はExceptionのサブクラスで、.codeにcodeを保持します。str(例外)はmessageです。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 機械が判断するcodeと、人が見る内部メッセージを分けます。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

self.code = "invalid"

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

2. codeをステータスにマッピングする(テスト)

提供されたservice.pyの次の公開契約をテストしてください: status_for(code)は、missing=404、conflict=409、invalid=422、それ以外=500です。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 未知のcodeを成功として扱いません。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

.get(code, 200)

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

3. 公開する文言を固定する(テスト)

提供されたservice.pyの次の公開契約をテストしてください: public_message(code)は、missing='Resource not found'、conflict='State conflict'、invalid='Invalid request'、それ以外='Internal error'です。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 例外の文字列にDBのアドレスや内部パスが含まれていても、公開しません。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

"invalid":"Internal error"

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

4. リクエストidを制限する(テスト)

提供されたservice.pyの次の公開契約をテストしてください: request_id(value)は、ASCIIの英数字・アンダースコア・ハイフンだけで構成された1–32文字の文字列ならそのまま、そうでなければ'untracked'です。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 任意のヘッダーを反射しないように、長さと文字集合を一緒に制限します。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

{1,64}

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

5. 問題本文を作る(テスト)

提供されたservice.pyの次の公開契約をテストしてください: problem(code, rid)は、type='urn:labhub:problem:'+code、titleとdetail=public_message(code)、status=status_for(code)、request_id=request_id(rid)だけを持つ辞書です。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 本文とHTTPステータスが食い違っていると、クライアントはどちらの値を信じるべきか判断できません。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

"status":500

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

6. レスポンス形式を一貫させる(テスト)

提供されたservice.pyの次の公開契約をテストしてください: response_for(code, rid)は、problemを本文に、status_forをステータスに、application/problem+jsonをmedia_typeに、X-Request-IDを正規化したridにしたJSONResponseです。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 単純な辞書を返すだけでは、エラーも200になってしまうことがあります。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

media_type="application/json"

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

7. 業務例外のハンドラーをつなぐ(テスト)

提供されたservice.pyの次の公開契約をテストしてください: install_handlers(app)は、DomainErrorのハンドラーを登録します。ヘッダーX-Request-IDを読み取り、exc.codeについてresponse_forを返します。excのmessageはレスポンスに入れません。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 例外を捕捉する場所を散らさず、アプリの共通の境界に置きます。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

response_for("invalid", request.headers.get("x-request-id"))

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

8. 予期しないエラーも隠す(テスト)

提供されたservice.pyの次の公開契約をテストしてください: create_app()は、ハンドラーをインストールし、GET /fail/{code}でDomainError(code, 内部文言)を送出します。ただしcode=boomならRuntimeErrorを送出します。RuntimeErrorのハンドラーは、code=internalの固定の500問題レスポンスを返します。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: テストでは例外の再送出を無効にして、実際の500レスポンスのバイト列を確認します。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

レビューする誤った変更の断片:

response_for("invalid", request.headers.get("x-request-id"))

この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。

現場での姿

このラボの問題レスポンスは、type・title・status・detail・request_idを持つ学習用の契約です。汎用的な国際化や、標準への完全な適合は主張しません。request idは追跡に使う文字列であって認証の手段ではなく、本番のログにも機密値をそのまま記録してはいけません。提供された実装は読んでもかまいませんが、採点は別のコピーを使用します。ソースの文言の検査やファイルの修正で欠陥を回避せず、公開インターフェースの実行結果を検査してください。

次のラボですること

8つのステップが、1つの実行可能な成果物につながります。業務例外にcodeを残す(テスト) → codeをステータスにマッピングする(テスト) → 公開する文言を固定する(テスト) → リクエストidを制限する(テスト) → 問題本文を作る(テスト) → レスポンス形式を一貫させる(テスト) → 業務例外のハンドラーをつなぐ(テスト) → 予期しないエラーも隠す(テスト)。

各ステップでは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どのテストが失敗するかを確認してください。前のテストが次のステップでも維持される理由を説明し、このラボが保証しない本番の条件を1つ書いてみてください。