5 分鐘快速上手 Pytest 測(cè)試框架
本文將會(huì)把關(guān)于 Pytest 的內(nèi)容分上下兩篇,上篇主要涉及關(guān)于 pytest 概念以及功能組件知識(shí)的介紹,下篇主要以一個(gè) Web 項(xiàng)目來(lái)將 Pytest 運(yùn)用實(shí)踐中。
為什么要做單元測(cè)試
相信很多 Python 使用者都會(huì)有這么一個(gè)經(jīng)歷,為了測(cè)試某個(gè)模塊或者某個(gè)函數(shù)是否輸出自己預(yù)期的結(jié)果,往往會(huì)對(duì)產(chǎn)出結(jié)果的部分使用 print() 函數(shù)將其打印輸出到控制臺(tái)上。
- def myfunc(*args, **kwargs):
- do_something()
- data = ...
- print(data)
在一次次改進(jìn)過程中會(huì)不得不經(jīng)常性地使用 print() 函數(shù)來(lái)確保結(jié)果的準(zhǔn)確性,但同時(shí),也由于要測(cè)試的模塊或者函數(shù)變多,代碼中也會(huì)逐漸遺留著各種未被去掉或注釋的 print() 調(diào)用,讓整個(gè)代碼變得不是那么簡(jiǎn)潔得體。
在編程中往往會(huì)存在「單元測(cè)試」這么一個(gè)概念,即指對(duì)軟件中的最小可測(cè)試單元進(jìn)行檢查和驗(yàn)證。這個(gè)最小可測(cè)單元可以是我們的表達(dá)式、函數(shù)、類、模塊、包中的任意一種或組合,因此我們可以將使用 print() 進(jìn)行測(cè)試的步驟統(tǒng)一地放到單元測(cè)試中來(lái)進(jìn)行。
在 Python 中官方早已經(jīng)為我們內(nèi)置好了用以進(jìn)行單元測(cè)試的模塊 unittest。但對(duì)于新手來(lái)說(shuō),unittest 在學(xué)習(xí)曲線上是稍微有點(diǎn)難度的,因?yàn)槭切枰ㄟ^繼承測(cè)試用例類(TestCase)來(lái)進(jìn)行封裝,所以需要對(duì)面向?qū)ο蟮闹R(shí)有足夠多的了解;而和類綁定在一起就意味著如果想要實(shí)現(xiàn)定制化或者模塊解耦,可能就需要多花一些時(shí)間在設(shè)計(jì)劃分上。
所以,為了能讓測(cè)試變得簡(jiǎn)單且具備可擴(kuò)展性,一個(gè)名為 pytest 的測(cè)試框架在 Python 社區(qū)中誕生了,使用 pytest 我們可以不用考慮如何基于 TestCase 來(lái)實(shí)現(xiàn)我們的測(cè)試,我們只需要簡(jiǎn)單到保持我們?cè)械拇a邏輯不變,外加一個(gè) assert 關(guān)鍵字來(lái)斷言結(jié)果,剩下的部分 pytest 會(huì)幫我們處理。
- # main.py
- import pytest
- raw_data = read_data(...)
- def test_myfunc(*args, **kwargs):
- do_something()
- data = ...
- assert data == raw_data
- if __name__ == '__main__':
- pytest.main()
之后我們只需要運(yùn)行包含上述代碼的 main.py 文件,就能在終端控制臺(tái)上看到 pytest 為我們測(cè)試得到的結(jié)果。如果結(jié)果通過,則不會(huì)有過多的信息顯示,如果測(cè)試失敗,則會(huì)拋出錯(cuò)誤信息并告知運(yùn)行時(shí) data 里的內(nèi)容是什么。
盡管說(shuō) pytest 已經(jīng)足夠簡(jiǎn)單,但它也提供了許多實(shí)用的功能(如:依賴注入),這些功能本身是存在著一些概念層面的知識(shí);但這并不意味著勸退想要使用 pytest 來(lái)測(cè)試自己代碼的人,而是讓我們擁有更多的選擇,因此只有對(duì) pytest 的這些功能及其概念有了更好地了解,我們才能夠充分發(fā)揮 pytest 的威力。
快速實(shí)現(xiàn)你的第一個(gè) Pytest 測(cè)試
通過 pip install pytest 安裝 pytest 之后,我們就可以快速實(shí)現(xiàn)我們的第一個(gè)測(cè)試。
首先我們可以任意新建一個(gè) Python 文件,這里我直接以 test_main.py 命名,然后當(dāng)中留存如下內(nèi)容:
- from typing import Union
- import pytest
- def add(
- x: Union[int, float],
- y: Union[int, float],
- ) -> Union[int, float]:
- return x + y
- @pytest.mark.parametrize(
- argnames="x,y,result",
- argvalues=[
- (1,1,2),
- (2,4,6),
- (3.3,3,6.3),
- ]
- )
- def test_add(
- x: Union[int, float],
- y: Union[int, float],
- result: Union[int, float],
- ):
- assert add(x, y) == result
之后將終端切換到該文件所處路徑下,然后運(yùn)行 pytest -v,就會(huì)看到 pytest 已經(jīng)幫我們將待測(cè)試的參數(shù)傳入到測(cè)試函數(shù)中,并實(shí)現(xiàn)對(duì)應(yīng)的結(jié)果:
可以看到我們無(wú)需重復(fù)地用 for 循環(huán)傳參,并且還能直觀地從結(jié)果中看到每次測(cè)試中傳入?yún)?shù)的具體數(shù)值是怎樣。這里我們只通過 pytest 提供的 mark.parametrize 裝飾器就搞定了。也說(shuō)明 pytest 的上手程度是比較容易的,只不過我們需要稍微了解一下這個(gè)框架中的一些概念。
Pytest 概念與用法
命名
如果需要 pytest 對(duì)你的代碼進(jìn)行測(cè)試,首先我們需要將待測(cè)試的函數(shù)、類、方法、模塊甚至是代碼文件,默認(rèn)都是以 test_* 開頭或是以 *_test 結(jié)尾,這是為了遵守標(biāo)準(zhǔn)的測(cè)試約定。如果我們將前面快速上手的例子文件名中的 test_ 去掉,就會(huì)發(fā)現(xiàn) pytest 沒有收集到對(duì)應(yīng)的測(cè)試用例。
當(dāng)然我們也可以在 pytest 的配置文件中修改不同的前綴或后綴名,就像官方給出的示例這樣:
- # content of pytest.ini
- # Example 1: have pytest look for "check" instead of "test"
- [pytest]
- python_files = check_*.py
- python_classes = Check
- python_functions = *_check
但通常情況下我們使用默認(rèn)的 test 前后綴即可。如果我們只想挑選特定的測(cè)試用例或者只對(duì)特定模塊下的模塊進(jìn)測(cè)試,那么我們可以在命令行中通過雙冒號(hào)的形式進(jìn)行指定,就像這樣:
- pytest test.py::test_demo
- pytest test.py::TestDemo::test_demo
標(biāo)記(mark)
在 pytest 中,mark 標(biāo)記是一個(gè)十分好用的功能,通過標(biāo)記的裝飾器來(lái)裝飾我們的待測(cè)試對(duì)象,讓 pytest 在測(cè)試時(shí)會(huì)根據(jù) mark 的功能對(duì)我們的函數(shù)進(jìn)行相應(yīng)的操作。
官方本身提供了一些預(yù)置的 mark 功能,我們只挑常用的說(shuō)。
參數(shù)測(cè)試:pytest.parametrize
正如前面的示例以及它的命名意思一樣,mark.parametrize 主要就是用于我們想傳遞不同參數(shù)或不同組合的參數(shù)到一個(gè)待測(cè)試對(duì)象上的這種場(chǎng)景。
正如我們前面的 test_add() 示例一樣,分別測(cè)試了:
- 當(dāng) x=1 且 y=1 時(shí),結(jié)果是否為 result=2 的情況
- 當(dāng) x=2 且 y=4 時(shí),結(jié)果是否為 result=6 的情況
- 當(dāng) x=3.3 且 y=3 時(shí),結(jié)果是否為 result=6.3 的情況
- ……
我們也可以將參數(shù)堆疊起來(lái)進(jìn)行組合,但效果也是類似:
- import pytest
- @pytest.mark.parametrize("x", [0, 1])
- @pytest.mark.parametrize("y", [2, 3])
- @pytest.mark.parametrize("result", [2, 4])
- def test_add(x, y, result):
- assert add(x,y) == result
當(dāng)然如果我們有足夠多的參數(shù),只要寫進(jìn)了 parametrize 中,pytest 依舊能幫我們把所有情況都給測(cè)試一遍。這樣我們就再也不用寫多余的代碼。
但需要注意的是,parametrize 和我們后面將要講到的一個(gè)重要的概念 fixture 會(huì)有一些差異:前者主要是模擬不同參數(shù)下時(shí)待測(cè)對(duì)象會(huì)輸出怎樣的結(jié)果,而后者是在固定參數(shù)或數(shù)據(jù)的情況下,去測(cè)試會(huì)得到怎樣的結(jié)果。
跳過測(cè)試
有些情況下我們的代碼包含了針對(duì)不同情況、版本或兼容性的部分,那么這些代碼通常只有在符合了特定條件下可能才適用,否則執(zhí)行就會(huì)有問題,但產(chǎn)生的這個(gè)問題的原因不在于代碼邏輯,而是因?yàn)橄到y(tǒng)或版本信息所導(dǎo)致,那如果此時(shí)作為用例測(cè)試或測(cè)試失敗顯然不合理。比如我針對(duì) Python 3.3 版本寫了一個(gè)兼容性的函數(shù),add(),但當(dāng)版本大于 Python 3.3 時(shí)使用必然會(huì)出現(xiàn)問題。
因此為了適應(yīng)這種情況 pytest 就提供了 mark.skip 和 mark.skipif 兩個(gè)標(biāo)記,當(dāng)然后者用的更多一些。
- import pytest
- import sys
- @pytest.mark.skipif(sys.version_info >= (3,3))
- def test_add(x, y, result):
- assert add(x,y) == result
所以當(dāng)我們加上這一標(biāo)記之后,每次在測(cè)試用例之前使用 sys 模塊判斷 Python 解釋器的版本是否大于 3.3,大于則會(huì)自動(dòng)跳過。
預(yù)期異常
代碼只要是人寫的必然會(huì)存在不可避免的 BUG,當(dāng)然有一些 BUG 我們作為寫代碼的人是可以預(yù)期得到的,這類特殊的 BUG 通常也叫異常(Exception)。比如我們有一個(gè)除法函數(shù):
- def div(x, y):
- return x / y
但根據(jù)我們的運(yùn)算法則可以知道,除數(shù)不能為 0;因此如果我們傳遞 y=0 時(shí),必然會(huì)引發(fā) ZeroDivisionError 異常。所以通常的做法要么就用 try...exception 來(lái)捕獲異常,并且拋出對(duì)應(yīng)的報(bào)錯(cuò)信息(我們也可以使用 if 語(yǔ)句進(jìn)行條件判斷,最后也同樣是拋出報(bào)錯(cuò)):
- def div(x, y):
- try:
- return x/y
- except ZeroDivisionError:
- raise ValueError("y 不能為 0")
因此,此時(shí)在測(cè)試過程中,如果我們想測(cè)試異常斷言是否能被正確拋出,此時(shí)就可以使用 pytest 提供的 raises() 方法:
- import pytest
- @pytest.mark.parametrize("x", [1])
- @pytest.mark.parametrize("y", [0])
- def test_div(x, y):
- with pytest.raises(ValueError):
- div(x, y)
這里需要注意,我們需要斷言捕獲的是引發(fā) ZeroDivisionError 后我們自己指定拋出的 ValueError,而非前者。當(dāng)然我們可以使用另外一個(gè)標(biāo)記化的方法(pytest.mark.xfail)來(lái)和 pytest.mark.parametrize 相結(jié)合:
- @pytest.mark.parametrize(
- "x,y,result",
- [
- pytest.param(1,0, None, marks=pytest.mark.xfail(raises=(ValueError))),
- ]
- )
- def test_div_with_xfail(x, y, result):
- assert div(x,y) == result
這樣測(cè)試過程中會(huì)直接標(biāo)記出失敗的部分。
Fixture
在 pytest 的眾多特性中,最令人感到驚艷的就是 fixture。關(guān)于 fixture 的翻譯大部分人都直接將其直譯為了「夾具」一詞,但如果你有了解過 Java Spring 框架的 那么你在實(shí)際使用中你就會(huì)更容易將其理解為 IoC 容器類似的東西,但我自己認(rèn)為它叫「載具」或許更合適。
因?yàn)橥ǔG闆r下都是 fixture 的作用往往就是為我們的測(cè)試用例提供一個(gè)固定的、可被自由拆裝的通用對(duì)象,本身就像容器一樣承載了一些東西在里面;讓我們使用它進(jìn)行我們的單元測(cè)試時(shí),pytest 會(huì)自動(dòng)向載具中注入對(duì)應(yīng)的對(duì)象。
這里我稍微模擬了一下我們?cè)谑褂檬褂脭?shù)據(jù)庫(kù)時(shí)的情況。通常我們會(huì)通過一個(gè)數(shù)據(jù)庫(kù)類創(chuàng)建一下數(shù)據(jù)庫(kù)對(duì)象,然后使用前先進(jìn)行連接 connect(),接著進(jìn)行操作,最后使用完之后斷開連接 close() 以釋放資源。
- # test_fixture.py
- import pytest
- class Database(object):
- def __init__(self, database):
- self.database = database
- def connect(self):
- print(f"\n{self.database} database has been connected\n")
- def close(self):
- print(f"\n{self.database} database has been closed\n")
- def add(self, data):
- print(f"`{data}` has been add to database.")
- return True
- @pytest.fixture
- def myclient():
- db = Database("mysql")
- db.connect()
- yield db
- db.close()
- def test_foo(myclient):
- assert myclient.add(1) == True
在這段代碼中,實(shí)現(xiàn)載具的關(guān)鍵是 @pytest.fixture 這一行裝飾器代碼,通過該裝飾器我們可以直接使用一個(gè)帶有資源的函數(shù)將其作為我們的載具,在使用時(shí)將函數(shù)的簽名(即命名)作為參數(shù)傳入到我們的測(cè)試用例中,在運(yùn)行測(cè)試時(shí) pytest 則會(huì)自動(dòng)幫助我們進(jìn)行注入。
在注入的過程中 pytest 會(huì)幫我們執(zhí)行 myclient() 中 db 對(duì)象的 connect() 方法調(diào)用模擬數(shù)據(jù)庫(kù)連接的方法,在測(cè)試完成之后會(huì)再次幫我們調(diào)用 close() 方法釋放資源。
pytest 的 fixture 機(jī)制是一個(gè)讓我們能實(shí)現(xiàn)復(fù)雜測(cè)試的關(guān)鍵,試想我們以后只需要寫好一個(gè)帶有測(cè)試數(shù)據(jù)的 fixture,就可以在不同的模塊、函數(shù)或者方法中多次使用,真正做到「一次生成,處處使用」。
當(dāng)然 pytest 給我們提供了可調(diào)節(jié)載具作用域(scope)的情況,從小到大依次是:
- function:函數(shù)作用域(默認(rèn))
- class:類作用域
- module:模塊作用域
- package:包作用域
- session:會(huì)話作用域
載具會(huì)隨著作用域的生命周期而誕生、銷毀。所以如果我們希望創(chuàng)建的載具作用域范圍增加,就可以在 @pytest.fixture() 中多增加一個(gè) scope 參數(shù),從而提升載具作用的范圍。
雖然 pytest 官方為我們提供了一些內(nèi)置的通用載具,但通常情況下我們自己自定義的載具會(huì)更多一些。所以我們都可以將其放到一個(gè)名為 conftest.py 文件中進(jìn)行統(tǒng)一管理:
- # conftest.py
- import pytest
- class Database:
- def __init__(self, database):
- self.database:str = database
- def connect(self):
- print(f"\n{self.database} database has been connected\n")
- def close(self):
- print(f"\n{self.database} database has been closed\n")
- def add(self, data):
- print(f"\n`{data}` has been add to database.")
- return True
- @pytest.fixture(scope="package")
- def myclient():
- db = Database("mysql")
- db.connect()
- yield db
- db.close()
因?yàn)槲覀兟暶髁俗饔糜驗(yàn)橥粋€(gè)包,那么在同一個(gè)包下我們?cè)賹⑶懊娴?test_add() 測(cè)試部分稍微修改一下,無(wú)需顯式導(dǎo)入 myclient 載具就可以直接注入并使用:
- from typing import Union
- import pytest
- def add(
- x: Union[int, float],
- y: Union[int, float],
- ) -> Union[int, float]:
- return x + y
- @pytest.mark.parametrize(
- argnames="x,y,result",
- argvalues=[
- (1,1,2),
- (2,4,6),
- ]
- )
- def test_add(
- x: Union[int, float],
- y: Union[int, float],
- result: Union[int, float],
- myclient
- ):
- assert myclient.add(x) == True
- assert add(x, y) == result
之后運(yùn)行 pytest -vs 即可看到輸出的結(jié)果:
Pytest 擴(kuò)展
對(duì)于每個(gè)使用框架的人都知道,框架生態(tài)的好壞會(huì)間接影響框架的發(fā)展(比如 Django 和 Flask)。而 pytest 預(yù)留了足夠多的擴(kuò)展空間,加之許多易用的特性,也讓使用 pytest 存在了眾多插件或第三方擴(kuò)展的可能。
根據(jù)官方插件列表所統(tǒng)計(jì),目前 pytest 有多大 850 個(gè)左右的插件或第三方擴(kuò)展,我們可以在 pytest 官方的 Reference 中找到 Plugin List 這一頁(yè)面查看,這里我主要只挑兩個(gè)和我們下一章實(shí)踐相關(guān)的插件:
相關(guān)插件我們可以根據(jù)需要然后通過 pip 命令安裝即可,最后使用只需要簡(jiǎn)單的參照插件的使用文檔編寫相應(yīng)的部分,最后啟動(dòng) pytest 測(cè)試即可。
pytest-xdist
pytest-xdist 是一個(gè)由 pytest 團(tuán)隊(duì)維護(hù),并能讓我們進(jìn)行并行測(cè)試以提高我們測(cè)試效率的 pytest 插件,因?yàn)槿绻覀兊捻?xiàng)目是有一定規(guī)模,那么測(cè)試的部分必然會(huì)很多。而由于 pytest 收集測(cè)試用例時(shí)是以一種同步的方式進(jìn)行,因此無(wú)法充分利用到多核。
因此通過 pytest-xdist 我們就能大大加快每輪測(cè)試的速度。當(dāng)然我們只需要在啟動(dòng) pytest 測(cè)試時(shí)加上 -n
pytest-asyncio
pytest-asycnio 是一個(gè)讓 pytest 能夠測(cè)試異步函數(shù)或方法的擴(kuò)展插件,同樣是由 pytest 官方維護(hù)。由于目前大部分的異步框架或庫(kù)往往都是會(huì)基于 Python 官方的 asyncio 來(lái)實(shí)現(xiàn),因此 pytest-asyncio 可以進(jìn)一步在測(cè)試用例中集成異步測(cè)試和異步載具。
我們直接在測(cè)試的函數(shù)或方法中直接使用 @pytest.mark.asyncio 標(biāo)記裝飾異步函數(shù)或方法,然后進(jìn)行測(cè)試即可:
- import asyncio
- import pytest
- async def foo():
- await asyncio.sleep(1)
- return 1
- @pytest.mark.asyncio
- async def test_foo():
- r = await foo()
- assert r == 1
結(jié)語(yǔ)
本次內(nèi)容主要簡(jiǎn)單介紹了一下 pytest 概念及其核心特性,我們可以看到 pytest 在測(cè)試部分是多么易用。pytest 特性和使用示例遠(yuǎn)遠(yuǎn)不止于此,官方文檔已經(jīng)足夠全面,感興趣的朋友可以進(jìn)一步深入了解。
下一部分內(nèi)容我們將會(huì)以 Web 項(xiàng)目為例進(jìn)一步集成 pytest 進(jìn)行實(shí)踐。