Python Table Manners - 測試 (二)

Category Tech

接續前一篇提到的 pytest,繼續看它的其他功能吧

fixture

fixture 幾乎可以說是 pytest 最重要的功能
前一篇的例子中只有用到準備資源的部分
這裡再舉一些例子來說明它的其他應用

準備 / 清除資源

假設我們已經有了虛構的 db 函式庫,它可以處理各種資料庫相關的功能

現在寫一個測試案例來驗證 is_connected 函式是否能正確的判斷資料庫有連線

import pytest

from db import DB


@pytest.fixture(scope="function")
def db():
    # 初始化 DB 的 instance
    _db = DB()
    # 連接到資料庫
    _db.connect()

    yield _db

    # 斷開資料庫連線
    _db.close()


def test_db_is_connected(db):
    assert db.is_connected() is True

fixture db 中不使用 return 而是使用 yield
連線資料庫後,就先將 _db instance 回傳
test_db_is_connected 引入 fixture db 時,資料庫會處於連線的狀態
結束後,則會執行 _db.close() 斷開資料庫的連線
(什麼時候才算結束則是看 fixture 的參數 scope,這會在後面說明)

接下來我還想要說明兩個概念

  1. fixture 中使用 fixture
  2. fixture 準備跟清除資源,但不直接呼叫到資源 (pytest.mark.usefixtures)

現在假設已經實作了 model,裡面有 User 的定義
我們想要驗證新增了一筆 admin 的使用者後,是否能成功查詢到這筆資料

import pytest

from db import DB
from model import User


@pytest.fixture(scope="function")
def db():
    _db = DB()
    _db.connect()

    yield

    _db.close()


@pytest.fixtures(scope="function")
def insert_admin_user(db):
    # 初始化 user
    user = User(name="admin")
    # 將 user 新增到資料庫
    db.insert(user)

    yield
    # 將 user 從資料庫移除
    db.delete(user)


@pytest.mark.usefixtures("insert_admin_user")
def test_admin_user_exists():
    # 從資料庫中找出第一筆 name 是 admin 的 user
    admin_user = User.query.filter_by(name="admin").first()
    assert admin_user is not None

新增資料前,必須先跟資料庫建立連線
因此準備資料的 fixture insert_admin_user 會使用到 db fixture

而測試函式 test_admin_user_exists ,需要已經有 admin 使用者的資料庫,來測試 User.query.filter(name="admin").first() 是否能成功取得資料
但它不需要用到 insert_admin_user 這個變數,因此就能改成使用 pytest.mark.usefixtures
這樣就能在不引入參數的情況下,使用 fixture 設定好的環境

scope

fixture 的 scope 共分為五種 (function, class, module, package, session)
表示 fixture 會在哪個階段前準備資源,並在哪個階段後清除
如果設定成 function,就會在每一個測試函式執行前和後做資源的處理

conftest.py

conftest.py 是 pytest 中的一個特殊檔案
如果是整個套件(同一個資料夾)都會用到的 fixture 就能放在這, pytest 執行時會自動載入

以下面的結構為例, test_sponsor.py 就會自動載入上層的 conftest.py 中的 fixture

└── tests
    ├── __init__.py
    ├── conftest.py
    ├── test_sponsor.py
    └── page
        ├── __init__.py
        ├── conftest.py
        └── test_title.py

常用的內建 fixture

  • caplog: 抓 log 訊息
  • capsys: 抓 std out, std err
  • tmpdir: 暫時資料夾,通常用來測檔案相關的測試

參數化 (parameterize)

在測試資料比較簡單的時候,可以使用 parameterize 來減少撰寫重複的程式碼

  • @pytest.mark.parametrize(args1, arg2)
    • 第一個參數: 指定測試函式要使用的參數名稱
    • 第二個參數: 測試資料的陣列
import pytest


@pytest.mark.parametrize(
    "x, y, expected_sum",
    (
        (1, 1, 2),
        (2, 2, 4),
        (3, 3, 6),
    ),
)
def test_add(x, y, expected_sum):
    assert x + y == expected_sum

marker

前面已經介紹過 parameterizeusefixtures
這裡會介紹 markers 還可以做什麼

內建 marker

  • skip: 跳過這個測試案例
  • skipif: 如果符合某個條件,則跳過這個測試案例
  • xfail: 預期會失敗 (其實前一篇想跳過會失敗的案例應該要用 xfail,而不是 skip

自定義 marker

@pytest.mark.[any custom marker] 的用途是標記測試案例
像是如果有些測試會特別慢,就可以透過標記 @pytest.mark.slow

from time import sleep


@pytest.mark.slow
def test_super_slow_test():
    sleep(99999999999999)

執行時加上參數 -m 就能跳過(或只執行)這些案例

pipenv run pytest -m "not slow"

上面的做法,如果有測試案例不小心打成 @pytest.mark.slwo,會不太容易被發現
但 pytest 還是會正常執行
這時候可以在專案加入設定檔 pyproject.toml (pytest 6.0.0 之後才支援這種設定檔格式) 定義 marker
p.s. 不建議使用 setup.cfg 做為 pytest 的設定檔 (Read More 👉 deprecate setup.cfg support #3523)

[tool.pytest.ini_options]
minversion = "6.0"
markers = [
    "slow"
]

並在執行時加上 --strict-markers 參數

pipenv run pytest --strict-markers -m "not slow"

pytest 就會告訴我們 slwo 並不是被定義過的 maker

更進一步可以把 --strict-markers 直接寫入 pyproject.toml

[tool.pytest.ini_options]
minversion = "6.0"
addopts = "--strict-markers"
markers = [
    "slow"
]

測試例外事件

透過 pytest.raise 確認測試案例是否有符合預期的丟出例外事件

import pytest


def test_index_error():
    some_list = []
    with pytest.raises(IndexError):
        print(some_list[1])

pytest 常用命令列參數

  • -v (-vv, -vvv): 顯示更多資訊 (越多 v 就會顯示越多資訊)
  • --durations=N: 只列出最慢的 N 個測試
  • -x (--exitfirst): 遇到第一個失敗就終止測試
  • --maxfail=num: 失敗次數達到 num 次,直接終止測試
  • --lf (--last-failed): 只測試上次失敗的案例
  • --ff (--failed-first): 從上次失敗的案例開始測試
  • --nf --new-first: 從新的案例開始測試
  • -k EXPRESSION: 只測試名稱符合 "EXPRESSION" 的案例
  • -m MARKEXPR: 只測試有 "MARKEXPR" maker 的案例
  • --fixtures: 列出所有 fixtures

pytest-cov 測試覆蓋率

pytest-cov 可以用來產生測試覆蓋率的報告,讓我們知道程式碼還有哪些沒被測試到

# 安裝 pytest-cov
pipenv install pytest-cov --dev

e.g.,

# 計算 myproj 的覆蓋率
pipenv run pytest --cov=myproj tests/

比較重要的參數有

  • --cov=[SOURCE]: 測試包含的程式碼範圍
  • --cov-report=TYPE: 測試覆蓋率報告的種類 (term, term-missing, annotate, html, xml)
  • --cov-fail-under=MIN: 如果覆蓋率小於 MIN 則跳出

其中 --cov, --cov-report 都可以加入多個參數

回到 pycontw-postevent-report-generator 的例子
先 checkout 回 1.0.2,來測試 1.0.2 上的測試覆蓋率

pipenv run pytest --cov=report_generator --cov-report=term-missing test/

從下面的結果可以看到哪些檔案的哪些部分沒有被測試到

test-coverage

如果想看精美的網頁版報告,可以試試看以下的指令
報告會產生在專案資料夾下的 htmlcov

pipenv run pytest --cov=report_generator --cov-report=term-missing --cov-report=html

一些更進階的設定,可以寫入設定檔 pyproject.toml (或 .coveragerc,但語法會不太一樣)
以下是我自己使用的 pyproject.toml

[tool.coverage]
    [tool.coverage.report]
    show_missing = true
    exclude_lines = [
        # Have to re-enable the standard pragma
        'pragma: no cover',

        # Don't complain about missing debug-only code:
        'def __repr__',
        'if self\.debug',

        # Don't complain if tests don't hit defensive assertion code:
        'raise AssertionError',
        'raise NotImplementedError',

        # Don't complain if non-runnable code isn't run:
        'if 0:',
        'if __name__ == .__main__.:'
    ]

Read More 👉 Configuration reference

其他常用 plugins

其他測試工具

Reference