單元測(cè)試的概念可能多數(shù)讀者都有接觸過(guò)。作為開(kāi)發(fā)人員,我們編寫(xiě)一個(gè)個(gè)測(cè)試用例,測(cè)試框架發(fā)現(xiàn)這些測(cè)試用例,將它們組裝成測(cè)試 suite 并運(yùn)行,收集測(cè)試報(bào)告,并且提供測(cè)試基礎(chǔ)設(shè)施(斷言、mock、setup 和 teardown 等)。Python 當(dāng)中最主流的單元測(cè)試框架有三種,Pytest, nose 和 Unittest,其中 Unittest 是標(biāo)準(zhǔn)庫(kù),其它兩種是第三方工具。在 ppw 向?qū)傻捻?xiàng)目中,就使用了 Pytest 來(lái)驅(qū)動(dòng)測(cè)試。
這里主要比較一下 pytest 和 unittest。多數(shù)情況下,當(dāng)我們選擇單元測(cè)試框架時(shí),選擇二者之一就好了。unitttest 基于類來(lái)組織測(cè)試用例,而 pytest 則是函數(shù)式的,基于模塊來(lái)組織測(cè)試用例,同時(shí)它也提供了 group 概念來(lái)組織測(cè)試用例。pytest 的 mock 是基于第三方的 pytest-mock,而 pytest-mock 實(shí)際上只是對(duì)標(biāo)準(zhǔn)庫(kù)中的 mock 的簡(jiǎn)單封裝。單元測(cè)試都會(huì)有 setup 和 teardown 的概念,unittest 直接使用了 setUp 和 tearDown 作為測(cè)試入口和結(jié)束的 API,在 pytest 中,則是通過(guò) fixture 來(lái)實(shí)現(xiàn),這方面學(xué)習(xí)曲線可能稍微陡峭一點(diǎn)。在斷言方面,pytest 使用 python 的關(guān)鍵字 assert 進(jìn)行斷言,比 unittest 更為簡(jiǎn)潔,不過(guò)斷言類型上沒(méi)有 unittest 豐富。
另外一個(gè)值得一提的區(qū)別是,unittest 從 python 3.8 起就內(nèi)在地支持 asyncio,而在 pytest 中,則需要插件 pytest-asyncio 來(lái)支持。但兩者在測(cè)試的兼容性上并沒(méi)有大的不同。
pytest 的主要優(yōu)勢(shì)是有:
- pytest 的測(cè)試用例更簡(jiǎn)潔。由于測(cè)試用例并不是正式代碼,開(kāi)發(fā)者當(dāng)然希望少花時(shí)間在這些代碼上,因此代碼的簡(jiǎn)潔程度很重要。
- 提供了命令行工具。如果我們僅使用 unittest,則執(zhí)行單元測(cè)試必須要使用
python -m unittest
來(lái)執(zhí)行;而通過(guò) pytest 來(lái)執(zhí)行單元測(cè)試,我們只需要調(diào)用pytest .
即可。 - pytest 提供了 marker,可以更方便地決定哪些用例執(zhí)行或者不執(zhí)行。
- pytest 提供了參數(shù)化測(cè)試。
這里我們簡(jiǎn)要地舉例說(shuō)明一下什么是參數(shù)化測(cè)試,以便讀者理解為什么參數(shù)化測(cè)試是一個(gè)值得一提的優(yōu)點(diǎn)。
# 示例 7 - 1
import pytest
from datetime import datetime
from src.example import get_time_of_day
@pytest.mark.parametrize(
"datetime_obj, expect",
[
(datetime(2016, 5, 20, 0, 0, 0), "Night"),
(datetime(2016, 5, 20, 1, 10, 0), "Night"),
(datetime(2016, 5, 20, 6, 10, 0), "Morning"),
(datetime(2016, 5, 20, 12, 0, 0), "Afternoon"),
(datetime(2016, 5, 20, 14, 10, 0), "Afternoon"),
(datetime(2016, 5, 20, 18, 0, 0), "Evening"),
(datetime(2016, 5, 20, 19, 10, 0), "Evening"),
],
)
def test_get_time_of_day(datetime_obj, expect, mocker):
mock_now = mocker.patch("src.example.datetime")
mock_now.now.return_value = datetime_obj
assert get_time_of_day() == expect?
在這個(gè)示例中,我們希望用不同的時(shí)間參數(shù),來(lái)測(cè)試 get_time_of_day 這個(gè)方法。如果使用 unittest,我們需要寫(xiě)一個(gè)循環(huán),依次調(diào)用 get_time_of_day(),然后對(duì)比結(jié)果。而在 pytest 中,我們只需要使用 parametrize 這個(gè)注解,就可以傳入?yún)?shù)數(shù)組(包括期望的結(jié)果),進(jìn)行多次測(cè)試,不僅代碼量要少不少,更重要的是,這種寫(xiě)法更加清晰。
基于以上原因,在后面的內(nèi)容中,我們將以 pytest 為例進(jìn)行介紹。
1. 測(cè)試代碼的組織
我們一般將所有的測(cè)試代碼都?xì)w類在項(xiàng)目根目錄下的 tests 文件夾中。每個(gè)測(cè)試文件的名字,要么使用 test_.py,要么使用_test.py。這是測(cè)試框架的要求。如此以來(lái),當(dāng)我們執(zhí)行命令如pytest tests
時(shí),測(cè)試框架就能從這些文件中發(fā)現(xiàn)測(cè)試用例,并組合成一個(gè)個(gè)待執(zhí)行的 suite。
在 test_*.py 中,函數(shù)名一樣要遵循一定的模式,比如使用 test_xxx。不遵循規(guī)則的測(cè)試函數(shù),不會(huì)被執(zhí)行。
一般來(lái)說(shuō),測(cè)試文件應(yīng)該與功能模塊文件一一對(duì)應(yīng)。如果被測(cè)代碼有多重文件夾,對(duì)應(yīng)的測(cè)試代碼也應(yīng)該按同樣的目錄來(lái)組織。這樣做的目的,是為了將商業(yè)邏輯與其測(cè)試代碼對(duì)應(yīng)起來(lái),方便我們添加新的測(cè)試用例和對(duì)測(cè)試用例進(jìn)行重構(gòu)。
比如在 ppw 生成的示例工程中,我們有:
sample
├── sample
│ ├── __init__.py
│ ├── app.py
│ └── cli.py
├── tests
│ ├── __init__.py
│ ├── test_app.py
│ └── test_cli.py
注意這里面的__init__.py 文件,如果缺少這個(gè)文件的話,tests 就不會(huì)成為一個(gè)合法的包,從而導(dǎo)致 pytest 無(wú)法正確導(dǎo)入測(cè)試用例。
2. PYTEST
使用 pytest 寫(xiě)測(cè)試用例很簡(jiǎn)單。假設(shè) sample\app.py 如下所示:
# 示例 7 - 2
def inc(x:int)->int:
return x + 1
則我們的 test_app.py 只需要有以下代碼即可完成測(cè)試:
# 示例 7 - 3
import pytest
from sample.app import inc
def test_inc():
assert inc(3) == 4
這比 unittest 下的代碼要簡(jiǎn)潔很多。
2.1. 測(cè)試用例的組裝
在 pytest 中,pytest 會(huì)按傳入的文件(或者文件夾),搜索其中的測(cè)試用例并組裝成測(cè)試集合 (suite)。除此之外,它還能通過(guò) pytest.mark 來(lái)標(biāo)記哪些測(cè)試用例是需要執(zhí)行的,哪些測(cè)試用例是需要跳過(guò)的。
# 示例 7 - 4
import pytest
@pytest.mark.webtest
def test_send_http():
pass # perform some webtest test for your app
def test_something_quick():
pass
def test_another():
pass
class TestClass:
def test_method(self):
pass
然后我們就可以選擇只執(zhí)行標(biāo)記為 webtest 的測(cè)試用例:
$ pytest -v -m webtest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-7.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 4 items / 3 deselected / 1 selected
test_server.py::test_send_http PASSED [100%]
===================== 1 passed, 3 deselected in 0.12s ======================
從輸出可以看出,只有 test_send_http 被執(zhí)行了。
這里的 webtest 是自定義的標(biāo)記。pytest 還內(nèi)置了這些標(biāo)記,有的也可以用來(lái)篩選用例:
- pytest.mark.filterwarnings, 給測(cè)試用例添加 filterwarnings 標(biāo)記,可以忽略警告信息。
- pytest.mark.skip,給測(cè)試用例添加 skip 標(biāo)記,可以跳過(guò)測(cè)試用例。
- pytest.mark.skipif, 給測(cè)試用例添加 skipif 標(biāo)記,可以根據(jù)條件跳過(guò)測(cè)試用例。
- pytest.mark.xfail, 在某些條件下(比如運(yùn)行在某個(gè) os 上),用例本應(yīng)該失敗,此時(shí)就應(yīng)使用此標(biāo)記,以便在測(cè)試報(bào)告中標(biāo)記出來(lái)。
- pytest.mark.parametrize, 給測(cè)試用例添加參數(shù)化標(biāo)記,可以根據(jù)參數(shù)化的參數(shù)執(zhí)行多次測(cè)試用例。
這些標(biāo)記可以用 pytest --markers 命令查看。
2.2. pytest 斷言
在測(cè)試時(shí),當(dāng)我們調(diào)用一個(gè)方法之后,會(huì)希望將其返回結(jié)果與期望結(jié)果進(jìn)行比較,以決定該測(cè)試是否通過(guò)。這被稱之為測(cè)試斷言。
pytest 中的斷言巧妙地?cái)r截并復(fù)用了 python 內(nèi)置的函數(shù) assert,由于您很可能已經(jīng)接觸過(guò) assert 了,因而使得這一部分的學(xué)習(xí)成本變得非常低。
# 示例 7 - 5
def test_assertion():
# 判斷基本變量相等
assert "loud noises".upper() == "LOUD NOISES"
# 判斷列表相等
assert [1, 2, 3] == list((1, 2, 3))
# 判斷集合相等
assert set([1, 2, 3]) == {1, 3, 2}
# 判斷字典相等
assert dict({
"one": 1,
"two": 2
}) == {
"one": 1,
"two": 2
}
# 判斷浮點(diǎn)數(shù)相等
# 缺省地, ORIGIN ± 1E-06
assert 2.2 == pytest.approx(2.2 + 1e-6)
assert 2.2 == pytest.approx(2.3, 0.1)
# 如果要判斷兩個(gè)浮點(diǎn)數(shù)組是否相等,我們需要借助 NUMPY.TESTING
import numpy
arr1 = numpy.array([1., 2., 3.])
arr2 = arr1 + 1e-6
numpy.testing.assert_array_almost_equal(arr1, arr2)
# 異常斷言:有些用例要求能拋出異常
with pytest.raises(ValueError) as e:
raise ValueError("some error")
msg = e.value.args[0]
assert msg == "some error"
上面的代碼分別演示了如何判斷內(nèi)置類型、列表、集合、字典、浮點(diǎn)數(shù)和浮點(diǎn)數(shù)組是否相等。這部分語(yǔ)法跟標(biāo)準(zhǔn) python 語(yǔ)法并無(wú)二致。pytest 與 unittest 一樣,都沒(méi)有提供如何判斷兩個(gè)浮點(diǎn)數(shù)數(shù)組是否相等的斷言,如果有這個(gè)需求,我們可以求助于 numpy.testing,正如例子中第 25~30 行所示。
有時(shí)候我們需要測(cè)試錯(cuò)誤處理,看函數(shù)是否正確地拋出了異常,代碼 32~37 演示了異常斷言的使用。注意這里我們不應(yīng)該這么寫(xiě):
# 示例 7 - 6
try:
# CALL SOME_FUNC WILL RAISE VALUEERROR
except ValueError as e:
assert str(e) == "some error":
else:
assert False
上述代碼看上去邏輯正確,但它混淆了異常處理和斷言,使得他人一時(shí)難以分清這段代碼究竟是在處理測(cè)試代碼中的異常呢,還是在測(cè)試被調(diào)用函數(shù)能否正確拋出異常,明顯不如異常斷言那樣清晰。
2.3. pytest fixture
一般而言,我們的測(cè)試用例很可能需要依賴于一些外部資源,比如數(shù)據(jù)庫(kù)、緩存、第三方微服務(wù)等。這些外部資源的初始化和銷毀,我們希望能夠在測(cè)試用例執(zhí)行前后自動(dòng)完成,即自動(dòng)完成 setup 和 teardown 的操作。這時(shí)候,我們就需要用到 pytest 的 fixture。
假定我們有一個(gè)測(cè)試用例,它需要連接數(shù)據(jù)庫(kù),代碼如下(參見(jiàn) code/chap07/sample/app.py)
# 示例 7 - 7
import asyncpg
import datetime
async def add_user(conn: asyncpg.Connection, name: str, date_of_birth: datetime.date)->int:
# INSERT A RECORD INTO THE CREATED TABLE.
await conn.execute('''
INSERT INTO users(name, dob) VALUES($1, $2)
''', name, date_of_birth)
# SELECT A ROW FROM THE TABLE.
row: asyncpg.Record = await conn.fetchrow(
'SELECT * FROM users WHERE name = $1', 'Bob')
# *ROW* NOW CONTAINS
# ASYNCPG.RECORD(ID=1, NAME='BOB', DOB=DATETIME.DATE(1984, 3, 1))
return row["id"]
我們先展示測(cè)試代碼(參見(jiàn) code/chap07/sample/test_app.py),再結(jié)合代碼講解 fixture 的使用:
# 示例 7 - 8
import pytest
from sample.app import add_user
import pytest_asyncio
import asyncio
# PYTEST-ASYNCIO 已經(jīng)提供了一個(gè) EVENT_LOOP 的 FIXTURE, 但它是 FUNCTION 級(jí)別的
# 這里我們需要一個(gè) SESSION 級(jí)別的 FIXTURE,所以我們需要重新實(shí)現(xiàn)
@pytest.fixture(scope="session")
def event_loop():
policy = asyncio.get_event_loop_policy()
loop = policy.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope='session')
async def db():
import asyncpg
conn = await asyncpg.connect('postgresql://zillionare:123456@localhost/bpp')
yield conn
await conn.close()
@pytest.mark.asyncio
async def test_add_user(db):
import datetime
user_id = await add_user(db, 'Bob', datetime.date(2022, 1, 1))
assert user_id == 1
我們的功能代碼很簡(jiǎn)單,就是往 users 表里插入一條記錄,并返回它在表中的 id。測(cè)試代碼調(diào)用 add_user 這個(gè)函數(shù),然后檢測(cè)返回值是否為 1(如果每次測(cè)試前都新建數(shù)據(jù)庫(kù)或者清空表的話,那么返回的 ID 就應(yīng)該是 1)。
這個(gè)測(cè)試顯然需要連接數(shù)據(jù)庫(kù),因此我們需要在測(cè)試前創(chuàng)建一個(gè)數(shù)據(jù)庫(kù)連接,然后在測(cè)試結(jié)束后關(guān)閉連接。并且,我們還會(huì)有多個(gè)測(cè)試用例需要連接數(shù)據(jù)庫(kù),因此我們希望數(shù)據(jù)庫(kù)連接是一個(gè)全局的資源,可以在多個(gè)測(cè)試用例中共享。這就是 fixture 的用武之地。
fixture 是一些函數(shù),pytest 會(huì)在執(zhí)行測(cè)試函數(shù)之前(或之后)加載運(yùn)行它們。但與 unitest 中的 setup 和 teardown 不同,pytest 中的 fixture 依賴是顯式聲明的。比如,在上面的 test_add_user 顯式依賴了 db 這個(gè) fixture(通過(guò)在函數(shù)聲明中傳入 db 作為參數(shù)),而 db 則又顯示依賴 event_loop 這個(gè) fixture。即使文件中還存在其它 fixture, test_add_user 也不會(huì)依賴到這些 fixture,因?yàn)橐蕾嚤仨氾@式聲明。
上面的代碼中,我們演示的是對(duì)異步函數(shù) add_user 的測(cè)試。顯然,異步函數(shù)必須在某個(gè) event loop 中執(zhí)行,并且相關(guān)的初始化 (setup) 和退出操作 (teardown) 也必須在同一個(gè) loop 中執(zhí)行。這里是分別通過(guò) pytest.mark.asyncio, pytest_asyncio 等 fixture 來(lái)實(shí)現(xiàn)的:
首先,我們需要將測(cè)試用例標(biāo)注為異步執(zhí)行,即上面的代碼第 21 行。其次,test_add_user 需要一個(gè)數(shù)據(jù)庫(kù)連接,該連接由 fixture db
來(lái)提供。這個(gè)連接的獲得也是異步的,因此,我們不能使用 pytest.fixutre 來(lái)聲明該函數(shù),而必須使用@pytest_asyncio.fixture 來(lái)聲明該函數(shù)。
最后,我們還必須提供一個(gè) event_loop 的 fixture,它是一切的關(guān)鍵。當(dāng)某個(gè)函數(shù)被 pytest.mark.asyncio 裝飾時(shí),該函數(shù)將在 event_loop 提供的 event loop 中執(zhí)行。
我們還要介紹一下出現(xiàn)在第 6 行和第 13 行中的 scope=‘session’。這個(gè)參數(shù)表示 fixture 的作用域,它有四個(gè)可選值:function, class, module 和 session。默認(rèn)值是 function,表示 fixture 只在當(dāng)前測(cè)試函數(shù)中有效。在上面的示例中,我們希望這個(gè) event loop 在一次測(cè)試中都有效,所以將 scope 設(shè)置為 session。
上面的例子是關(guān)于異步模式下的測(cè)試的。對(duì)普通函數(shù)的測(cè)試更簡(jiǎn)單一些。我們不需要 pytest.mark.asynio 這個(gè)裝飾器,也不需要 event_loop 這個(gè) fixture。所有的 pytest_asyncio.fixture 都換成 pytest.fixture 即可(顯然,它必須、也只能裝飾普通函數(shù),而非由 async 定義的函數(shù))。
我們通過(guò)上面的例子演示了 fixture。與 markers 類似,要想知道我們的測(cè)試環(huán)境中存在哪些 fixtures,可以通過(guò) pytest --fixtures 來(lái)顯示當(dāng)前環(huán)境中所有的 fixture。
$ pytest --fixtures
------------- fixtures defined from faker.contrib.pytest.plugin --------------
faker -- .../faker/contrib/pytest/plugin.py:24
Fixture that returns a seeded and suitable ``Faker`` instance.
------------- fixtures defined from pytest_asyncio.plugin -----------------
event_loop -- .../pytest_asyncio/plugin.py:511
Create an instance of the default event loop for each test case.
...
------------- fixtures defined from tests.test_app ----------------
event_loop [session scope] -- tests/test_app.py:45
db [session scope] -- tests/test_app.py:52
這里我們看到 faker.contrib 提供了一個(gè)名為 faker 的 fixture, 我們之前安裝的、支持異步測(cè)試的 pytest_asyncio 也提供了名為 event_loop 的 fixture(為節(jié)省篇幅,其它幾個(gè)省略了),以及我們自己測(cè)試代碼中定義的 event_loop 和 db 這兩個(gè) fixture。
Pytest 還提供了一類特別的 fixture,即 pytest-mock。為了講解方便,我們先安裝 pytest-mock 這個(gè)插件,看看它提供的 fixture。
$ pip install pytest-mock
pytest --fixture
------- fixtures defined from pytest_mock.plugin --------
class_mocker [class scope] -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
mocker -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
module_mocker [module scope] -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
package_mocker [package scope] -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
session_mocker [session scope] -- .../pytest_mock/plugin.py:419
Return an object that has the same interface to the `mock` module, but
takes care of automatically undoing all patches after each test method.
可以看到 pytest-mock 提供了 5 個(gè)不同級(jí)別的 fixture。關(guān)于什么是 mock,這是下一節(jié)的內(nèi)容。文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-796674.html
本文摘自《Python能做大項(xiàng)目》,將由機(jī)械工業(yè)出版社出版。全書(shū)已發(fā)表在大富翁量化上,歡迎提前閱讀!文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-796674.html
到了這里,關(guān)于單元測(cè)試:Testing leads to failure, and failure leads to understanding的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!