Python 測試入門 - 仿製物件
Python 測試入門系列之三,建立仿製物件(mock)
在撰寫測試腳本時,我們會有一種需求:需要把對應的環境或參數等準備好,那麼待測函數才能輸出預期的結果;這個「測試的環境」就是 fixture。
這其中的一個環節便是怎麼去生成一個仿製物件、怎麼對環境打補丁,以達到控制環境的效果。
為什麼需要仿製物件
在寫測試時,如果今天待測單元只是一隻演算法,那麼我們透過輸入參數的調控差不多就足夠覆蓋各種狀況:
def add(a, b):
return a + b
def test_add100():
assert add(1, 2) == 3
assert add(3, 5) == 8
但現實世界沒這麼單純,我們大概率會出現一些功能需要去依賴其他函數甚至外部函式庫:
def remove_user(user_id):
resp = requests.delete(f"https://example.com/user/{user_id}")
return resp.status_code == 200
那總不可能每次都準備好一個使用者擺在那邊給人砍吧,這樣整個環境的設置成本有夠高啊,所以我們會需要去把相依的函數 mock 掉。
(務實上如果今天你真的是需要 mock 掉 requests,那建議使用 responses,不用重新造輪子)
Mock
首先介紹來自 unittest.mock 這個內建模組的 Mock ,注意它不會在 import unittest
的時候被帶進來,需要特別明示來調用:
from unittest.mock import Mock, patch
Mock
是一個十分好用的物件,幾個常見需求都包在裡面了:
模擬為特定的物件
Mock
的初始化參數裡面有個spec
可以讓其假裝為其他型別,這可以帶來幾個好處——其一,跳過繁複的初始化環節,直接取得一個可以通過型別檢查的測資:
fake_dict = Mock(spec=dict)assert isinstance(fake_dict, dict) # pass!其二,它會根據
spec
依據其成員及屬性自動建立子物件方便使用,並在我們碰到不存在的成員時跳出錯誤:In []: fake_dict.getOut []: <Mock name='mock.get' id='4335719424'>In []: fake_dict.item---------------------------------------------------------------------------AttributeError Traceback (most recent call last)Input In [], in <cell line: 1>()----> 1 fake_dict.itemFile /opt/homebrew/Cellar/python@3.9/3.9.13_4/Frameworks/Python.framework/Versions/3.9/lib/python3.9/unittest/mock.py:630, in NonCallableMock.__getattr__(self, name)628 elif self._mock_methods is not None:629 if name not in self._mock_methods or name in _all_magics:--> 630 raise AttributeError("Mock object has no attribute %r" % name)631 elif _is_magic(name):632 raise AttributeError(name)AttributeError: Mock object has no attribute 'item'這樣可以同時懶人地不去仿製某個型別的所有成員,但是打錯 API 名稱這類蠢問題的依然可以報錯。
控制輸出
我們設定
Mock
直接輸出某些參數給呼叫方、或是讓他模擬出各種錯誤。第一個控制參數是 return_value,作為該
Mock
物件被呼叫時永遠回傳的內容,這個關鍵字可以作為初始參數、也能在物件初始完成後置換:In []: item = Mock()In []: item = Mock(return_value=1234)In []: item()Out[]: 1234In []: item.return_value = 4567In []: item()Out[]: 4567第二個參數則是 side_effect,它的行為則比較多元——可以把參數傳遞給另一個函數執行、或依序輸出、或製造一個 exception。另外注意
side_effect
會覆蓋掉return_value
的行為:# 設為另一個函數時,他就是個送信仔In []: item.side_effect = lambda x: x + 2In []: item(123)Out []: 125# 設為一個 list 時,依序回傳;若呼叫次數操過,則報錯In []: item.side_effect = [1,2]In []: item()Out[]: 1In []: item()Out[]: 2In []: item()---------------------------------------------------------------------------StopIteration Traceback (most recent call last)...# 設定為 exception 時,則把它丟出來In []: item.side_effect = RuntimeError('test error')In []: item()---------------------------------------------------------------------------RuntimeError Traceback (most recent call last)Input In [], in <cell line: 1>()----> 1 item()RuntimeError: test error驗證輸入
Mock
物件會記錄下對這個物件被呼叫的歷史,故我們也可以利用它去驗證代測函數是否有對某個外部 API 正確地呼叫。對應的屬性包含:
- call_count 紀錄總共被呼叫的次數
- call_args 紀錄最後一次被呼叫時的參數
- call_args_list 紀錄每次被呼叫的參數
快速理解就是前兩個可以算是
call_args_list
簡化版,可以讓測試腳本不要寫太冗。對應可以拿到的資訊大概長這樣:
In []: item(1, 2, 3, 4)In []: item(5, 6, a=7, b=8)In []: item.call_countOut []: 2In []: item.call_argsOut []: call(5, 6, a=7, b=8)In []: item.call_args_listOut []: [call(1, 2, 3, 4), call(5, 6, a=7, b=8)]說一下回傳的 call 物件——用 Python typing 的描述下會一個是
Tuple[Tuple[Any, ...], Dict[str, Any]]
物件:它是一個帶著 2 個 元素tuple
,分別是args
及kwargs
,取得的格式則對應到tuple
及dict
,畢竟Mock
本身就是用def __call__(*args, **kwargs)
去接收輸入。Python 3.8 起
call
轉為 NamedTuple 故我們可以直接用item.call_args.args[0]
這類的方法取值。在這之前的版本則需要用 index 或是 unpacking,要碼可讀性降低、要碼程式變長。另外,若是測試輸入內容,
Mock
本身也提供了幾個成員可直接呼叫:assert_called()
確認這個物件曾經被呼叫 / 其相反的行為有個assert_not_called
確認這個物件不曾被呼叫assert_called_with(*args, **kwargs)
確認這個物件 最後一次被呼叫時 是這個參數組合assert_any_call(*args, **kwargs)
確認這個物件 曾經 被呼叫時是這個參數組合
個人經驗上,多數的測試都是用上面的幾個功能的排列組合,以上面提到的情境下,我們會想要去把 requests
的 delete
mock 掉,然後確認參數輸入,故就會誕生這樣的測試腳本:
# 注意這段程式只是示意,這段程式碼沒有用!
fake_delete = Mock()
remove_user(1234)
fake_delete.assert_called_once_with("https://example.com/user/1234")
很明顯地這裡缺了一個環節:系統怎麼知道 Mock
是要取代掉誰?
這就是 patch 的工作了。
patch
顧名思義,打補丁,這是個同樣來自 unittest.mock
的函數,邏輯上它幫我們處理的工作會是在某段測試腳本開始之時,去把某個物件的指標指向我們設定的 其他物件(可以是 Mock
也可以是其他東西),並在測試腳本結束之時復原這一切。
首先來個基本的使用場景:
# demo/foo.py
def add_random(a: int, max_: int = 100):
return a + random.randint(0, max_)
# tests/test_foo.py
def test_add_random():
# 這段程式有冗余,要抄的抄下一段
randint = Mock(return_value=45)
with patch("random.randint", randint):
assert foo.add_random(5) == 50
assert randint.call_args == call(0, 100)
patch
的其中一個功能就是置換,在上面的操作中我們設定了一個 Mock
物件、其回傳永遠是 45
,而 patch
則會在那個 with
的範圍內去把 random.randint
取代為我們指定的仿製物件。
不過上面的程式有冗余,那就是 Mock
物件本身。patch
在 不指定回傳 的時候會自動建立一個 MagicMock 物件作為回傳(可以快速理解為 Mock
的加強版),並且在 patch
的參數中我們也可以見到它幾乎相容了 Mock
的各個參數。故我們可以把上面的程式簡化為:
def test_add_random():
with patch("random.randint", return_value=45) as randint:
assert foo.add_random(5) == 50
assert randint.call_args == call(0, 100)
patch
也可用其他方式啟動,如作為裝飾器(function decorator):
@patch("random.randint", return_value=45)
def test_add_random(randint: MagicMock):
assert foo.add_random(5) == 50
assert randint.call_args == call(0, 100)
在這個使用方法下,這個 patch
的生效範圍就會變成整個 test_add_random
。
另外要注意的地方是在多數時 patch
會新增一個輸入給函數本身,且這個輸入參數的名稱是使用者完全自訂,故有多個 patch
要使用的時候要特別小心順序問題。這種需求可以考慮全部搬到函數裡面用情境管理器(context manager)去處理,利用 with ... as ...
的語法會讓這一切變成明示的一對一對應,或是使用 monkeypatch
去處理。
Monkey patch
monkeypatch 是 PyTest 所提供的 fixture,其基礎功能跟 patch
十分接近。以上面的情境來說可以代換為下面的寫法:
def mock_randint(*args):
assert args == (0, 100)
return 45
def test_add_random(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("random.randint", mock_randint)
assert foo.add_random(5) == 50
實際上的使用差異有幾個:
monkeypatch.setattr
不會自動建立 mock 物件甚至可以注意到的是在官方教學裡面 是沒有搭配
unittest
的Mock
使用的。它的邏輯比較接近請使用者實作出一個簡明的仿製函數來取代那個比較複雜的相依。自動的復原機制
Monkey patch 系列的功能預設就是在呼叫的當下生效、到對應測試結束時復原,相較於
patch
對需要新增多個裝飾器、並且會需要去小心參數輸入順序,這樣的程式有時候讀起來比較乾淨。不過如果只想要針對某幾行程式碼打補丁,monkey patch 也是可以作為情境管理器去使用的:
def test_add_random(monkeypatch: pytest.MonkeyPatch):with monkeypatch.context() as m:m.setattr("random.randint", mock_randint)assert foo.add_random(5) == 50是以 PyTest fixture1 的形式輸入
(這個系列文章到目前還沒談到 PyTest fixture,簡單理解就是在測試函數的參數中放入
monkeypatch
這個關鍵字、然後 PyTest 就會去把MonkeyPatch
建立好並餵給測試函數。)不若
patch
是一個單純的函數,monkey patch 通常是透過 fixture 取得1,所以在某些不能直接摸到 fixture 的地方、如setup_method
會比較麻煩一點(當然這不是個很大的影響,事實上可以透過autouse= True
的 fixture 去完成相同的工作,後面談到這塊的章節再補充)。
其他設置
patch
/ monkey patch 還有一些功能在上面沒舉例到。例如我們可能會想要某個物件的特定 attribute 做調整,那麼就可以利用 patch.object (Monkey patch 則是繼續使用 setattr
),如果需要調整的內容是一個 dict
,則有 patch.dict 及 MonkeyPatch.setitem 。又如果今天要調整的那個字典是環境變數(os.environ),monkey patch 下還有 MonkeyPatch.setenv 可以應用:
def test_getenv():
with patch.dict("os.environ", {"EXAMPLE": "HELLO, WORLD!"}):
assert os.getenv("EXAMPLE") == "HELLO, WORLD!"
def test_getenv(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("EXAMPLE", "HELLO, WORLD!")
assert os.getenv("EXAMPLE") == "HELLO, WORLD!"
小結
在使用 PyTest 的時候由於其相容性很高,對於 patch
及 monkey patch 的應用場合可以任意切換的。
在不少的時候,monkey patch 的 API 可以讓程式看起來更為簡潔;另一方面,patch
搭配 return_value
及 side_effect
參數則可以直接少寫一組 mock function,並且可讀性還會比 lambda 高。
目前理解上在這塊似乎並沒有所謂的 best practice,所以有看個人喜歡用哪個吧。
續讀:
https://blog.tzing.tw/posts/python-testing-pytest-fixture-91b547f2
PyTest 6.2 (2020 Dec)開始可以直接透過
pytest.MonkeyPatch()
去建立monkeypatch
物件。↩