エラー応答の回帰を防ぐ契約テスト:設計原理
一言でいうと
既知のエラー・未知のエラー・非公開情報・追跡ヘッダーをテストします。
なぜ必要なのか
エラーメッセージを変更した後、モバイルアプリのリトライのロジックが壊れました。テストは例外が発生したという事実しか確認しておらず、ステータスと公開本文の意味は比較していませんでした。内部の例外がユーザーにそのまま露出するリグレッションも、同じ隙間をすり抜けました。
どう動くのか
正常な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つ書いてみてください。