テストカバレッジ—フラスコのドキュメント

提供:Dev Guides
< FlaskFlask/docs/1.0.x/tutorial/tests
移動先:案内検索

テストカバレッジ

アプリケーションの単体テストを作成すると、作成したコードが期待どおりに機能することを確認できます。 Flaskは、アプリケーションへの要求をシミュレートし、応答データを返すテストクライアントを提供します。

できるだけ多くのコードをテストする必要があります。 関数内のコードは、関数が呼び出されたときにのみ実行され、ifブロックなどのブランチ内のコードは、条件が満たされたときにのみ実行されます。 各関数が各ブランチをカバーするデータでテストされていることを確認する必要があります。

100 % cの超過に近づくほど、変更を加えても他の動作が予期せず変更されないという安心感が得られます。 ただし、100 % cの超過は、アプリケーションにバグがないことを保証するものではありません。 特に、ユーザーがブラウザーでアプリケーションをどのように操作するかはテストしません。 それにもかかわらず、テストカバレッジは開発中に使用する重要なツールです。

ノート

これはチュートリアルの後半で紹介されていますが、将来のプロジェクトでは、開発しながらテストする必要があります。


pytestcoverage を使用して、コードをテストおよび測定します。 両方をインストールします。

pip install pytest coverage

セットアップと備品

テストコードはtestsディレクトリにあります。 このディレクトリは、 ' flaskrパッケージの隣にあり、その中にはありません。 tests/conftest.pyファイルには、各テストで使用されるフィクスチャと呼ばれるセットアップ関数が含まれています。 テストはtest_で始まるPythonモジュールで行われ、これらのモジュールの各テスト関数もtest_で始まります。

各テストは、新しい一時データベースファイルを作成し、テストで使用されるいくつかのデータを入力します。 そのデータを挿入するSQLファイルを記述します。

tests/data.sql

INSERT INTO user (username, password)
VALUES
  ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
  ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');

INSERT INTO post (title, body, author_id, created)
VALUES
  ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');

appフィクスチャはファクトリを呼び出し、test_configを渡して、ローカル開発構成を使用する代わりに、テスト用にアプリケーションとデータベースを構成します。

tests/conftest.py

import os
import tempfile

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

with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f:
    _data_sql = f.read().decode('utf8')


@pytest.fixture
def app():
    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

    os.close(db_fd)
    os.unlink(db_path)


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


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

tempfile.mkstemp()は一時ファイルを作成して開き、ファイルオブジェクトとそのパスを返します。 DATABASEパスはオーバーライドされるため、インスタンスフォルダーではなくこの一時パスを指します。 パスを設定した後、データベーステーブルが作成され、テストデータが挿入されます。 テストが終了すると、一時ファイルが閉じられて削除されます。

TESTING は、アプリがテストモードであることをFlaskに通知します。 Flaskは一部の内部動作を変更してテストを容易にし、他の拡張機能もフラグを使用してテストを容易にすることができます。

clientフィクスチャは、appフィクスチャによって作成されたアプリケーションオブジェクトを使用してapp.test_client()を呼び出します。 テストでは、クライアントを使用して、サーバーを実行せずにアプリケーションに要求を行います。

runnerフィクスチャはclientに似ています。 app.test_cli_runner()は、アプリケーションに登録されているクリックコマンドを呼び出すことができるランナーを作成します。

Pytestは、関数名をテスト関数の引数の名前と照合することにより、フィクスチャを使用します。 たとえば、次に作成するtest_hello関数は、client引数を取ります。 Pytestは、これをclientフィクスチャ関数と照合して呼び出し、戻り値をテスト関数に渡します。


工場

工場自体についてテストすることはあまりありません。 ほとんどのコードはすでに各テストで実行されているため、何かが失敗した場合、他のテストが気付くでしょう。

変更できる唯一の動作は、テスト構成に合格することです。 構成が渡されない場合は、いくつかのデフォルト構成が存在する必要があります。そうでない場合は、構成がオーバーライドされます。

tests/test_factory.py

from flaskr import create_app


def test_config():
    assert not create_app().testing
    assert create_app({'TESTING': True}).testing


def test_hello(client):
    response = client.get('/hello')
    assert response.data == b'Hello, World!'

チュートリアルの冒頭でファクトリを作成するときに、例としてhelloルートを追加しました。 「Hello、World!」が返されるため、テストは応答データが一致することを確認します。


データベース

アプリケーションコンテキスト内では、get_dbは呼び出されるたびに同じ接続を返す必要があります。 コンテキストの後、接続を閉じる必要があります。

tests/test_db.py

import sqlite3

import pytest
from flaskr.db import get_db


def test_get_close_db(app):
    with app.app_context():
        db = get_db()
        assert db is get_db()

    with pytest.raises(sqlite3.ProgrammingError) as e:
        db.execute('SELECT 1')

    assert 'closed' in str(e.value)

init-dbコマンドは、init_db関数を呼び出して、メッセージを出力する必要があります。

tests/test_db.py

def test_init_db_command(runner, monkeypatch):
    class Recorder(object):
        called = False

    def fake_init_db():
        Recorder.called = True

    monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
    result = runner.invoke(args=['init-db'])
    assert 'Initialized' in result.output
    assert Recorder.called

このテストでは、Pytestのmonkeypatchフィクスチャを使用して、init_db関数を呼び出されたことを記録する関数に置き換えます。 上記で記述したrunnerフィクスチャは、init-dbコマンドを名前で呼び出すために使用されます。


認証

ほとんどのビューでは、ユーザーはログインする必要があります。 テストでこれを行う最も簡単な方法は、クライアントを使用してloginビューにPOST要求を行うことです。 毎回それを書き出すのではなく、それを行うためのメソッドを備えたクラスを書き、フィクスチャを使用して各テストのクライアントに渡すことができます。

tests/conftest.py

class AuthActions(object):
    def __init__(self, client):
        self._client = client

    def login(self, username='test', password='test'):
        return self._client.post(
            '/auth/login',
            data={'username': username, 'password': password}
        )

    def logout(self):
        return self._client.get('/auth/logout')


@pytest.fixture
def auth(client):
    return AuthActions(client)

authフィクスチャを使用すると、テストでauth.login()を呼び出して、 [のテストデータの一部として挿入されたtestユーザーとしてログインできます。 X149X]フィクスチャ。

registerビューはGETで正常にレンダリングされるはずです。 有効なフォームデータがあるPOSTでは、ログインURLにリダイレクトされ、ユーザーのデータがデータベースに存在する必要があります。 無効なデータはエラーメッセージを表示するはずです。

tests/test_auth.py

import pytest
from flask import g, session
from flaskr.db import get_db


def test_register(client, app):
    assert client.get('/auth/register').status_code == 200
    response = client.post(
        '/auth/register', data={'username': 'a', 'password': 'a'}
    )
    assert 'http://localhost/auth/login' == response.headers['Location']

    with app.app_context():
        assert get_db().execute(
            "select * from user where username = 'a'",
        ).fetchone() is not None


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('', '', b'Username is required.'),
    ('a', '', b'Password is required.'),
    ('test', 'test', b'already registered'),
))
def test_register_validate_input(client, username, password, message):
    response = client.post(
        '/auth/register',
        data={'username': username, 'password': password}
    )
    assert message in response.data

client.get()GETリクエストを行い、Flaskから返されたResponseオブジェクトを返します。 同様に、client.post()POST要求を行い、data辞書をフォームデータに変換します。

ページが正常にレンダリングされることをテストするために、単純な要求が行われ、200 OK status_codeがチェックされます。 レンダリングが失敗した場合、Flaskは500 Internal Server Errorコードを返します。

headersには、レジスタビューがログインビューにリダイレクトされるときに、ログインURLを含むLocationヘッダーがあります。

dataには、応答の本文がバイト単位で含まれています。 ページに特定の値が表示されると予想される場合は、その値がdataにあることを確認してください。 バイトはバイトと比較する必要があります。 Unicodeテキストを比較する場合は、代わりにget_data(as_text=True)を使用してください。

pytest.mark.parametrizeは、異なる引数を使用して同じテスト関数を実行するようにPytestに指示します。 ここでは、同じコードを3回記述せずに、さまざまな無効な入力メッセージとエラーメッセージをテストするために使用します。

loginビューのテストは、registerのテストと非常によく似ています。 データベース内のデータをテストするのではなく、セッションにログイン後にuser_idを設定する必要があります。

tests/test_auth.py

def test_login(client, auth):
    assert client.get('/auth/login').status_code == 200
    response = auth.login()
    assert response.headers['Location'] == 'http://localhost/'

    with client:
        client.get('/')
        assert session['user_id'] == 1
        assert g.user['username'] == 'test'


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('a', 'test', b'Incorrect username.'),
    ('test', 'a', b'Incorrect password.'),
))
def test_login_validate_input(auth, username, password, message):
    response = auth.login(username, password)
    assert message in response.data

withブロックでclientを使用すると、応答が返された後、 session などのコンテキスト変数にアクセスできます。 通常、リクエストの外部でsessionにアクセスすると、エラーが発生します。

logoutのテストは、loginの反対です。 セッションには、ログアウト後にuser_idを含めることはできません。

tests/test_auth.py

def test_logout(client, auth):
    auth.login()

    with client:
        auth.logout()
        assert 'user_id' not in session

ブログ

すべてのブログビューは、前に作成したauthフィクスチャを使用します。 auth.login()を呼び出すと、クライアントからの後続の要求はtestユーザーとしてログインします。

indexビューには、テストデータとともに追加された投稿に関する情報が表示されます。 作成者としてログインすると、投稿を編集するためのリンクが表示されます。

indexビューのテスト中に、さらにいくつかの認証動作をテストすることもできます。 ログインしていない場合、各ページにはログインまたは登録するためのリンクが表示されます。 ログインすると、ログアウトするためのリンクがあります。

tests/test_blog.py

import pytest
from flaskr.db import get_db


def test_index(client, auth):
    response = client.get('/')
    assert b"Log In" in response.data
    assert b"Register" in response.data

    auth.login()
    response = client.get('/')
    assert b'Log Out' in response.data
    assert b'test title' in response.data
    assert b'by test on 2018-01-01' in response.data
    assert b'test\nbody' in response.data
    assert b'href="/1/update"' in response.data

createupdate、およびdeleteビューにアクセスするには、ユーザーがログインしている必要があります。 updateおよびdeleteにアクセスするには、ログインしたユーザーが投稿の作成者である必要があります。そうでない場合、403 Forbiddenステータスが返されます。 指定されたidを持つpostが存在しない場合、updateおよびdelete404 Not Foundを返す必要があります。

tests/test_blog.py

@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
    '/1/delete',
))
def test_login_required(client, path):
    response = client.post(path)
    assert response.headers['Location'] == 'http://localhost/auth/login'


def test_author_required(app, client, auth):
    # change the post author to another user
    with app.app_context():
        db = get_db()
        db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
        db.commit()

    auth.login()
    # current user can't modify other user's post
    assert client.post('/1/update').status_code == 403
    assert client.post('/1/delete').status_code == 403
    # current user doesn't see edit link
    assert b'href="/1/update"' not in client.get('/').data


@pytest.mark.parametrize('path', (
    '/2/update',
    '/2/delete',
))
def test_exists_required(client, auth, path):
    auth.login()
    assert client.post(path).status_code == 404

createおよびupdateビューは、GETリクエストに対して200 OKステータスをレンダリングして返す必要があります。 POSTリクエストで有効なデータが送信されると、createは新しい投稿データをデータベースに挿入し、updateは既存のデータを変更する必要があります。 両方のページに、無効なデータに関するエラーメッセージが表示されます。

tests/test_blog.py

def test_create(client, auth, app):
    auth.login()
    assert client.get('/create').status_code == 200
    client.post('/create', data={'title': 'created', 'body': ''})

    with app.app_context():
        db = get_db()
        count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0]
        assert count == 2


def test_update(client, auth, app):
    auth.login()
    assert client.get('/1/update').status_code == 200
    client.post('/1/update', data={'title': 'updated', 'body': ''})

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post['title'] == 'updated'


@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
))
def test_create_update_validate(client, auth, path):
    auth.login()
    response = client.post(path, data={'title': '', 'body': ''})
    assert b'Title is required.' in response.data

deleteビューはインデックスURLにリダイレクトされ、投稿はデータベースに存在しなくなります。

tests/test_blog.py

def test_delete(client, auth, app):
    auth.login()
    response = client.post('/1/delete')
    assert response.headers['Location'] == 'http://localhost/'

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post is None

テストの実行

プロジェクトのsetup.cfgファイルには、必須ではありませんが、カバレッジを使用してテストを実行する際の冗長性を軽減する追加の構成を追加できます。

setup.cfg

[tool:pytest]
testpaths = tests

[coverage:run]
branch = True
source =
    flaskr

テストを実行するには、pytestコマンドを使用します。 それはあなたが書いたすべてのテスト関数を見つけて実行します。

pytest

========================= test session starts ==========================
platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: /home/user/Projects/flask-tutorial, inifile: setup.cfg
collected 23 items

tests/test_auth.py ........                                      [ 34%]
tests/test_blog.py ............                                  [ 86%]
tests/test_db.py ..                                              [ 95%]
tests/test_factory.py ..                                         [100%]

====================== 24 passed in 0.64 seconds =======================

いずれかのテストが失敗した場合、pytestは発生したエラーを表示します。 pytest -vを実行して、ドットではなく各テスト関数のリストを取得できます。

テストのコードカバレッジを測定するには、coverageコマンドを使用して、直接実行する代わりにpytestを実行します。

coverage run -m pytest

ターミナルで簡単なカバレッジレポートを表示できます。

coverage report

Name                 Stmts   Miss Branch BrPart  Cover
------------------------------------------------------
flaskr/__init__.py      21      0      2      0   100%
flaskr/auth.py          54      0     22      0   100%
flaskr/blog.py          54      0     16      0   100%
flaskr/db.py            24      0      4      0   100%
------------------------------------------------------
TOTAL                  153      0     44      0   100%

HTMLレポートを使用すると、各ファイルでカバーされている行を確認できます。

coverage html

これにより、htmlcovディレクトリにファイルが生成されます。 ブラウザでhtmlcov/index.htmlを開いて、レポートを表示します。

本番環境への展開に進みます。