Claude Media
Claude CodeでFlaskアプリを開発する — test_clientのpytestで完了を判定

Claude CodeでFlaskアプリを開発する — test_clientのpytestで完了を判定

Claude CodeにFlaskアプリを作らせるとき、flask runを起動させず、test_clientを使ったpytestの合格を完了条件にする設定を、CLAUDE.mdとStop hookの例つきで示します。

Claude CodeにFlaskアプリを作らせるなら、完了の判定は flask run を起動してブラウザで確かめる形でなく、test_client を使ったpytestの合格にします。Flaskのtestingドキュメントによれば、テストクライアントはライブサーバーを動かさずにアプリへリクエストを送ります。ポートも常駐プロセスも要らないので、Claude Codeの「実装して、実行して、結果を読んで、直す」という反復にそのまま乗ります。

この記事では、create_app を使う小さなFlaskアプリを例に、fixture、CLAUDE.mdの検証コマンド、Stop hookによる強制の順に設定を組みます。

なぜflask runでなくtest_clientを完了条件にするのか

flask run は終わらないコマンドです。クイックスタートによれば、--debug を付けるとコード変更のたびにサーバーが自動で再読み込みされます。人が画面を見ながら開発するには便利ですが、Claude Codeに「動作確認して」と頼むと、起動したきり戻らないプロセスの扱いが問題になります。

test_client なら、確認は1回のコマンドで終わります。

くらべる

完了条件を2通りで比べる

サーバー経由

flask runを起動して確認

ポートを占有する常駐プロセスが残ります。確かめられるのは、人が叩いたURLの結果だけです。終了コードで成否を判定できません。

テスト経由

test_clientのpytestで確認

コマンドが成否を終了コードで返して終わります。確認の内容がテストファイルに残り、次のセッションでも同じ検証を繰り返せます。

もう一つの利点は、検証がコードとして残ることです。画面で目視した結果はそのセッションで消えますが、テストはリポジトリに残ります。

同じ「検証コマンドを完了条件にする」考え方は、Phoenixのmix precommitの記事にもあります。Pythonのフレームワークなら、Djangoのpython manage.py testやFastAPIの型チェックが並びます。Flaskは検証用の公式コマンドを持たない代わりに、pytestとtest_clientが公式ドキュメントで標準的な組み合わせとして書かれています。

検証対象のアプリとfixtureを用意する

Flaskのtestingドキュメントは、アプリケーションファクトリを使っているなら app fixtureでアプリを作って設定し、そこからテストクライアントとCLIランナーを取り出す形を示しています。fixtureは tests/conftest.py に置きます。

まず、検証対象になる最小のアプリです。以下は説明用に用意した例で、Flaskのドキュメントのコードではありません。

# my_project/__init__.py
from flask import Flask, jsonify, request
 
def create_app(config=None):
    app = Flask(__name__)
    app.config.update(config or {})
    items = []
 
    @app.get("/items")
    def list_items():
        return jsonify(items)
 
    @app.post("/items")
    def add_item():
        name = (request.get_json(silent=True) or {}).get("name", "")
        if not name:
            return jsonify(error="name is required"), 400
        items.append({"id": len(items) + 1, "name": name})
        return jsonify(items[-1]), 201
 
    return app

次にfixtureです。ドキュメントの構成に沿って、TESTING を有効にします。

# tests/conftest.py
import pytest
from my_project import create_app
 
@pytest.fixture()
def app():
    app = create_app({"TESTING": True})
    yield app
    # DBを使う場合は、ここで後始末を書く
 
@pytest.fixture()
def client(app):
    return app.test_client()
 
@pytest.fixture()
def runner(app):
    return app.test_cli_runner()

コンフィグの解説によると、TESTING を有効にすると、例外がアプリのエラーハンドラーで処理されずに伝播します。拡張機能がテストしやすい挙動に切り替わることもあります。この設定がないと、ビューの中の例外が500応答に包まれ、テストの失敗理由が読みにくくなります。

テストを書かせる — リクエストの種類ごとの書き方

テストクライアントには client.get() や client.post() のようにHTTPメソッドに対応するメソッドがあります。query_string、headers、data、json といった引数でリクエストを組み立て、返ってきたレスポンスの response.data や response.json を検査します。

Claude Codeには、この引数の使い分けをテストの形で書かせます。依頼の例です。

GET /items と POST /items のテストを tests/test_items.py に書いてください。
test_client を使い、flask run は起動しないでください。
正常系の201、name が空のときの400、登録後に一覧へ反映されることの3つを確認します。

返ってくるテストは、たとえば次のような形になります(例示であり、実際の出力は環境によって変わります)。

# tests/test_items.py
def test_add_item(client):
    res = client.post("/items", json={"name": "milk"})
    assert res.status_code == 201
    assert res.json["name"] == "milk"
 
def test_add_item_requires_name(client):
    res = client.post("/items", json={})
    assert res.status_code == 400
 
def test_list_reflects_added_item(client):
    client.post("/items", json={"name": "milk"})
    res = client.get("/items")
    assert [i["name"] for i in res.json] == ["milk"]

リクエストの種類ごとの書き方は次のとおりです。

目的書き方補足
フォームを送る書き方data={...} に辞書を渡す補足Content-Type は自動で付く
JSONを送る書き方json={...} に渡す補足レスポンスは response.json で読める
ファイルを送る書き方data の値に "rb" で開いたファイルを渡す補足リクエスト後に自動で閉じられる
リダイレクトを追う書き方follow_redirects=True補足既定では追わない
ログイン状態を作る書き方with client.session_transaction() as session:補足リクエストの前にセッションを書ける

最後の2行は、Claude Codeが外しやすい点です。リダイレクトは既定で追われないため、ログイン後の画面遷移を確かめるテストで、302のまま検査してしまう書き方になりがちです。依頼文に「リダイレクト先の内容を見たいときは follow_redirects=True」と添えておくと、手戻りが減ります。

セッションとコンテキストに依存するテスト

session を読みたいときは、クライアントを with 文で使います。リクエスト後もアプリとリクエストのコンテキストが with ブロックの終わりまで残るためです。

current_app や request を触る関数を単体で検査したいときは、with app.app_context() や with app.test_request_context(...) でコンテキストを直接作ります。test_request_context ではFlaskのディスパッチ処理が走らないため before_request 関数が呼ばれないと説明しています。必要なら app.preprocess_request() を手で呼ぶか、通常のリクエストを送るほうがよいとしています。

CLIコマンドを足したアプリなら、app.test_cli_runner() の invoke() が使えます。flask コマンドと同じ呼び方で、コマンドを分離して実行できます。

CLAUDE.mdに検証コマンドを固定する

テスト基盤ができたら、毎回の依頼文に書かずに済むよう、CLAUDE.mdへ検証の約束事を置きます。CLAUDE.mdは、セッションの開始時に読み込まれる指示を書くファイルです。書き方の全体像はCLAUDE.mdの書き方パターンにまとめています。

# CLAUDE.md
 
## 検証
- 完了の判定は `.venv/bin/python -m pytest -q` の合格で行う
- 動作確認のために `flask run` を起動しない。リクエストを送る確認は tests/ 配下の test_client で書く
- 新しいルートを足したら、正常系と入力不備の2ケースをテストに追加する
- テストが落ちたら、テスト側を弱めずに実装を直す
- `app.config["TESTING"]` は tests/conftest.py の fixture で設定する。本体コードに書かない

「テスト側を弱めずに」の一行は、Claudeがテストを通すためにアサーションを緩める経路を塞ぐためのものです。この一行は運用上の工夫で、ドキュメントに書かれた推奨ではありません。テストを書かせた直後のコミット前に差分を読み、assert が減っていないかを確かめる習慣と併せて使うと確実です。

Pythonの実行は .venv/bin/python -m pytest のように、仮想環境のPythonを直接呼ぶ形にしています。Claude Codeでは仮想環境の有効化が次のコマンドに引き継がれない点が、Djangoの記事のつまずきとして挙がっています。

pytestの探索先は、チュートリアルの pyproject.toml の設定で固定できます。

[tool.pytest.ini_options]
testpaths = ["tests"]
 
[tool.coverage.run]
branch = true
source = ["my_project"]

testpaths を指定すると、pytest を引数なしで呼んでも tests フォルダだけが対象になります。カバレッジを測りたいときは、チュートリアルにあるとおり pip install pytest coverage のあとで coverage run -m pytest を使います。

許可ルールで確認の手間を減らす

pytestは何度も実行するコマンドです。毎回承認プロンプトが出ると反復が止まるので、.claude/settings.json で許可を決めておきます。

{
  "permissions": {
    "allow": [
      "Bash(.venv/bin/python -m pytest *)",
      "Bash(.venv/bin/python -m pytest)"
    ]
  }
}

ワイルドカードを使うBash権限の書き方と、&& でつないだ複合コマンドの扱いはBash権限と複合コマンドで扱っています。flask run を許可リストに入れないでおけば、CLAUDE.mdの約束に加えて、許可の面からも起動の機会が減ります。

Stop hookでpytestの合格を強制する

CLAUDE.mdの指示は、Claudeが読んで従うものです。従わなかった場合に止める仕組みが要るなら、Stop hookを使います。

hooksガイドによると、Stop はClaudeの応答が終わったときに発火するイベントです。終了コード2で終わると動作がブロックされ、標準エラーに書いた理由がClaudeへのフィードバックとして渡されます。Stopイベントでは reason がClaudeに戻り、作業が続きます。

.claude/settings.json に次を加えます。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/pytest-gate.sh",
            "timeout": 300
          }
        ]
      }
    ]
  }
}

呼び出すスクリプトです。

#!/usr/bin/env bash
cd "$CLAUDE_PROJECT_DIR" || exit 0
[ -d tests ] || exit 0
 
# Pythonのソースやテストに変更が無ければ何もしない
if [ -z "$(git status --porcelain -- '*.py' pyproject.toml)" ]; then
  exit 0
fi
 
if out=$(.venv/bin/python -m pytest -q -x 2>&1); then
  exit 0
fi
 
echo "pytest が失敗しました。実装を直して再実行してください。" >&2
echo "$out" | tail -n 40 >&2
exit 2
chmod +x .claude/hooks/pytest-gate.sh

設計の要点は3つです。

  • 変更がないターンでは何も走らせません。質問に答えただけの会話でテストを回さないためです。
  • -x で最初の失敗で止め、出力は末尾40行に絞ります。長い出力でコンテキストを消費しないためです。
  • 成功時は無言で exit 0 します。

このhookの危険は、直らないテストを抱えたままStopのたびにブロックされ、同じ修正を繰り返すことです。hooksガイドは、Stop hookが連続8回ブロックしたときにClaude Codeが判断を上書きすると説明し、入力JSONの stop_hook_active を見て早期終了する書き方も示しています。上限の挙動と環境変数はStop hookのブロック上限に詳しくあります。hooks全般の使い分けはhooksの解説記事も参考になります。

失敗の出方ごとにClaudeへ求めること

pytestが落ちたとき、Claudeに何を求めるかは失敗の型で変わります。

失敗の出方疑う場所依頼文の例
assert 500 == 201疑う場所TESTING が無効でエラーが丸められている依頼文の例「fixtureでTESTINGを有効にして、例外の原文を出して」
302が返りアサーションが外れる疑う場所リダイレクトを追っていない依頼文の例「follow_redirects=Trueを付けて遷移先を検査して」
RuntimeError: Working outside of application context疑う場所コンテキスト外で current_app を触っている依頼文の例「app_contextを作ってから呼ぶテストに直して」
テスト間で状態が混ざる疑う場所モジュール変数などの共有依頼文の例「fixtureのたびにアプリを作り直して、後始末も書いて」

最後の行は、上の例のアプリで実際に起こり得ます。items を create_app の内側で定義したのは、fixtureごとに空の状態で始まるようにするためです。モジュールの直下に置くと、テストの実行順で結果が変わります。

よくあるつまずき

本体のアプリだけを検証して、設定の差を見落とす

テストのfixtureでは TESTING を有効にしますが、実際の起動では無効です。例外がハンドラーで処理される経路は、テストに現れません。エラーページを確かめたいテストでは、TESTING を有効にしたまま、ステータスコードのアサーションで404や500の挙動を検査します。

factoryを使わないアプリでimportの副作用が残る

ファクトリを使わない場合は既にあるappオブジェクトをインポートして設定できると説明しています。ただし、インポート時に副作用のある初期化があると、テスト間で状態を引きずります。Claude Codeに新規で書かせるなら、最初から create_app の形にするよう、CLAUDE.mdで指定しておくのが安全です。

デバッグモードの起動をClaudeが提案してくる

flask --app hello run --debug はCLIドキュメントにある起動方法です。CLAUDE.mdに起動を禁じる行があれば、検証をpytestで行う方向へ誘導できます。画面での最終確認は、人が別のターミナルで行う運用が無理がありません。

まとめ

Flaskの完了条件は、flask run の画面でなく、test_client で書いたpytestの終了コードに置きます。CLAUDE.mdで検証コマンドと「起動しない」の約束を固定し、Stop hookで合格しないと終われない形にすると、Claudeの自己申告に頼らずに済みます。

ただし、テストが通ることは画面が正しいことと同義ではありません。見た目やテンプレートの出来は、人が一度は画面で確かめる前提で、この設定を使ってください。

この記事を共有:XはてブLinkedIn