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 是一個十分好用的物件,幾個常見需求都包在裡面了:

  1. 模擬為特定的物件

    Mock 的初始化參數裡面有個 spec 可以讓其假裝為其他型別,這可以帶來幾個好處——

    其一,跳過繁複的初始化環節,直接取得一個可以通過型別檢查的測資:

    fake_dict = Mock(spec=dict)
    assert isinstance(fake_dict, dict) # pass!

    其二,它會根據 spec 依據其成員及屬性自動建立子物件方便使用,並在我們碰到不存在的成員時跳出錯誤:

    In []: fake_dict.get
    Out []: <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.item
    File /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 名稱這類蠢問題的依然可以報錯。

  2. 控制輸出

    我們設定 Mock 直接輸出某些參數給呼叫方、或是讓他模擬出各種錯誤。

    第一個控制參數是 return_value,作為該 Mock 物件被呼叫時永遠回傳的內容,這個關鍵字可以作為初始參數、也能在物件初始完成後置換:

    In []: item = Mock()
    In []: item = Mock(return_value=1234)
    In []: item()
    Out[]: 1234
    In []: item.return_value = 4567
    In []: item()
    Out[]: 4567

    第二個參數則是 side_effect,它的行為則比較多元——可以把參數傳遞給另一個函數執行、或依序輸出、或製造一個 exception。另外注意 side_effect 會覆蓋掉 return_value 的行為:

    # 設為另一個函數時,他就是個送信仔
    In []: item.side_effect = lambda x: x + 2
    In []: item(123)
    Out []: 125
    # 設為一個 list 時,依序回傳;若呼叫次數操過,則報錯
    In []: item.side_effect = [1,2]
    In []: item()
    Out[]: 1
    In []: item()
    Out[]: 2
    In []: 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
  3. 驗證輸入

    Mock 物件會記錄下對這個物件被呼叫的歷史,故我們也可以利用它去驗證代測函數是否有對某個外部 API 正確地呼叫。

    對應的屬性包含:

    1. call_count 紀錄總共被呼叫的次數
    2. call_args 紀錄最後一次被呼叫時的參數
    3. call_args_list 紀錄每次被呼叫的參數

    快速理解就是前兩個可以算是 call_args_list 簡化版,可以讓測試腳本不要寫太冗。

    對應可以拿到的資訊大概長這樣:

    In []: item(1, 2, 3, 4)
    In []: item(5, 6, a=7, b=8)
    In []: item.call_count
    Out []: 2
    In []: item.call_args
    Out []: call(5, 6, a=7, b=8)
    In []: item.call_args_list
    Out []: [call(1, 2, 3, 4), call(5, 6, a=7, b=8)]

    說一下回傳的 call 物件——用 Python typing 的描述下會一個是 Tuple[Tuple[Any, ...], Dict[str, Any]] 物件:它是一個帶著 2 個 元素 tuple,分別是 argskwargs,取得的格式則對應到 tupledict,畢竟 Mock 本身就是用 def __call__(*args, **kwargs) 去接收輸入。

    Python 3.8 起 call 轉為 NamedTuple 故我們可以直接用 item.call_args.args[0] 這類的方法取值。在這之前的版本則需要用 index 或是 unpacking,要碼可讀性降低、要碼程式變長。

    另外,若是測試輸入內容,Mock 本身也提供了幾個成員可直接呼叫:

個人經驗上,多數的測試都是用上面的幾個功能的排列組合,以上面提到的情境下,我們會想要去把 requestsdelete 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 物件

    甚至可以注意到的是在官方教學裡面 是沒有搭配 unittestMock 使用的。它的邏輯比較接近請使用者實作出一個簡明的仿製函數來取代那個比較複雜的相依。

  • 自動的復原機制

    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.dictMonkeyPatch.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_valueside_effect 參數則可以直接少寫一組 mock function,並且可讀性還會比 lambda 高。

目前理解上在這塊似乎並沒有所謂的 best practice,所以有看個人喜歡用哪個吧。


續讀:

https://blog.tzing.tw/posts/python-testing-pytest-fixture-91b547f2

  1. PyTest 6.2 (2020 Dec)開始可以直接透過 pytest.MonkeyPatch() 去建立 monkeypatch 物件。