測試覆蓋率

為您的應用程式編寫單元測試,可以讓您檢查您編寫的程式碼是否如您預期般運作。 Flask 提供了一個測試客戶端,可以模擬對應用程式的請求並返回響應數據。

您應該盡可能多地測試您的程式碼。函數中的程式碼只有在函數被調用時才會運行,而分支中的程式碼,例如 if 區塊,只有在條件滿足時才會運行。您需要確保每個函數都使用涵蓋每個分支的數據進行測試。

您越接近 100% 的覆蓋率,您就越能安心地認為進行變更不會意外地改變其他行為。然而,100% 的覆蓋率並不能保證您的應用程式沒有錯誤。特別是,它沒有測試使用者如何在瀏覽器中與應用程式互動。儘管如此,測試覆蓋率仍然是開發過程中使用的重要工具。

注意

這是在本教學的後期才介紹的,但在您未來的專案中,您應該在開發時就進行測試。

您將使用 pytestcoverage 來測試和衡量您的程式碼。安裝它們兩個

$ pip install pytest coverage

設定與夾具 (Fixtures)

測試程式碼位於 tests 目錄中。此目錄 flaskr 套件並列,而不是在它裡面。tests/conftest.py 檔案包含稱為夾具 (fixtures) 的設定函數,每個測試都會使用它們。測試位於以 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 夾具將調用 factory 並傳遞 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()。 測試將使用 client 向應用程式發出請求,而無需運行伺服器。

runner 夾具與 client 類似。 app.test_cli_runner() 建立一個 runner,可以調用向應用程式註冊的 Click 命令。

Pytest 通過將夾具的函數名稱與測試函數中參數的名稱進行匹配來使用夾具。例如,您接下來要編寫的 test_hello 函數接受一個 client 參數。 Pytest 將其與 client 夾具函數匹配,調用它,並將返回的值傳遞給測試函數。

Factory (工廠模式)

關於 factory 本身沒有太多可以測試的。大多數程式碼已經為每個測試執行,因此如果出現問題,其他測試會注意到。

唯一可以改變的行為是傳遞測試配置。如果沒有傳遞配置,應該有一些預設配置,否則配置應該被覆寫。

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!'

當您在本教學開始編寫 factory 時,您添加了 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 命令。

身份驗證

對於大多數視圖,使用者需要登錄。在測試中執行此操作的最簡單方法是使用 client 向 login 視圖發出 POST 請求。您可以編寫一個包含執行此操作的方法的類別,並使用夾具將 client 傳遞給每個測試,而不是每次都寫出來。

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 使用者身份登錄,該使用者已作為 app 夾具中測試資料的一部分插入。

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 response.headers["Location"] == "/auth/login"

    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 dict 轉換為表單數據。

為了測試頁面是否成功渲染,發出一個簡單的請求並檢查 200 OK status_code。 如果渲染失敗,Flask 將返回 500 Internal Server Error 代碼。

headers 將在註冊視圖重定向到登錄視圖時具有帶有登錄 URL 的 Location 標頭。

data 包含響應的主體 (以位元組為單位)。如果您希望頁面上渲染特定值,請檢查它是否在 data 中。 位元組必須與位元組進行比較。 如果您想比較文字,請改用 get_data(as_text=True)

pytest.mark.parametrize 告訴 Pytest 使用不同的參數運行相同的測試函數。 您在此處使用它來測試不同的無效輸入和錯誤訊息,而無需編寫三次相同的程式碼。

login 視圖的測試與 register 的測試非常相似。 與其測試資料庫中的資料,不如在登錄後 session 應該設定 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"] == "/"

    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 會引發錯誤。

測試 logoutlogin 相反。 登出後,session 不應包含 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(),來自 client 的後續請求將以 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

使用者必須登錄才能訪問 createupdatedelete 視圖。 登錄的使用者必須是貼文的作者才能訪問 updatedelete,否則將返回 403 Forbidden 狀態。 如果具有給定 idpost 不存在,updatedelete 應返回 404 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"] == "/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

createupdate 視圖應渲染並為 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"] == "/"

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

運行測試

可以將一些額外的配置添加到專案的 pyproject.toml 檔案中,這不是必需的,但可以使運行帶有覆蓋率的測試不那麼冗長。

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.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
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 以查看報告。

繼續閱讀 部署到生產環境