メインコンテンツへスキップ
友田 陽大
Flask 本番運用
Python
Flask
pytest
テスト
バックエンド
アーキテクチャ設計

Flaskのテスト実践ガイド:pytest fixtures・test_client・test_cli_runnerで本番品質の自動テストを書く

Flask 3.1系のテストを本番品質で書く完全ガイド。アプリケーションファクトリとtest_clientがなぜテストを容易にするか、TESTING=Trueの効果、app/client/runnerのpytest fixture三点セット、response.data/json/text・follow_redirects・session_transactionによるリクエスト検証、test_request_contextとapp_context、test_cli_runnerでのCLIテストまで、公式ドキュメントに忠実な実コードで解説します。

公開日
読了時間
22分
著者
友田 陽大
シェア
目次

導入:Flask のテストは「設計のご褒美」である

テストが書きづらいバックエンドには、ほぼ例外なく設計の歪みがあります。グローバルな app をモジュールトップに置いていて設定を差し替えられない、DB 接続がハードコードされていてテスト用 DB に向けられない、ビュー関数にビジネスロジックが癒着していて単体で叩けない——こうした「テストのしづらさ」は、そのまま「変更のしづらさ」です。

逆に言えば、テストが素直に書けるかどうかは、その Flask アプリが本番品質かどうかのリトマス試験紙になります。そして Flask は、ピラー記事で解説したアプリケーションファクトリ(create_app)を採用していれば、テストが驚くほど書きやすいフレームワークです。実サーバーを立てずにリクエストを往復できる test_client()、テスト専用設定のアプリを毎回作れるファクトリ、そして pytest の fixture——この 3 つが噛み合うと、「境界の契約をテストで固定する」設計が最小の労力で実現します。

本記事は、ピラーの §9 を専用記事として深掘りするスポークです。筆者は、経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python / Flask / SQLAlchemy / PostgreSQL で設計・実装し、API Gateway → ALB → ECS(Fargate) 上で本番運用してきました。ここで示すのは、その実戦でリグレッションを防ぎ続けたテストの型です。

💡 この記事で扱うバージョンFlask 3.1 系(最小 Python 3.9)と pytest を前提とします。コードはすべて公式ドキュメント(flask.palletsprojects.com)のテストガイドおよびチュートリアルのパターンに基づきます。E2E(ブラウザを介したフロント込みの動線テスト)は対象外で、その層は対になる Playwright E2E テスト設計ガイド に分けています。本記事は Flask 側のサーバーテストに集中します。


1. なぜ Flask のテストは容易なのか:test_clientTESTING=True

1.1 test_client():サーバーを立てずにリクエストを往復する

Flask アプリは WSGI アプリケーションであり、Werkzeug の上に乗っています。この性質を利用して、Flask は 実際の HTTP サーバーを起動せずにアプリへリクエストを送れるテストクライアントを提供します。

client = app.test_client()
response = client.get("/health")

test_client() は、ネットワークも開発サーバーも介さずに、WSGI レベルで直接アプリのディスパッチを駆動します。だから速く、ポート競合もなく、CI でも安定します。use_cookies=True(既定)なら Cookie をリクエスト間で保持するので、ログイン状態を引き継いだ複数リクエストのフローも書けます。

1.2 TESTING=True が何を変えるのか

テストを書く前に、必ず app.testing = True(または設定 TESTING=True)を立てます。公式の言葉を借りれば「TESTING は Flask にアプリがテストモードであることを伝え、Flask はいくつかの内部挙動を変えてテストを容易にする」。具体的に重要なのは次の点です。

  • 例外を握りつぶさず伝播させる。通常、ビュー内で発生した例外は Flask がエラーハンドラで捕まえて 500 レスポンスに変換します。テストモードでは、これがテスト側に例外として伝播するため、「なぜか 500 が返る」をスタックトレースで追えます。これがないと、アサーションが落ちた本当の原因が見えません。
def test_config():
    # ファクトリにTESTINGを渡さなければtestingはFalse
    assert not create_app().testing
    # 渡せばTrueになる——設定が効いていることをまず固定する
    assert create_app({"TESTING": True}).testing

⚠️ TESTING を立て忘れる罠TESTING=True を設定し忘れると、ビューで起きた本物のバグ(属性エラー・型エラー)が 500 レスポンスに化けてしまい、テストは「ステータスが 200 ではない」としか教えてくれません。デバッグに無駄な時間を溶かす典型です。fixture でアプリを作る時点で必ず TESTING=True を渡すことを、テスト設計の最初の規律にしてください。


2. fixture 三点セット:app / client / runner

2.1 正準形:名前で注入される 3 つの fixture

pytest は、テスト関数の引数名と一致する名前の fixture を探して自動注入します。Flask のテストでは、この仕組みに乗せた app / client / runner の三点セットが正準です。これを tests/conftest.py に置くと、配下のすべてのテストファイルから引数名だけで使えます。

# tests/conftest.py
import pytest

from my_project import create_app


@pytest.fixture()
def app():
    app = create_app()
    app.config.update({"TESTING": True})

    # ここに setup 処理(DB作成など)を書ける
    yield app
    # yield の後ろが teardown(後始末)。ここに破棄処理を書く


@pytest.fixture()
def client(app):
    return app.test_client()


@pytest.fixture()
def runner(app):
    return app.test_cli_runner()

それぞれの役割を整理します。

fixture生成物何をテストするか
appcreate_app({"TESTING": True}) で作ったアプリアプリそのもの・設定・app_context を要する DB 層
clientapp.test_client()HTTP リクエスト/レスポンスの往復(ビュー・ルーティング)
runnerapp.test_cli_runner()@app.cli.command で定義した CLI コマンド

clientrunnerapp を引数に取っている点に注目してください。pytest は「client を使うテストには、まず app fixture を解決してから渡す」という依存解決を自動でやってくれます。テストごとに新鮮な app が作られ、テスト間で状態が漏れません。

💡 なぜファクトリでないとこれが書けないのか:fixture の create_app({"TESTING": True}) という一行こそが、アプリケーションファクトリの最大の配当です。グローバル app をモジュールトップに置く設計では、import 時にアプリが確定してしまい、テスト用設定を差し込む隙がありません。「テスト容易性」は将来の要件ではなく現在の要件であり、ファクトリはそれを満たすための分離です。詳細は 大規模構成ガイド を参照してください。

2.2 一時 DB 版:setup と teardown を fixture に閉じ込める

実アプリのテストは DB を伴います。公式チュートリアルの conftest は、テストごとに一時ファイルの SQLite を作り、テスト後に確実に消すパターンを示しています。yield の前が setup、後ろが teardown という pytest fixture の構造が、リソースのライフサイクルをきれいに閉じ込めます。

import os
import tempfile

import pytest

from flaskr import create_app
from flaskr.db import get_db, init_db

# テスト用の初期データSQLをあらかじめ読み込んでおく
with open(os.path.join(os.path.dirname(__file__), "data.sql"), "rb") as f:
    _data_sql = f.read().decode("utf8")


@pytest.fixture
def app():
    # 一時ファイルを作り、そのパスをDATABASE設定に渡す
    db_fd, db_path = tempfile.mkstemp()

    app = create_app({"TESTING": True, "DATABASE": db_path})

    with app.app_context():
        init_db()                                  # スキーマを作る
        get_db().executescript(_data_sql)          # 初期データを投入

    yield app

    # teardown:一時DBを閉じて削除する。テスト間で状態を持ち越さない
    os.close(db_fd)
    os.unlink(db_path)

この fixture の with app.app_context(): は重要です。init_db()get_db()current_app を参照するため、アプリケーションコンテキストの中でないと RuntimeError: Working outside of application context で落ちます(コンテキストの詳細は コンテキスト徹底解説 を参照)。fixture でコンテキストを push しておくことで、DB 初期化を安全に実行できます。

💡 fixture のスコープと「クリーンさ」のトレードオフ:上の fixture は既定の function スコープで、テスト関数ごとに DB を作り直すため最もクリーンですが、件数が増えると遅くなります。高速化したい場合は @pytest.fixture(scope="session") で DB を作る回数を減らし、各テストはトランザクションをロールバックして隔離する、という設計もあります。ただし「速さ」のために「テスト間の独立性」を犠牲にすると、テストの順序依存という最悪のフレークを生みます。まずは function スコープで正しさを担保し、遅さが実測で問題になってから最適化してください——推測で最適化しないのが鉄則です。


3. リクエストテスト:response.data / json / text を使い分ける

3.1 GET と本文の検証

client.get(path) がレスポンスオブジェクトを返します。本文は、期待する形式に応じて 3 つのプロパティを使い分けます。

def test_hello(client):
    response = client.get("/hello")
    # response.data は bytes。バイト列リテラル(b"...")と比較する
    assert response.data == b"Hello, World!"
アクセサ用途
response.databytes生のレスポンスボディ(バイト列)
response.jsondict / listJSON レスポンスをパースした Python オブジェクト
response.textstrテキストとしてデコードした本文(get_data(as_text=True) と同義)
response.status_codeintステータスコード
response.headersdict 風レスポンスヘッダ(Location 等)

REST API のテストでは response.json が主役です。jsonify で返したレスポンスをパース済みの dict として受け取れるため、本文の構造をそのまま辞書比較できます。

def test_health_returns_json(client):
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json == {"status": "ok"}      # パース済みdictを直接比較

3.2 POST・ヘッダ・Location の検証

POST はフォーム送信なら data=、JSON ボディなら json= で渡します。リダイレクトを返すエンドポイントでは、ステータスと Location ヘッダを検証します。

def test_register(client, app):
    # GETでフォーム画面が出ることを確認
    assert client.get("/auth/register").status_code == 200

    # POSTで登録 → 成功するとログイン画面へリダイレクトする
    response = client.post(
        "/auth/register", data={"username": "a", "password": "a"}
    )
    # リダイレクト先はLocationヘッダで検証する
    assert response.headers["Location"] == "/auth/login"

    # 副作用(DBに行が作られたか)はapp_contextの中で直接確認する
    with app.app_context():
        assert (
            get_db()
            .execute("SELECT * FROM user WHERE username = 'a'")
            .fetchone()
            is not None
        )

このテストが示す本質は、「HTTP の応答(Location)」と「副作用(DB の状態)」を両方検証することです。リダイレクト先が正しくても DB に書けていなければ機能していませんし、その逆もまた然り。client でリクエストを往復しつつ、app.app_context() で DB を直接覗くこの組み合わせが、登録系エンドポイントのテストの型です。

3.3 follow_redirects:リダイレクトを追う

リダイレクト先まで含めて検証したいときは、follow_redirects=True を渡すと最終ページまで追跡します。途中の遷移は response.history に、最終的に到達した URL は response.request.path に入ります。

def test_logout_redirects_to_index(client):
    response = client.get("/logout", follow_redirects=True)
    # 1回リダイレクトが起きたことを確認
    assert len(response.history) == 1
    # 最終的にトップへ着地したことを確認
    assert response.request.path == "/"

💡 Location 検証か follow_redirects:「どこへ飛ばすか」だけを契約として固定したいなら follow_redirects を使わず、response.headers["Location"] を直接アサートするのが軽量で意図も明確です(3.2 の例)。一方、「飛んだ先のページが正しく描画されるか」まで見たいなら follow_redirects=True で追跡します。両者は検証している契約が違うので、テストの意図に合わせて選んでください。


4. セッションのテスト:読むときと仕込むときで道具が違う

ログインを伴うアプリでは、session の検証が避けて通れません。Flask は「リクエスト後にセッションを読む」場合と「リクエスト前にセッションを仕込む」場合とで、別々の道具を用意しています。ここを混同すると RuntimeError で詰まります。

4.1 読む:with client: でリクエスト後に session / g を覗く

通常、sessiong はリクエストの外では触れません(コンテキストが無いため)。with client: ブロックの中でリクエストを送ると、そのリクエストのコンテキストがブロック終了まで保持され、リクエスト後に session を読めます。

from flask import session


def test_access_session(client):
    with client:
        client.post("/auth/login", data={"username": "flask"})
        # ログイン直後、サーバーがセッションに書いた値を検証できる
        assert session["user_id"] == 1
    # with ブロックを抜けると session はもうアクセスできない

ログイン処理が session["user_id"] を正しくセットしたか——というサーバー内部の副作用を、レスポンス本文を介さず直接確認できるのがこの手法の価値です。g に積んだ値も同様に覗けます。

4.2 仕込む:client.session_transaction() でリクエスト前にセッションを書く

逆に、「すでにログイン済みのユーザー」を前提としたエンドポイント(例:/users/me)をテストしたいときは、リクエストの前にセッションを仕込みますclient.session_transaction() のブロック内でセッションを書くと、ブロック終了時に署名付き Cookie として保存され、以降のリクエストに引き継がれます。

def test_users_me_with_logged_in_user(client):
    # ログインフローを毎回叩く代わりに、セッションを直接仕込む
    with client.session_transaction() as session:
        session["user_id"] = 1

    response = client.get("/users/me")
    assert response.status_code == 200
    assert response.json["id"] == 1

これはログインのショートカットとして極めて有用です。認証が必要な 20 個のエンドポイントをテストするのに、毎回ログイン POST を往復させるのは遅く・冗長です。session_transaction() でセッションを直接組み立てれば、「認証済み」という前提を 3 行で用意できます。

4.3 再利用:認証済み client を fixture にする

このパターンは fixture に切り出すと、認証テスト全体が劇的に読みやすくなります。

# tests/conftest.py(追加)
@pytest.fixture()
def auth_client(client):
    """user_id=1 でログイン済みの client を返す。"""
    with client.session_transaction() as session:
        session["user_id"] = 1
    return client
# 認証が要るエンドポイントは auth_client を引数に取るだけ
def test_dashboard_requires_auth(client):
    # 未ログインは弾かれる(リダイレクトや401)
    assert client.get("/dashboard").status_code in (302, 401)


def test_dashboard_shows_for_authed_user(auth_client):
    # ログイン済み前提でビジネスロジックを検証する
    assert auth_client.get("/dashboard").status_code == 200

「未認証は弾く・認証済みは通す」というアクセス制御の契約が、fixture を切り替えるだけで宣言的に書けます。これは認可(Authorization)のリグレッションを防ぐ、本番品質のテストの要です。


5. test_request_contextrequest を読む関数を単体テストする

ビュー関数の中から呼ばれるヘルパー関数request(フォーム値・クエリ・JSON)を直接参照していることはよくあります。こうした関数を、HTTP の完全なディスパッチを介さずに単体テストしたいときに使うのが app.test_request_context() です。

test_request_context() は Werkzeug の EnvironBuilder の引数(path / method / data / json / query_string / headers …)を受け取り、リクエストコンテキストだけを擬似的に作ってそのブロック内で request を使えるようにします。

def test_validate_user_edit(app):
    # /user/2/edit に空のnameをPOSTした状況を擬似的に作る
    with app.test_request_context(
        "/user/2/edit", method="POST", data={"name": ""}
    ):
        # validate_edit_user() は内部で request.form を読む
        messages = validate_edit_user()

    assert messages["name"][0] == "Name cannot be empty."

ビューを丸ごと叩く(client.post)よりも、検証ロジックだけを狙い撃ちで・高速にテストできます。境界バリデーションの単体テストとして相性が良く、原因の切り分けも容易です(境界をスキーマで設計する考え方は marshmallow × Flask × SQLAlchemy の境界設計ガイド と一貫します——境界の契約をテストで固定する、という同じ思想です)。

⚠️ before_request は呼ばれないtest_request_context()ディスパッチコードを実行しないため、@app.before_request で登録した前処理(認証チェック・g への値の積み込みなど)は走りません。テスト対象の関数がそれらの前提に依存している場合は、コンテキスト内で明示的に app.preprocess_request() を呼んでください。

def test_handler_that_depends_on_before_request(app):
    with app.test_request_context("/orders", json={"qty": 3}):
        app.preprocess_request()   # before_request を手動で起動する
        result = handle_create_order()
    assert result["qty"] == 3

client 経由のテストでは before_request は当然走るので、この注意は test_request_context を使う単体テスト特有のものです。完全なリクエストライフサイクルを再現したいなら client を、ロジックだけを狙い撃ちしたいなら test_request_context を選ぶ、という使い分けになります。


6. app_context:リクエストに紐づかない DB 層をテストする

request を一切使わない、純粋なデータアクセス層get_db() や ORM のクエリ関数)をテストしたいことがあります。これらは current_app を参照するのでアプリケーションコンテキストが必要ですが、リクエストコンテキストは不要です。そのための道具が app.app_context() です。

def test_get_db_returns_same_connection(app):
    # リクエストは無いが、current_app が要るので app_context を push
    with app.app_context():
        db = get_db()
        # 同一コンテキスト内では同じ接続が再利用される(g にキャッシュ)
        assert db is get_db()


def test_close_db_after_context(app):
    with app.app_context():
        db = get_db()

    # コンテキストを抜けると teardown_appcontext で接続が閉じられる
    import pytest
    with pytest.raises(Exception):
        db.execute("SELECT 1")   # 閉じた接続を使うと例外になる

test_request_context(リクエスト+アプリ両方のコンテキスト)と app_context(アプリのコンテキストのみ)の使い分けを整理します。

道具押し込まれるコンテキストrequest が使えるか主な用途
client.get(...)リクエスト+アプリ(フルディスパッチ)使えるビュー・ルーティング・E2E 的な往復
app.test_request_context()リクエスト+アプリ(ディスパッチなし)使えるrequest を読むヘルパー関数の単体テスト
app.app_context()アプリのみ使えないcurrent_app だけ要る DB 層・CLI 的処理

「テスト対象が何のコンテキストを必要とするか」で道具を選ぶ——これが Flask テストの設計判断の核です。


7. CLI コマンドのテスト:test_cli_runner + monkeypatch

Flask アプリには、@app.cli.command で定義する管理コマンド(DB 初期化・データ移行・バッチ)が付き物です。これらも app.test_cli_runner()(Click の CliRunner を Flask 向けに拡張したもの)でテストできます。本番の DB 初期化スクリプトやデータ移行コマンドにテストが無いのは、運用事故の温床です。

7.1 出力を検証する

import click


@app.cli.command("hello")
@click.option("--name", default="World")
def hello_command(name):
    click.echo(f"Hello, {name}!")
def test_hello_command(runner):
    # 引数なしで呼ぶ → デフォルトの "World"
    result = runner.invoke(args="hello")
    assert "World" in result.output

    # オプションを渡す → その値が出力に出る
    result = runner.invoke(args=["hello", "--name", "Flask"])
    assert "Flask" in result.output

runner.invoke() の戻り値の result.output に標準出力が文字列で入るので、click.echo した内容をそのままアサートできます。

7.2 monkeypatch:副作用の重い処理を差し替える

init-db のような重い副作用を持つコマンドは、本物の DB 初期化を走らせずに「正しい関数が呼ばれ、正しいメッセージを出すか」だけを検証したいことがあります。pytest の monkeypatch で内部関数を差し替え、呼び出しを記録します。

def test_init_db_command(runner, monkeypatch):
    # 本物の init_db を呼んだかどうかを記録するだけのスタブ
    class Recorder:
        called = False

    def fake_init_db():
        Recorder.called = True

    # init_db を fake に差し替える(本物のDB初期化は走らない)
    monkeypatch.setattr("flaskr.db.init_db", fake_init_db)

    result = runner.invoke(args=["init-db"])

    assert "Initialized" in result.output   # ユーザー向けメッセージが出る
    assert Recorder.called                  # 実際に init_db が呼ばれた

ここでのポイントは、「コマンドの責務(ユーザーに結果を伝える・正しい処理関数を起動する)」だけをテストし、その先の DB 初期化の中身は別途テストするという関心の分離です。コマンドのテストで本物の DB を毎回作り直す必要はありません。monkeypatch は関数のスコープが終わると自動で元に戻すので、差し替えがテスト間に漏れる心配もありません。


8. 本番テスト戦略:API エンドポイントの正常系・異常系を固定する

部品が揃ったので、本番で実際に書くテストを組み上げます。題材は「JSON を受け取り、検証して保存し、201 を返す」典型的な API です。テストの目的は、その契約——「正しい入力は 201、不正な入力はエラー、未認証は弾く」——を固定することです。

# tests/test_orders.py
def test_create_order_returns_201(auth_client):
    """正常系:妥当な入力で 201 と作成済みリソースが返る。"""
    response = auth_client.post(
        "/api/orders",
        json={"product_id": 10, "quantity": 3},
    )
    assert response.status_code == 201
    body = response.json
    assert body["quantity"] == 3
    assert "id" in body                       # サーバーが採番したidが返る
    assert "internal_cost" not in body        # 内部項目は出力に漏れない


def test_create_order_rejects_invalid_quantity(auth_client):
    """異常系:負の数量は 400/422 で弾かれる。"""
    response = auth_client.post(
        "/api/orders",
        json={"product_id": 10, "quantity": -1},
    )
    assert response.status_code in (400, 422)
    # フロントがどの欄にエラーを出すか判定できるよう、フィールド名を返す
    assert "quantity" in response.json["errors"]


def test_create_order_requires_auth(client):
    """認可:未認証は本処理に到達せず弾かれる。"""
    response = client.post(
        "/api/orders",
        json={"product_id": 10, "quantity": 3},
    )
    assert response.status_code in (302, 401)


def test_get_missing_order_returns_404(auth_client):
    """存在しないリソースは 404。"""
    assert auth_client.get("/api/orders/99999").status_code == 404

この 4 本が押さえているのは、正常系・入力検証の異常系・認可・リソース不在という、API の境界で起きる事故のほぼ全パターンです。auth_client fixture(§4.3)のおかげで、認証済み前提のテストが宣言的に書けている点にも注目してください。

💡 境界の契約をテストで固定する——これは marshmallow × Flask × SQLAlchemy の REST API ガイド で繰り返し強調した思想と完全に一致します。あちらは「load() で入口を、dump() で出口を、スキーマの宣言で守る」という設計を、こちらは「その境界の振る舞いを test_client で往復検証して固定する」というテストを担います。宣言(スキーマ)とテスト(契約の固定)の二重で境界を守る——これが本番品質の API の条件です。エラーレスポンスの整形や 422 の返し方そのものは エラー処理・可観測性ガイド で深掘りしています。

8.1 カバレッジと CI:テストを「常に緑」に保つ

テストは書いて終わりではなく、CI で全コミットに対して走り続けて初めてリグレッション防止の砦になります。

# coverage 付きで実行(pytest-cov)
pytest --cov=src/myapp --cov-report=term-missing

# CI では失敗時に分かりやすく
pytest -q --maxfail=1

カバレッジは目標ではなく診断として使います。100% を盲目的に追うと、意味の薄いテストで数字を埋める本末転倒に陥ります。重要なのは「境界(入口・出口・認可・エラー)が網羅されているか」であって、行カバレッジの数字そのものではありません。--cov-report=term-missing で「テストされていない行」を見て、それが境界やエラーパスなら埋める、という使い方が健全です。


9. テスト DB の選択:SQLite か、本物の PostgreSQL か

最後に、現実的かつ意見の分かれる論点を正直に扱います。テストをどの DB エンジンに対して走らせるかです。

公式チュートリアルは、§2.2 で見たとおり一時ファイルの SQLiteを使います。これは速く、追加のインフラが要らず、CI でも手軽です。小〜中規模アプリや、SQL が標準的な範囲に収まるアプリでは、これで十分に価値があります。

しかし、本番が PostgreSQL の場合、SQLite でテストするとエンジン差に起因する見落としが起きます。

観点SQLite(一時ファイル/インメモリ)本番と同じ PostgreSQL
速度・手軽さ◎ 追加インフラ不要・高速△ コンテナ等の用意が要る
型・制約の挙動△ 型が緩く、一部制約が効かない◎ 本番と一致
並行制御・トランザクション分離△ 挙動が異なる◎ 本番と一致
Postgres 固有機能(JSONB・配列・ON CONFLICT・部分インデックス)✗ 非対応/差異あり◎ そのまま検証できる

筆者の実戦での結論は、**「層で使い分ける」**です。

  • 検証ロジック・スキーマ単体・ビューの分岐など、DB エンジンに依存しない大多数のテストは SQLite で速く回す。
  • マイグレーション・Postgres 固有機能・トランザクション境界・一意制約の競合に関わるテストは、CI で本番と同じ PostgreSQL コンテナに対して走らせる。

⚠️ 「全部 SQLite で済ませる」過信の罠:ローカルで SQLite を使った全テストが緑でも、本番 PostgreSQL では一意制約の競合や JSONB クエリで落ちる——という事故は珍しくありません。「本番と同じエンジンに対する統合テストを CI に最低限持つ」ことが、この差を埋める保険です。速さ(SQLite)と本番一致(Postgres)はトレードオフであり、片方に全振りせず、テストの性質で配分するのが現実解です。永続化層の挙動そのものは SQLAlchemy 2.0 実践ガイド を参照してください。

CI で Postgres を使う最小例は次のようになります(GitHub Actions のサービスコンテナ)。

# .github/workflows/test.yml(抜粋)
services:
  postgres:
    image: postgres:17
    env:
      POSTGRES_PASSWORD: postgres
    ports:
      - 5432:5432
    options: >-
      --health-cmd pg_isready --health-interval 10s
      --health-timeout 5s --health-retries 5
# テスト時はこのDBへ向ける(秘密はコードに書かない)
export FLASK_SQLALCHEMY_DATABASE_URI='postgresql+psycopg://postgres:postgres@localhost:5432/test'
pytest

これで、ローカルは SQLite で速く、CI は本番と同じ Postgres で堅く、という二層構えが完成します。


まとめ:テスト容易性は、設計の質そのものである

Flask のテストが容易なのは偶然ではなく、アプリケーションファクトリ・test_client・pytest fixture という 3 つの設計が噛み合った結果です。本記事の要点を再掲します。

  1. TESTING=True を必ず立て、例外をテスト側に伝播させる。立て忘れるとバグが 500 に化けてデバッグが地獄になる。
  2. app / client / runner の fixture 三点セットを conftest.py に置く。pytest は引数名で fixture を注入し、yield 後が後始末(一時 DB の破棄など)を担う。
  3. リクエストは response.data(bytes)・response.jsonresponse.text で本文を、status_code とヘッダで Location を検証し、必要なら follow_redirects + response.history で追う。
  4. セッションは 読むとき with client:、仕込むとき session_transaction()。後者は認証済み client の fixture 化でログインのショートカットになる。
  5. test_request_contextrequest を読む関数の単体テストに(before_request は呼ばれない/必要なら preprocess_request)、app_context は DB 層のテストに。
  6. CLI は test_cli_runner + monkeypatch で、出力と「正しい処理が呼ばれたか」を検証する。
  7. 境界の契約(正常系・検証エラー・認可・404)をテストで固定し、CI で常に緑に保つ。テスト DB は SQLite の速さと Postgres の本番一致を層で使い分ける

「動く Flask アプリ」と「10 年運用できる Flask アプリ」を分けるのは、機能の数ではなく、境界の契約がどれだけテストで固定されているかです。そして、そのテストが素直に書けること自体が、アプリケーションファクトリで設計した配当です。本記事のテスト群は、ブラウザを介したフロント込みの動線を検証する Playwright E2E テスト設計ガイド と組み合わせることで、サーバー単体からユーザー動線までを一気通貫で守るテストピラミッドになります。各設計対象の全体地図は Flask 本番運用ガイド に戻って確認してください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る