メインコンテンツへスキップ

Flask でユニットテストを作成する(View編)

·5 分
ひとりアドベントカレンダー2024 Python Flask Pytest

はじめに
#

「OKAZAKI Shogo のひとりアドベントカレンダー2024」の15日目です。 昨日に引き続いて、Flask で作成したアプリに Pytest を用いて自動テストを作成していきます。 今日はビューのテストです。

テスト用クライアントを使ったリクエストの送信
#

まずはとても単純な index の view のテストを書く。

app/views/index.py

 1from flask import Blueprint, render_template
 2
 3index_bp = Blueprint("index", __name__, url_prefix="/")
 4
 5logger = logging.getLogger(__name__)
 6
 7
 8@index_bp.route("/", methods=["GET", "POST"])
 9def index():
10    return render_template("index.html", page_title="TOP")

テスト用クライアントは実際のサーバを走らせずにアプリケーションへのリクエストを作成する。Flask のクライアントは Werkzeug のクライアントを拡張している。

テスト用のクライアントは app.test_client() を呼び出すことで作成可能。この戻り値をテスト関数に渡すことで、リクエストの送受信のテストが可能になる。

テスト用アプリケーションには SERVER_NAME を設定しておく必要がある(設定がないとリクエスト送信時にエラーになる)。

昨日のフィクスチャ作成の流れに則ってテストを以下のように書くことができる:

tests/views/test_index.py

 1import pytest
 2from flask import url_for
 3
 4from app import create_app
 5
 6
 7# Flask アプリケーションのセットアップ
 8@pytest.fixture
 9def app():
10    test_config = {"TESTING": True, "SERVER_NAME": "localhost"}
11    app = create_app(test_config)
12    return app
13
14
15# テスト用のクライアントの設定
16@pytest.fixture
17def client(app):
18    return app.test_client()
19
20
21# index ページのテスト
22def test_indexにアクセスしてトップページが返されること(client):
23    # GET リクエストを "/" に送信
24    response = client.get(url_for("index.index"))
25
26    # ステータスコード 200 を確認
27    assert response.status_code == 200
28
29    # レンダリングされた HTML に "TOP" が含まれていることを確認
30    assert b"TOP" in response.data
31    assert "<h2>ボーイスカウト阪神さくら地区 加盟員向けページ</h2>".encode() in response.data

DB への参照を伴うページのテスト(テスト用 DB の参照なし)
#

例として以下のようなビューのテストを書く:

app/views/files.py

 1from flask import Blueprint, render_template
 2
 3from app.models.file import File
 4
 5files_bp = Blueprint("files", __name__, url_prefix="/files")
 6
 7
 8@files_bp.route("/", methods=["GET", "POST"])
 9def index():
10    files = File.query.all()
11    return render_template("files.html", files=files, page_title="文書館")

DB に対して SELECT 文を発行しているので、File.query.all() の処理をモックして、テスト用のデータを返却するようにする。このテストでは、テスト用の DB は必要ない。

tests/views/test_files.py

 1from unittest.mock import patch
 2
 3import pytest
 4from flask import url_for
 5
 6from app import create_app
 7from app.models.file import File
 8
 9
10@pytest.fixture
11def app():
12    test_config = {"TESTING": True, "SERVER_NAME": "localhost"}
13    app = create_app(test_config)
14    return app
15
16
17@pytest.fixture
18def client(app):
19    # テストクライアントを提供
20    return app.test_client()
21
22
23def test_ファイル一覧に登録されたファイルが表示されること(app, client):
24    # モックデータを設定
25    mock_files = [
26        File(file_id=1, display_name="テスト用ファイル1"),
27        File(file_id=1, display_name="テスト用ファイル2"),
28    ]
29
30    with app.app_context():
31        # File.query.all() をモック
32        with patch("app.models.file.File.query") as mock_query:
33            mock_query.all.return_value = mock_files
34
35            # テスト対象のエンドポイントにリクエスト
36            response = client.get(url_for("files.index"))
37
38            # ステータスコードを検証
39            assert response.status_code == 200
40
41            # レスポンスデータを検証
42            data = response.data.decode("utf-8")
43            assert "文書館" in data
44            assert "テスト用ファイル1" in data
45            assert "テスト用ファイル2" in data

DB にデータが入っていることを前提とするテスト&フォームのデータ送信を伴うテスト
#

例として app/views/login.py のテストを実装する:

 1from flask import Blueprint, flash, redirect, render_template, request, url_for
 2from flask_login import login_user
 3
 4from app.models.manager_user import ManagerUser
 5
 6login_bp = Blueprint("login", __name__, url_prefix="/login")
 7
 8
 9@login_bp.route("/", methods=["GET", "POST"])
10def index():
11    if request.method == "POST":
12        username = request.form.get("username")
13        password = request.form.get("password")
14        # ManagerUserテーブルからusernameに一致するユーザを取得
15        user = ManagerUser.query.filter_by(user_name=username).first()
16        if user is None or not user.check_password(password):
17            flash("無効なユーザー ID かパスワードです。")
18            return redirect(url_for("login.index"))
19        login_user(user)
20        return redirect(url_for("manage_menu.index"))
21    return render_template("login.html", page_title="管理者向けログイン")

テスト用の DB を設定し、テスト前にデータを投入する。今回の場合は、ManagerUser オブジェクトを作成し、 db.session.add() でデータを追加し、 db.session.add() でデータを投入する。

フォームのデータを渡すには、client.post() の data 引数に dict を渡す。Content-Type ヘッダーはmultipart/form-data または application/x-www-form-urlencoded へ自動的に設定される。

 1import pytest
 2from flask import url_for
 3
 4from app import create_app, db
 5from app.models.manager_user import ManagerUser
 6
 7
 8@pytest.fixture
 9def app():
10    # テスト用の Flask アプリケーションを作成
11    test_config = {"TESTING": True, "SERVER_NAME": "localhost", "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:"}
12    app = create_app(test_config)
13
14    with app.app_context():
15        db.create_all()
16        yield app
17        db.drop_all()
18
19
20@pytest.fixture
21def client(app):
22    return app.test_client()
23
24
25def test_GETリクエストを送るとログイン画面が返却されること(client, app):
26    with app.app_context():
27        response = client.get(url_for("login.index"))
28        assert response.status_code == 200
29        assert "管理者向けログイン" in response.data.decode("utf-8")
30
31
32def test_POSTリクエストでユーザー名とパスワードを受け取りログインが成功すること(client, app):
33    with app.app_context():
34        # ユーザーデータを作成とDBへの投入
35        user = ManagerUser(user_name="testuser")
36        user.set_password("password123")
37        db.session.add(user)
38        db.session.commit()
39
40    # ログインのPOSTリクエストを送信
41    response = client.post(
42        url_for("login.index"),
43        data={"username": "testuser", "password": "password123"}
44        # リダイレクトを追跡しない
45        follow_redirects=False, 
46    )
47
48    # リダイレクト先を確認
49    assert response.status_code == 302
50    # 相対パスで比較
51    assert response.location == url_for("manage_menu.index", _external=False)
52
53
54def test_POSTリクエストで無効なパスワードを受け取るとログインが失敗すること(client, app):
55    with app.app_context():
56        # ユーザーデータを作成
57        user = ManagerUser(user_name="testuser")
58        user.set_password("password123")
59        db.session.add(user)
60        db.session.commit()
61
62    response = client.post(
63        url_for("login.index"),
64        data={"username": "testuser", "password": "wrongpassword"},
65        follow_redirects=True,
66    )
67
68    assert response.status_code == 200
69    assert "無効なユーザー ID かパスワードです。" in response.data.decode("utf-8")

もし、data 引数に渡された dict に含まれる値が “rb” モード file オブジェクトの場合、アップロードされたファイルとして扱われる。検知されたファイル名とcontent typeを変更するには、file: (open(filename, "rb"), filename, content_type) のように、dict 内のアイテムで該当する tuple を値に設定する。file オブジェクトは、通常の with open() as f: のパターンを使う必要が無いように、リクエストを作成した後に閉じられる。

ファイルオブジェクトを POST で送信するテストを行う例:

 1from pathlib import Path
 2
 3# get the resources folder in the tests folder
 4resources = Path(__file__).parent / "resources"
 5
 6def test_edit_user(client):
 7    response = client.post("/user/2/edit", data={
 8        "name": "Flask",
 9        "theme": "dark",
10        "picture": (resources / "picture.png").open("rb"),
11    })
12    assert response.status_code == 200

ログインが必要な画面の実装例:
#

以下のように @login_required がついているエンドポイントのテストを実装する。

app/views/manage_menu.py

 1from flask import Blueprint, render_template
 2from flask_login import login_required
 3
 4manage_menu_bp = Blueprint("manage_menu", __name__, url_prefix="/manage-menu")
 5
 6
 7@manage_menu_bp.route("/", methods=["GET"])
 8@login_required
 9def index():
10    return render_template("manage-menu.html", page_title="管理者向けメニュー")

ログイン画面ではユーザーID(username)とパスワード(password)が POST で送信される。テストコード内では、ログインのエンドポイントにアクセスするためのログインヘルパーを利用する。

Flask のコンテキストの変数(主に session)へアクセスするには with 文の中でクライアントを使う。app と request のコンテキストは、with ブロックを終了するまで、リクエストを作成した後も残る。

session には、ログイン処理のところで説明した通り、ユーザー ID にあたるものが str 型で格納されている。

 1import pytest
 2from werkzeug.security import generate_password_hash
 3from flask import session
 4
 5from app import create_app, db
 6from app.models.manager_user import ManagerUser
 7
 8
 9@pytest.fixture
10def app():
11    """テスト用の Flask アプリケーションを作成"""
12    test_config = {
13        "TESTING": True,
14        "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
15        "SQLALCHEMY_TRACK_MODIFICATIONS": False,
16        "SECRET_KEY": "test_secret",
17    }
18    app = create_app(test_config)
19
20    with app.app_context():
21        db.create_all()
22        # テスト用のユーザーを追加
23        test_user = ManagerUser(
24            user_name="test_user",
25            password=generate_password_hash("password123"),
26            service="test_service",
27        )
28        db.session.add(test_user)
29        db.session.commit()
30        yield app
31        db.drop_all()
32
33
34@pytest.fixture
35def client(app):
36    """テスト用クライアントを作成"""
37    return app.test_client()
38
39
40@pytest.fixture
41def login(client):
42    """テスト用ログインヘルパー"""
43
44    def do_login(username, password):
45        return client.post(
46            "/login",
47            data={"username": username, "password": password},
48            follow_redirects=True,
49        )
50
51    return do_login
52
53
54def test_ログインせずに保護されたエンドポイントにアクセスすると401エラー(client):
55    response = client.get("/manage-menu/")
56    assert response.status_code == 401
57
58
59def test_ログイン後に保護されたエンドポイントにアクセスすると管理者メニューに遷移すること(client, login):
60    with client:
61        response = login("test_user", "password123")
62        assert response.status_code == 200  # ログイン成功
63        assert session["_user_id"] == '1'
64
65        response = client.get("/manage-menu/")
66        assert response.status_code == 200  # 正常にアクセス可能
67        assert "管理者向けメニュー".encode() in response.data  # ページの内容を検証
68        assert session["_user_id"] == '1'

参考資料
#