在撰寫測試腳本時,我們會有一種需求:需要把對應的環境或參數等準備好,那麼待測函數才能輸出預期的結果;這個「測試的環境」就是 fixture。
而其中一個環節就是怎麼準備參數,在某些測試中我們極有可能會需要以某些特設的物件/資料作為輸入,而準備這些資料可能十分繁瑣,且我們不會想把這段「準備過程」的程式碼到處複製貼上。
例如,我們可能有一些會連線到 MySQL 的函數需要做測試:
def query_db(client: MySQLdb.Connection, sql: str):
cursor = client.cursor()
cursor.execute(sql)
row = cursor.fetchone()
cursor.close()
return row
如果作為單元測試,我們會想要輸入一個仿製的 client 物件,那麼就需要一個設定妥當的 mock 物件作為輸入。如果作為功能測試,那麼可能是在設定好一個 MySQL container 之後要輸入一個已經設定好連線參數的 client。
這時候就是 PyTest fixture 上場的時機了。
用於準備測資
PyTest fixture 其中一個功能就是用於準備測試資料或環境:
@pytest.fixture()
def mysql_client():
return MySQLdb.connect(
host="localhost",
port=13306,
user="root",
passwd="example",
db="test",
)
def test_query_db(mysql_client: MySQLdb.Connection):
assert query_db(mysql_client, "SELECT COUNT(1) FROM example") == (1234,)
在這個範例中,我們在 mysql_client
中建立了一個對 MySQL 的真實連線,然後 test_query_db
會使用這個物件去測試 query_db
。
首先,只有那些有被使用 @pytest.fixture
這個裝飾器標記的函數會成為 fixture,且 PyTest 會直接使用其函數名稱為 ID,故在同個作用範圍1名稱必須唯一。
這個被標記為 fixture 的函數就負責在裡面做他需要做的工作,並且 return
物件供測試腳本調用。就這樣,一個用來回傳物件的函數、加上裝飾器,fixture 就完成了。
而調用的方法就是在 test_
函數新增一個參數,名稱設為 fixture 的函數名稱,你就會拿到它了。上面的 type annotation 只是要用來理解 code 用的,不作任何作用。
順帶一提,如果這個 fixture 會需要用到其他 fixture,那麼在建立 fixture 的函數上加入其他 fixture 作為輸入即可,PyTest 一樣會幫忙處理好。
需要關閉的資源
承前一個範例,我們建立了一個 MySQLdb.Connection
物件然後 return
出去作為測資,那麼資源管理的問題就冒出來了:開了連線卻沒有關閉。
PyTest 中就有設想到這樣的情境,它利用了 yield
會帶來惰性計算(lazy evaluation)這個特性,讓我們用 yield
去回傳測資,並方便在測試結束的時候做相對應的收尾操作。
故前面的 fixture 比較好的寫法會是:
@pytest.fixture()
def mysql_client():
conn = MySQLdb.connect(
host="localhost",
port=13306,
user="root",
passwd="example",
db="test",
)
yield conn
conn.close()
甚至,由於 MySQLdb.Connection
自身就能當情境管理器來使用:
@pytest.fixture()
def mysql_client():
with MySQLdb.connect(
host="localhost",
port=13306,
user="root",
passwd="example",
db="test",
) as conn:
yield conn
另外,考慮到資源啟動、關閉的成本,我們還可以利用 scope 這個參數去控制一個 fixture 的使用時機:
@pytest.fixture(scope="session")
def mysql_client():
...
以上面這個 session
為例就會重複利用那個 yield
出去的物件,直到整個測試結束再回來這裡面讓它做收拾;而預設值則是 function
,在每個測試結束就去做收拾。
搭配其他 Fixture 使用
承前面所說,fixture 不僅可以被測試函數呼叫,fixture 還可以被另一個 fixture 呼叫。例如我可能需要建立好某張表格用於某些測資,那麼就可以直接利用前面準備好的 DB 物件:
@pytest.fixture(scope="class")
def user_table(mysql_client: MySQLdb.Connection):
with mysql_client.cursor() as cur:
cur.execute("INSERT ...")
mysql_client.commit()
yield mysql_client
with mysql_client.cursor() as cur:
cur.execute("DELETE ...")
mysql_client.commit()
同樣地,我們也可以用這個特性去打補丁:
@pytest.fixture()
def patch_randomint(monkeypatch: pytest.MonkeyPatch):
# 使用 monkey patch
def mock_randint(*args):
return 45
monkeypatch.setattr("random.randint", mock_randint)
@pytest.fixture()
def patch_randomint():
# 使用 unittest.mock.patch
with patch("random.randint", return_value=45):
yield
內建的 Fixture
這裡 列出了所有 PyTest 內建的 fixture,以下挑幾個我常用的介紹:
- caplog 會捕捉
logging
系統中的 log - capsys 會捕捉
stdout
及stderr
- monkeypatch 打補丁;本系列文章的 前一篇 有介紹到它
- tmp_path 提供一個臨時資料夾裝東西,用完清掉
小結
Fixture 這樣的設計帶來了極大的彈性,至於怎麼玩還是要慢慢熟悉了。
如果這個 fixture 就是單純的被放在
test_*.py
檔裡面,那就是那隻test_*.py
腳本本身。而實際上會有更多複雜的情況,例如如果被放在Test*
型別裡面,那可呼叫的範圍就限於那個型別;又 PyTest 有個 feature 是可以將 fixture 放在conftest.py
讓它變成全域的存在。↩