遅延評価を観測するエクスポートテスト:設計原理
一言でいうと
行の消費回数・改行のエスケープ・公開フィールド・レスポンス形式を検証します。
なぜ必要なのか
小さなリストでテストしたエクスポートのコードは正常に見えましたが、大きな入力をすべてlistに変換してメモリを使い果たしました。結果が同じだというだけでは、実行方式の契約まで同じだとはいえません。遅延消費を、目に見える状態に変える必要があります。
どう動くのか
入力のジェネレーターが消費されるたびに、リストに痕跡を残します。生成した直後は0回、最初のnextの後は1回、上限の後はそれ以上消費しないことを確認します。ハングルと改行を含むJSON Linesを再パースし、HTTPレスポンスのContent-Typeと、内部フィールドの除去も検査します。
학생 테스트 → 정상 구현: 실제 시험 모두 통과
└→ 계약 위반 구현: 해당 동작에서 실패
수집 실패·0개 실행·강제 종료 ≠ 결함 검출
契約を読んで失敗を予測するワークシート
以下は、実装を丸ごと暗記するための答案ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を破っています。変更後でも、正常なケースが成功することがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが表れるかを予想し、実装した後で、その予想と結果を比較します。
1. 行の契約を検証する(テスト)
提供されたservice.pyの次の公開契約をテストしてください: validate_row(row)は、dictで、idがboolを除く正のint、nameが空でないstrのとき、rowを返します。それ以外はValueErrorです。追加の内部フィールドは許可します。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。
判断の根拠: boolと数値を区別し、空の名前をエラーとして扱います。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。
レビューする誤った変更の断片:
not isinstance(row.get("id"), int)
この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。
2. 公開する行だけを作る(テスト)
提供されたservice.pyの次の公開契約をテストしてください: project(row)は、validate_rowの後、idとnameだけを持つ新しいdictを返します。元の内部フィールドはそのまま保持します。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。
判断の根拠: エクスポートの経路にも、通常のAPIと同じ公開フィールドのポリシーを適用する必要があります。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。
レビューする誤った変更の断片:
"name":row["name"], "internal_cost":row.get("internal_cost")}
この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。
3. 行の境界を保ったままエンコードする(テスト)
提供されたservice.pyの次の公開契約をテストしてください: encode_line(row)は、projectの結果を、ensure_ascii=False、separators=(',',':')、sort_keys=TrueでJSONエンコードし、最後に' 'を1つ付けたstrです。name内の改行はJSONのエスケープである必要があります。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。
判断の根拠: 文字列の連結でJSONを作ると、引用符や改行で形式が壊れます。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。
レビューする誤った変更の断片:
ensure_ascii=True
この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。
4. 出力件数の上限を検証する(テスト)
提供されたservice.pyの次の公開契約をテストしてください: validate_max(value)は、boolを除く1–1000のintだけをそのまま返し、それ以外はValueErrorです。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。
判断の根拠: 無限の入力を誤って最後まで読み込まないように、呼び出し側に上限を要求します。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。
レビューする誤った変更の断片:
<= 1001
この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。
5. 必要な行だけを消費する(テスト)
提供されたservice.pyの次の公開契約をテストしてください: take_rows(rows, maximum)は、isliceなどで最大maximum個だけを遅延して返すiteratorです。呼び出し時にmaximumを検証し、nextを1回呼ぶと入力を1回だけ消費します。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。
判断の根拠: list(rows)に変換した瞬間に、無限の入力や大容量の入力を処理できなくなります。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。
レビューする誤った変更の断片:
islice(list(rows), validate_max(maximum))
この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。
6. 行を遅延してシリアライズする(テスト)
提供されたservice.pyの次の公開契約をテストしてください: json_lines(rows, maximum=100)は、take_rowsから受け取った行ごとにencode_lineをyieldします。すべてを結合した文字列やリストは返しません。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。
判断の根拠: オブジェクトの選択と表現の変換を、それぞれ遅延ステップのまま保ちます。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。
レビューする誤った変更の断片:
for row in list(take_rows(rows, maximum)):
この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。
7. ダウンロードした行を再検証する(テスト)
提供されたservice.pyの次の公開契約をテストしてください: decode_lines(text)は、splitlinesの空でない各行をjson.loadsしてvalidate_rowし、リストとして返します。空文字列は[]、空の中間行はValueErrorです。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。
判断の根拠: 空のファイルと、形式が壊れた空のレコードを区別します。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。
レビューする誤った変更の断片:
continue
この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。
8. HTTPダウンロードを完成させる(テスト)
提供されたservice.pyの次の公開契約をテストしてください: create_app(rows)は、GET /exportでjson_lines(rows, 100)を、application/x-ndjsonのStreamingResponseとして返します。rowsは、再び反復可能なリストです。内部フィールドがなく、各行の内容と順序を保持する必要があります。正常な実装では成功し、この契約に違反する実装では、実際のテスト本文の失敗として検出する必要があります。前のステップのテストを維持したまま、test_関数を追加してください。
判断の根拠: Content-Typeだけをストリーミングと書いて、内部ではすべてを集めていないかを、ジェネレーターのテストと合わせて確認します。実装ファイルは修正しません。pytest.raisesで期待する例外を確認し、正常な結果には具体的な期待値をassertしてください。
レビューする誤った変更の断片:
media_type="application/json"
この断片が入った関数の公開契約と比較してください。成功するケースが1つだけでは区別できない場合は、拒否されるべき入力や、失敗した後の状態を観測の対象に選びます。
現場での姿
TestClientはレスポンスをバッファリングするので、ネットワークの最初のバイトの遅延や、メモリ上限の全体は証明しません。遅延評価かどうかは、別のカウント用ジェネレーターで検査します。ストリームの開始後に誤った行に遭遇すると、通常のエラーJSONにステータスを変えることは困難です。実際のサービスでは、事前検証・行ごとのエラー形式・中断ポリシーのどれを選ぶかを決める必要があります。提供された実装は読んでもかまいませんが、採点は別のコピーを使用します。ソースの文言の検査やファイルの修正で欠陥を回避せず、公開インターフェースの実行結果を検査してください。
次のラボですること
8つのステップが、1つの実行可能な成果物につながります。行の契約を検証する(テスト) → 公開する行だけを作る(テスト) → 行の境界を保ったままエンコードする(テスト) → 出力件数の上限を検証する(テスト) → 必要な行だけを消費する(テスト) → 行を遅延してシリアライズする(テスト) → ダウンロードした行を再検証する(テスト) → HTTPダウンロードを完成させる(テスト)。
各ステップでは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どのテストが失敗するかを確認してください。前のテストが次のステップでも維持される理由を説明し、このラボが保証しない本番の条件を1つ書いてみてください。