【Python】Test and Coverage in Flask

今回は, Flask の Tutorial で使われている Flaskr と呼ばれる blog application を用いて Flask のテストとコードカバレッジの測定を試してみたので備忘録を残しておきます。

Flaskr

Flaskr は Flask の Tutorial で使われている blog application です。Flask のソースコードを GitHub から clone します。

$ git clone https://github.com/pallets/flask
$ cd flask/examples/tutorial

flask/examples/tutorial 以下のディレクトリ構成は以下です。

$ tree -L 2 -I '*.pyc|__pycache__|flaskr.egg-info'
.
├── LICENSE
├── MANIFEST.in
├── README.rst
├── flaskr
│   ├── __init__.py
│   ├── auth.py
│   ├── blog.py
│   ├── db.py
│   ├── schema.sql
│   ├── static
│   └── templates
├── instance
│   └── flaskr.sqlite
├── setup.cfg
├── setup.py
└── tests
    ├── conftest.py
    ├── data.sql
    ├── test_auth.py
    ├── test_blog.py
    ├── test_db.py
    └── test_factory.py

5 directories, 17 files

Flaskr に含まれる主要な Python コードは以下の4つです。

  • __init__.py: 主に Flask の環境設定やインスタンスを生成するコード
  • auth.py: ユーザ登録やログイン認証に関するコード
  • blog.py: ブログ投稿の管理 (作成, 更新, 削除) に関するコード
  • db.py: DB への接続と初期化に関するコード

Tutorial では RDB に SQLite3 が用いられていますが, 今回は MySQL (w/ MySQL Connector/Python) に書き換えてみました。変更後のコードは GitHub (feature/flaskr-sqlite3-to-mysql branch) に置いています。

Flaskr を実行し, Webブラウザから一連の操作を行いアプリケーションの動作を確認してみます。

最初に, flask.cli と Click を用いて CLI から DB の初期化 (schema.sql) を行います。

@click.command("init-db")
@with_appcontext
def init_db_command():
    """Clear existing data and create new tables."""
    init_db()
    click.echo("Initialized the database.")

以下のコマンドを実行します。

$ flask init-db
Initialized the database.

MySQL に接続し DB の初期化を確認します。

mysql> show tables;
+-------------------+
| Tables_in_example |
+-------------------+
| post              |
| user              |
+-------------------+
2 rows in set (0.01 sec)

mysql> DESC user;
+----------+-------------+------+-----+---------+----------------+
| Field    | Type        | Null | Key | Default | Extra          |
+----------+-------------+------+-----+---------+----------------+
| id       | int(11)     | NO   | PRI | NULL    | auto_increment |
| username | varchar(64) | NO   | UNI | NULL    |                |
| password | text        | NO   |     | NULL    |                |
+----------+-------------+------+-----+---------+----------------+
3 rows in set (0.01 sec)

mysql> DESC post;
+-----------+-----------+------+-----+-------------------+-------------------+
| Field     | Type      | Null | Key | Default           | Extra             |
+-----------+-----------+------+-----+-------------------+-------------------+
| id        | int(11)   | NO   | PRI | NULL              | auto_increment    |
| author_id | int(11)   | NO   | MUL | NULL              |                   |
| created   | timestamp | NO   |     | CURRENT_TIMESTAMP | DEFAULT_GENERATED |
| title     | text      | NO   |     | NULL              |                   |
| body      | text      | NO   |     | NULL              |                   |
+-----------+-----------+------+-----+-------------------+-------------------+
5 rows in set (0.00 sec)

mysql> SELECT count(*) FROM post;
+----------+
| count(*) |
+----------+
|        0 |
+----------+
1 row in set (0.00 sec)

mysql> SELECT count(*) FROM user;
+----------+
| count(*) |
+----------+
|        0 |
+----------+
1 row in set (0.00 sec)

flask run で Flask アプリケーションを実行します。

$ export FLASK_APP=flaskr
$ export FLASK_ENV=development
$ flask run

Webブラウザから `/auth/register` に接続しユーザ登録を行うと MySQL にユーザが保存され, `/auth/login` に遷移します。user テーブルに登録したユーザが保存されていることを確認します。

mysql> SELECT * FROM user WHERE username="t2sy";
+----+----------+------------------------------------------------------------------------------------------------+
| id | username | password                                                                                       |
+----+----------+------------------------------------------------------------------------------------------------+
|  1 | t2sy     | pbkdf2:sha256:150000$jebGrBle$4c99516a9503ee3cf56c9974f862376ca5090e6e256f9a66b934f15fcb385b5a |
+----+----------+------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

登録したユーザでログインすると `/` にリダイレクトされます。 画面上の New のリンクを押下すると `/create` に遷移しブログを投稿できます。

Title と Body を入力し Save ボタンを押下します。post テーブルに投稿が保存されていることを確認します。

mysql> SELECT * FROM post WHERE author_id=1;
+----+-----------+---------------------+-------------+----------------+
| id | author_id | created             | title       | body           |
+----+-----------+---------------------+-------------+----------------+
|  1 |         1 | 2019-09-01 16:31:49 | hello world | my first post. |
+----+-----------+---------------------+-------------+----------------+
1 row in set (0.00 sec)

次に, 画面上の Edit のリンクから先ほど投稿したブログを更新します。

post テーブルの body カラムの値が変更されていることを確認します。

mysql> SELECT * FROM post WHERE author_id=1;
+----+-----------+---------------------+-------------+----------+
| id | author_id | created             | title       | body     |
+----+-----------+---------------------+-------------+----------+
|  1 |         1 | 2019-09-01 16:31:49 | hello world | updated. |
+----+-----------+---------------------+-------------+----------+

最後に, `/<int:id>/delete` から更新した投稿を削除します。post テーブルからレコードが削除されていることを確認します。

mysql> SELECT * FROM post WHERE author_id=1;
Empty set (0.00 sec)

SQLite3 から MySQL に変更後の Flaskr の動作確認を行いました。

pytest and coverage

pytest で Flaskr の単体テストを行います。RDB を SQLite3 から MySQL に変更したためテストコードを少し変更しています。

tests/ 以下の主なファイルは以下です。pytest では各テストモジュールのファイル名の先頭を test_* とし, テストする関数も同様に test_* とすることでテスト対象として認識されます。

  • tests/conftest.py: fixture と呼ばれる各テストモジュールで用いる設定関数
  • tests/test_auth.py: ユーザ登録, ログイン認証に関するテストモジュール
  • tests/test_blog.py: ブログ投稿に関するテストモジュール
  • tests/test_db.py: DB 接続と初期化に関するテストモジュール

Flask は Flask.test_client によりアプリケーションサーバへの Request をシミュレートすることで, その Response に含まれるデータ (e.g. HTTP Status code, HTML に含まれる文字列) が期待している値かどうかをテストできます。

Flask.test_client は with ブロック内ではコンテキストを継続できるため, 柔軟なテストを書くことができます。

with app.test_client() as c:
    rv = c.get('/?vodka=42')
    assert request.args['vodka'] == '42'

テスト中の DB 操作 (tests/data.sql) は tempfile.mkstemp() によって作成される一時ファイルに書き込まれ, テストが終了すると一時ファイルは閉じられ削除されます。

@pytest.fixture
def app():
    """Create and configure a new app instance for each test."""
    # create a temporary file to isolate the database for each test
    db_fd, db_path = tempfile.mkstemp()
    # create the app with common test config
    app = create_app({"TESTING": True, "DATABASE": db_path})

    # create the database and load test data
    with open(os.path.join(os.path.dirname(__file__), "data.sql"), "rb") as f:
        with app.app_context():
            init_db()
            cnx = get_db()
            execute_from_sql_file(cnx, f)

    yield app

    # close and remove the temporary database
    os.close(db_fd)
    os.unlink(db_path)

pytest コマンドを実行します。

$ pytest
======================================== test session starts ========================================
platform darwin -- Python 3.6.8, pytest-4.3.1, py-1.8.0, pluggy-0.9.0
rootdir: /Users/tatsuya.egawa/Dropbox/programs/python/flask-testing-profiling/flask/examples/tutorial, inifile: setup.cfg
plugins: remotedata-0.3.1, openfiles-0.3.2
collected 24 items

tests/test_auth.py ........                                                                   [ 33%]
tests/test_blog.py ............                                                               [ 83%]
tests/test_db.py ..                                                                           [ 91%]
tests/test_factory.py ..                                                                      [100%]

===================================== 24 passed in 4.16 seconds =====================================

続いて, coverage コマンドでテストのコードカバレッジを測定します。

$ coverage run -m pytest
$ coverage report
Name                 Stmts   Miss Branch BrPart  Cover
------------------------------------------------------
flaskr/__init__.py      21      0      2      0   100%
flaskr/auth.py          75      0     26      0   100%
flaskr/blog.py          77      0     22      0   100%
flaskr/db.py            43      4     10      1    91%
------------------------------------------------------
TOTAL                  216      4     60      1    98%

おわりに

Flask アプリケーションの単体テストは Tutorial の Test Coverage が参考になります。Tutorial では一時ファイルを用いて DB のテスト環境を作っていますが, DB とのやり取りを pytest-mock で mock する方法もあります。
次回 は Flask アプリケーションの Profiling について調べようと思います。

[1] MySQL Connector/Python Developer Guide
[2] execute *.sql file with python MySQLdb
[3] Testing with pytest-mock and pytest-flask
[4] pytest ヘビーユーザーへの第一歩