Python 測試入門 - PyTest Fixture

Python 測試入門系列之四,PyTest 的 Fixture 這個功能

在撰寫測試腳本時,我們會有一種需求:需要把對應的環境或參數等準備好,那麼待測函數才能輸出預期的結果;這個「測試的環境」就是 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,以下挑幾個我常用的介紹:

  1. caplog 會捕捉 logging 系統中的 log
  2. capsys 會捕捉 stdoutstderr
  3. monkeypatch 打補丁;本系列文章的 前一篇 有介紹到它
  4. tmp_path 提供一個臨時資料夾裝東西,用完清掉

小結

Fixture 這樣的設計帶來了極大的彈性,至於怎麼玩還是要慢慢熟悉了。

  1. 如果這個 fixture 就是單純的被放在 test_*.py 檔裡面,那就是那隻 test_*.py 腳本本身。而實際上會有更多複雜的情況,例如如果被放在 Test* 型別裡面,那可呼叫的範圍就限於那個型別;又 PyTest 有個 feature 是可以將 fixture 放在 conftest.py 讓它變成全域的存在。