今回は, 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 ヘビーユーザーへの第一歩