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

テストツール実戦

障害を注入してリソース漏れを見つける:設計原理

TT Labで続きを見る

一言でいうと

正常・二重終了・失敗の経路と、アプリのlifespanの開始・後始末を観測します。

なぜ必要なのか

リクエストが成功したときは接続を閉じるコードがありましたが、例外が発生すると開いた接続が残りました。成功レスポンスだけを確認するテストでは、このリークは表に出ませんでした。ライフサイクルのテストは、戻り値よりも、リソースがいつ開き、いつ閉じたかに集中する必要があります。

どう動くのか

テストごとに独立したリソースオブジェクトを作ります。startとstopの回数とopenの状態を確認し、contextの中で意図的に例外を投げます。TestClientをwithで使ってlifespanを実際に実行し、withの終了後の状態までアサートします。

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

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

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

1. リソースの状態を独立させる(テスト)

提供されたservice.pyの次の公開契約をテストしてください: new_resource()は{open:False, events:[]}の新しい辞書で、呼び出し同士でeventsを共有しません。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 変更可能なリストを、グローバルやデフォルト引数で共有しません。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

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

"open":True

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

2. 二重の開始を拒否する(テスト)

提供されたservice.pyの次の公開契約をテストしてください: start(resource)は、すでに開いていればValueError、そうでなければopen=Trueに変えてeventsに'open'を追加します。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 2回開始してリソースを1つ見失う動作を拒否します。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

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

if False:
        raise

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

3. 終了を冪等にする(テスト)

提供されたservice.pyの次の公開契約をテストしてください: stop(resource)は、開いているときだけopen=Falseに変え、'close'をeventsに追加します。すでに閉じていればそのままにします。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 複数の後始末の経路が重なっても、重複したcloseイベントが出てはいけません。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

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

resource["events"].append("closed")

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

4. 閉じたリソースの使用を防ぐ(テスト)

提供されたservice.pyの次の公開契約をテストしてください: read(resource)は、閉じていればRuntimeError、開いていれば{ready:True}を返します。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 準備ができた状態と、オブジェクトが存在することは別です。オブジェクトがあっても、閉じていることがあります。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

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

if False:

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

5. 例外の経路にfinallyを置く(テスト)

提供されたservice.pyの次の公開契約をテストしてください: scope(resource)はcontextmanagerです。入るときにstartし、ブロックの中にはresourceをyieldし、ブロックの成功・失敗のどちらでもstopで閉じます。ブロックの例外は伝播させます。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: yieldの後にだけcloseを書くと、例外が発生した場合にその行へ到達できません。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

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

pass

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

6. アプリのライフサイクルとリソースをつなぐ(テスト)

提供されたservice.pyの次の公開契約をテストしてください: lifespan_for(resource)は、asynccontextmanagerの関数lifespan(app)を返します。scope(resource)の中でapp.state.resourceを設定してyieldします。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: lifespan関数自体を呼び出すのではなく、FastAPIのコンストラクターに渡します。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

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

app.state.resource = dict(resource)

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

7. 準備状態を実際のリクエストで読み取る(テスト)

提供されたservice.pyの次の公開契約をテストしてください: create_app(resource)は、lifespan_forを使います。GET /readyは、app.state.resourceをreadした結果を返します。contextの終了時にリソースを閉じる必要があります。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: with TestClientを使って初めて、lifespanの開始と終了の両方が実行されます。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

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

FastAPI()

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

8. リクエストの後の失敗も後始末する(テスト)

提供されたservice.pyの次の公開契約をテストしてください: exercise(resource, fail=False)は、with TestClient(create_app(resource))の中でGET /readyを呼び出します。fail=Trueならその中でRuntimeErrorを送出し、そうでなければレスポンスのJSONを返します。どちらの場合もリソースが閉じている必要があります。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。

判断の根拠: 正常な経路と例外の経路を、同じ後始末の構造にまとめれば、抜けている終了経路を減らせます。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。

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

if False:

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

現場での姿

学習用のリソース辞書は、実際のDB接続プールの代わりとなる観測装置です。本番では、部分的な初期化の失敗、接続プールの並行性、キャンセル処理と終了のタイムアウトも設計する必要があります。イベントの文字列をレポートに書くのではなく、学習者のコードが実行して変えたオブジェクトの状態を検査します。提供された実装は読んでもかまいませんが、採点は別のコピーを使用します。ソースの文言の検査やファイルの修正で欠陥を回避せず、公開インターフェースの実行結果を検査してください。

次のラボですること

8つのステップが、1つの実行可能な成果物につながります。リソースの状態を独立させる(テスト) → 二重の開始を拒否する(テスト) → 終了を冪等にする(テスト) → 閉じたリソースの使用を防ぐ(テスト) → 例外の経路にfinallyを置く(テスト) → アプリのライフサイクルとリソースをつなぐ(テスト) → 準備状態を実際のリクエストで読み取る(テスト) → リクエストの後の失敗も後始末する(テスト)。

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