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

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

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

はじめに
#

「OKAZAKI Shogo のひとりアドベントカレンダー2024」の14日目です。 今更ながら、Flask で作成したアプリに Pytest を用いて自動テストを作成していきます。 色々手こずってしまったので、今日はひとまずモデルのテストのみです。

Pytest のフィクスチャ
#

Pytest を用いて Flask のテストを作成するにあたって、 Pytest の「フィクスチャ」を多用することになるので、概要を確認する。

ソフトウェアテストにおける「フィクスチャ」とは、テスト対象のソフトウェアのコンポーネントの実際の使い方を模倣できるように、固定された環境設定をシミュレートするものである。Pytest においては、テスト関数の依存として提供できる、 setup/teardown 内の再利用可能なパーツがフィクスチャである。

Pytest を使うには、いつもの通り、パッケージを導入すれば良い:

1poetry add pytest --group dev

Pytest のフィクスチャの作成
#

以下のように関数に @pytest.fixture デコレータをつけるとフィクスチャを作成できる。

 1import pytest
 2
 3
 4@pytest.fixture
 5def dependency():
 6    return "fixture value"
 7
 8
 9def test_one(dependency):
10    assert dependency == "fixture value"

フィクスチャ関数の戻り値は、テスト関数に入力引数として渡すことができる。その際、フィクスチャ関数名をそのまま記述すればいよい。

以下のようなジェネレータ構文を使うと、同じフィクスチャ関数内で setup と teardown の両方を提供できる。

1import pytest
2
3
4@pytest.fixture
5def dependency():
6    # ここに setup のための処理を書く
7    yield "fixture value"
8    # ここに teardown のための処理を書く

フィクスチャのスコープ
#

フィクスチャの値のライフタイムを決定するスコープとして以下の5つが利用可能。

  • “function” スコープ:デフォルトのスコープで、各テストの実行ごとに一度だけ実行され、そのあと破棄される。
  • “class” スコープ:xUnit スタイルで書かれたテストメソッドに使用できる。テストクラス内の最後のテストの後に破棄される。
  • “module” スコープ:テストモジュール内の最後のテストの後に破棄される。
  • “package” スコープ:テストパッケージの最後のテストの後に破棄される。
  • “session” スコープ:一種のグローバルスコープで、ランナーの実行全体を通じて生き続け、最後のテストの後に破棄される。

Flask におけるフィクスチャの利用
#

Flask においては、テスト用の app のフィクスチャを用意し、各テストケースに渡すことができる。

今回作成しているアプリのように create_app() 関数を呼び出して app を生成する場合は以下のような感じでテスト用のクライアントを作成する。今の実装のままだと、元のアプリケーションが DB 接続を持っている場合、テスト用の設定が上書きされずに元の設定が利用されてしまう可能性があるため、テスト時にテスト用の設定を上書きするようにする。app/__init__.py を以下のように書きあけ、引数でテスト用設定を受け取れるようにする。

 1def create_app(test_config=None):
 2    # appの設定
 3    app = Flask(__name__, instance_relative_config=True)
 4
 5    # configファイルを読み込む
 6    config_path = os.path.join("config", "dev.py")
 7    app.config.from_pyfile(config_path)
 8
 9    # dev.py からログ設定を読み込んで適用
10    dictConfig(app.config["LOGGING_CONFIG"])
11
12    # テスト用設定が渡された場合に上書き
13    if test_config:
14        app.config.update(test_config)
15    
16    # 以下省略

テストコードでは以下のようにする。

 1import pytest
 2
 3from app import create_app
 4
 5
 6@pytest.fixture
 7def app():
 8    """テスト用のFlaskアプリケーションを設定"""
 9    test_config = {
10        'TESTING': True
11    }
12    app = create_app(test_config)
13    # setup 処理
14    yield app
15    # teardown 処理

あとは、テストで app を利用すれば良い。

Flask におけるテストの配置
#

以下のようにすれば良い。

  • テスト用プログラムは tests ディレクトリに入れる
  • テストは tests_ から始める関数。
  • test_ から始めるモジュールの中に入れる。
  • テストクラスは Test で始まる。

以下は構成例:

 1.
 2|-- Makefile
 3|-- app
 4|   |-- __init__.py
 5|   |-- app.py
 6|   |-- index.cgi
 7|   |-- models
 8|   |   |-- file.py
 9|   |   `-- manager_user.py
10|   |-- templates
11|   `-- views
12|       |-- files.py
13|       |-- index.py
14|       `-- login.py
15|-- instance
16|   |-- config
17|       `-- dev.py
18|-- poetry.lock
19|-- pyproject.toml
20|-- pytest.ini
21`-- tests
22    |-- models
23    |   |-- test_file.py
24    |   `-- test_manager_user.py
25    `-- views
26        |-- test_files.py
27        |-- test_index.py
28        `-- test_login.py

モデルのテスト:DB 接続を伴うテストを行う
#

テスト用のデータを挿入してテストを実施したい場合があるが、本番と同じDBを用いたりするのは手間なので、 SQLite のインメモリー を利用する。

テスト用の app を作成する際に以下のようにする。

 1import pytest
 2
 3from app import create_app, db
 4
 5
 6@pytest.fixture
 7def app():
 8    """テスト用のFlaskアプリケーションを設定"""
 9    # テスト用の設定を渡してアプリケーションを作成
10    test_config = {
11        "TESTING": True,
12        "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
13        "SQLALCHEMY_TRACK_MODIFICATIONS": False,
14    }
15    app = create_app(test_config)
16    with app.app_context():
17        # テスト用のデータベーステーブルを作成
18        db.create_all()
19        yield app
20        db.session.remove()
21        db.drop_all()
22
23
24@pytest.fixture
25def db_session(app):
26    """テスト用のデータベースセッション"""
27    with app.app_context():
28        yield db.session

app.app_context() は Flask アプリケーションの アプリケーションコンテキスト を明示的に作成し、その範囲内で動作するための仕組み。通常、Flask アプリケーションはリクエストが処理されるときに自動的にアプリケーションコンテキストを作成する。しかし、リクエストの外側でアプリケーション関連の操作を行いたい場合(例えば、データベース操作やテスト実行)には、app.app_context() を明示的に使用してコンテキストを作成する必要がある。

上記の例では、アプリケーションコンテキスト内で、テスト実行前に DB を初期化し、テスト後に DB とのセッションを切り、 DB 内のデータを消すという動作を行っている。

app/models/file.py のテスト例
#

簡単な例として、以下のような File モデルのテストを書く:

 1from app import db
 2
 3
 4# File テーブル
 5class File(db.Model):
 6    __tablename__ = "File"
 7    file_id = db.Column(db.Integer, primary_key=True, autoincrement=True)
 8    file_name = db.Column(db.String(255))
 9    display_name = db.Column(db.String(255))
10    url = db.Column(db.String(255))
11    file_type = db.Column(db.String(255))
12    size = db.Column(db.String(255))
13    description = db.Column(db.String(255))
14    tag = db.Column(db.String(255))
15    is_standard = db.Column(db.Integer)
16    created_at = db.Column(db.String(255))
17    created_by = db.Column(db.String(255))
18    updated_at = db.Column(db.String(255))
19    updated_by = db.Column(db.String(255))

以下のようにすれば良い。

 1import pytest
 2
 3from app import create_app, db
 4from app.models.file import File
 5
 6
 7@pytest.fixture
 8def app():
 9    """テスト用のFlaskアプリケーションを設定"""
10    # テスト用の設定を渡してアプリケーションを作成
11    test_config = {
12        "TESTING": True,
13        "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
14        "SQLALCHEMY_TRACK_MODIFICATIONS": False,
15    }
16    app = create_app(test_config)
17    with app.app_context():
18        # テスト用のデータベーステーブルを作成
19        db.create_all()
20        yield app
21        db.session.remove()
22        db.drop_all()
23
24
25@pytest.fixture
26def db_session(app):
27    """テスト用のデータベースセッション"""
28    with app.app_context():
29        yield db.session
30
31
32def test_FileモデルがDBに作成されること(db_session):
33    file = File(
34        file_name="test.pdf",
35        display_name="テスト用ファイル",
36        url="https://hoge.com/test.pdf",
37        file_type="pdf",
38        size="1 MB",
39        description="テスト用のファイルです",
40        tag="BS, CS",
41        is_standard=1,
42        created_at="2024-12-16 12:00:00",
43        created_by="okazaki",
44        updated_at="2024-12-16 12:00:00",
45        updated_by="okazaki",
46    )
47    db_session.add(file)
48    db_session.commit()
49
50    # データベースに挿入されたことを確認
51    saved_file = db_session.query(File).filter_by(file_name="test.pdf").first()
52    assert saved_file is not None
53    assert saved_file.display_name == "テスト用ファイル"
54    assert saved_file.size == "1 MB"
55
56
57def test_FileモデルがDBで更新されること(db_session):
58    # 初期データを挿入
59    file = File(file_name="update.pdf", display_name="更新前の名前")
60    db_session.add(file)
61    db_session.commit()
62
63    # 更新処理
64    file_to_update = db_session.query(File).filter_by(file_name="update.pdf").first()
65    file_to_update.display_name = "更新後の名前"
66    db_session.commit()
67
68    # 更新されたことを確認
69    updated_file = db_session.query(File).filter_by(file_name="update.pdf").first()
70    assert updated_file.display_name == "更新後の名前"
71
72
73def test_FileモデルがDBから削除されること(db_session):
74    # 初期データを挿入
75    file = File(file_name="delete.pdf", display_name="削除用ファイル")
76    db_session.add(file)
77    db_session.commit()
78
79    # 削除処理
80    file_to_delete = db_session.query(File).filter_by(file_name="delete.pdf").first()
81    db_session.delete(file_to_delete)
82    db_session.commit()
83
84    # 削除されたことを確認
85    deleted_file = db_session.query(File).filter_by(file_name="delete.pdf").first()
86    assert deleted_file is None

app/models/manager_user.py のテスト例
#

ログインユーザーを管理する manager_user.py では、セッションからユーザー情報を取り出す load_user() 関数があるので、これをテストするコードを以下のように作成する。

app/models/manager_user.py の実装:

 1from flask_login import UserMixin
 2from werkzeug.security import check_password_hash, generate_password_hash
 3
 4from app import db, login_manager
 5
 6
 7# ManagerUser テーブル
 8class ManagerUser(UserMixin, db.Model):
 9    __tablename__ = "ManagerUser"
10    user_id = db.Column(db.Integer, primary_key=True, autoincrement=True)
11    user_name = db.Column(db.String(255))
12    password = db.Column(db.String(255))
13    service = db.Column(db.String(255))
14    logined_at = db.Column(db.String(255))
15
16    def get_id(self):
17        return str(self.user_id)  # IDをstr型で返す
18    # 中略
19
20
21@login_manager.user_loader
22def load_user(user_id: str) -> ManagerUser:
23    with db.session() as session:
24        return session.get(ManagerUser, int(user_id))

tests/models/test_manager_user.py の一部:

 1import pytest
 2from werkzeug.security import generate_password_hash
 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 = {
12        "TESTING": True,
13        "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
14        "SQLALCHEMY_TRACK_MODIFICATIONS": False,
15    }
16    app = create_app(test_config)
17    with app.app_context():
18        db.create_all()
19        yield app
20        db.session.remove()
21        db.drop_all()
22
23
24@pytest.fixture
25def db_session(app):
26    """テスト用のデータベースセッション"""
27    with app.app_context():
28        yield db.session
29    # 中略
30
31def test_load_user(db_session):
32    """load_user関数のテスト"""
33    # テスト用ユーザーを作成
34    user = ManagerUser(user_id=1, user_name="loader_test_user")
35    db_session.add(user)
36    db_session.commit()
37
38    # load_user 関数でユーザーをロード
39    from app.models.manager_user import load_user
40
41    loaded_user = load_user("1")
42
43    assert loaded_user is not None
44    assert loaded_user.user_name == "loader_test_user"

参考資料
#