接續前一篇提到的 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
,這會在後面說明)
接下來我還想要說明兩個概念
fixture
中使用fixture
- 用
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
參數化 (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
前面已經介紹過 parameterize
和 usefixtures
這裡會介紹 markers 還可以做什麼
內建 marker
自定義 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/
從下面的結果可以看到哪些檔案的哪些部分沒有被測試到
如果想看精美的網頁版報告,可以試試看以下的指令
報告會產生在專案資料夾下的 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
- pytest-xdist
- 用平行化加速測試的執行 (
pipenv run pytest -n NUM
)
- 用平行化加速測試的執行 (
- pytest-mock
- 使用 mocking 的技巧將部分不好測試的物件替換成假的物件
- 推薦參考 Demystifying the Patch Function - PyCon US 2018 (不過她不是用 pytest)
- pytest-regressions
- 將冗長的測試結果寫成檔案,每次測試都去比對跟上次產生的結果是否相同
- 尋找其他 plugins
其他測試工具
- tox
- 在各種不同版本的 Python 中做測試,幾乎是開源 Python 專案的標準工具
- nox
- 基本上跟 tox 的功能相似,不過組態設定是使用 Python
- tox 跟 nox 推薦參考 Break the Cycle: Three excellent Python tools to automate repetitive tasks - PyCon US 2019
- hypothesis
- 採用 Property-based testing,跟以往要自己產生測試資料不同,我們只需要給予資料的定義(e.g., 0 ~ 10000 之間的整數), hypothsis 會根據定義來產生隨機的資料,也因此更容易包含到極端案例
- 推薦參考 Escape from auto-manual testing with Hypothesis! (PyCon US 2019, Zac 投了 talk, sprint, tutorial, poster,很用心在推廣這套工具)
Reference
- Python Testing with pytest
- 快快樂樂成為 Coding Ninja (by pytest) - PyCon APAC 2015
- Pytest: Rapid Simple Testing - Swiss Python Summit 2016
- Demystifying the Patch Function - PyCon US 2018
- Escape from auto-manual testing with Hypothesis!
- Break the Cycle: Three excellent Python tools to automate repetitive tasks - PyCon US 2019