前情提要
TL;DR
本篇筆記透過圖解來進一步說明 Mock 在測試中所扮演的角色。
此外針對 Python 常見的測試框架 unittest
及 pytest
,分別整理了常用的 Mock 語法。
在內容中所使用到的說明程式碼,你可以在 Github - pytest_101 取得。
前言
在前一篇筆記 : Pytest 101 - 給 Python 開發者的測試入門 (2) - Mock 基礎介紹 中,我們討論了 Mock 的基本概念,也針對幾個實際案例分享了 Mock 的使用方法。
然而,在實際撰寫測試的過程中,我發現自己對於 Mock 的認知還是有點模糊,在網路上搜尋資料時,常會看到不同的 Mock 語法,因此想要透過這篇筆記進一步梳理。
範例 - 呼叫 API
在今天的範例中,我們會撰寫一個 Function check_response_greater_than_0_5()
:
確認 Response 的數值是否大於 0.5
這個 Function 的目的很單純 :
- 呼叫外部的 API :
call_external_api()
- 嘗試讀取 Response 中的特定數值 :
"response_value"
- 依據
response_value
的數值進行判斷 :- 如果
response_value > 0.5
, 回傳True
- 如果
response_value <= 0.5
, 回傳False
- 如果
response_value
不存在, 拋出錯誤
- 如果
# src/check_response.py
from src.external import call_external_api
def check_response_greater_than_0_5() -> bool:
"""
判斷 call_external_api() 的數值是否大於 0.5
"""
external_result = call_external_api() # 呼叫其他 API
response_value = external_result.get("response_value", None) # 從 Response 中取得 response_value 的數值
if response_value is None:
raise KeyError("response_value not exists!") # 如果 response_value 不存在,拋出錯誤
elif external_result > 0.5:
return True
else:
return False
通常在撰寫測試的過程中,常會遇到某些 Function 或是外部元件包含「不確定性」,意思是 :
我們並不確定與外部元件互動時,得到的 Response 是什麼。
為了模擬這個「不確定性」,我們試著在 call_external_api()
中加入一些隨機性 :
# src/external.py
from typing import Dict
import time
import random
def call_external_api() -> Dict:
"""模擬呼叫外部的 API
Returns:
Dict: 外部 API 回傳的結果
"""
time.sleep(0.5)
# 有 50% 機率回傳沒有任何資料的 Dict
if random.random() < 0.5:
return {}
response_value = random.random()
return {"response_value": response_value}
上述程式碼會讓 call_external_api()
的結果有下列可能性 :
- 有 50% 機率會出現空白 Response :
{}
- 有 50% 機率會回傳隨機的數值 :
{"response_value": X} # X 為一個隨機數值
開始撰寫測試
接下來,讓我們開始嘗試對 check_response_greater_than_0_5()
撰寫測試,那麼我究竟要測試什麼呢 ?
我們再看一次 Function 的邏輯圖 :
從上圖中看到的三個情境,都必須是我們要納入測試的,但是問題來了 :
每次呼叫
call_external_api()
的結果都不同,該如何撰寫測試呢?
圖解 Mock
透過 Mock,我們可以在執行測試的當下,將 「不穩定的物件」替換成「特定的物件」,來確保程式碼的邏輯可以被測試。
舉例來說:
當我們 call_external_api()
時有 50% 的機率會得到 {}
,此時我們的 Function 應該要拋出錯誤訊息。
然而,如果我們真的在測試的過程中呼叫 call_external_api()
,執行 100 次測試中,理論上只有 50 次的情境會得到 {}
,其他 50 次會是隨機的 {"response_value": X}
。
當測試的配置相同時,理論上每次執行測試的結果都應該要完全相同!,如果每次執行測試,都會出現不同結果,這個測試也就失去意義了。
為了讓每次測試的結果都一致,我們可以先檢驗第一個條件 :
當 Response 為 {} 時,
check_response_greater_than_0_5()
必須要拋出錯誤
為了檢驗這個情境是否正確運行,我們需要先確保一件事情 :
讓 Response 回傳 {}
為了達成這個目的,我們可以透過 Mock 來產生一個 mock_api()
,替換掉原先的 call_external_api()
,使其在測試執行的當下,一定會回傳 {}
。
如此一來,我們在測試的當下不再需要擔心 call_external_api()
的結果是什麼。而是專注在 :
當
call_external_api()
的結果為 {} 時,當下函式的行為是否正常。
接下來,讓我們將 Mock 擴充到其他不同情境 :
- 當 Response 的
response_value > 0.5
時,需要回傳True
- 當 Response 不存在
response_value
時,拋出錯誤 - 當 Response 的
response_value <= 0.5
時,需要回傳False
在這三個測試情境下,分別針對 call_external_api()
的結果有一些先決條件,同樣的,我們可以用 Mock 來替換 :
從上圖可以發現,所謂的 Mock,其實就是在測試過程中,替換函式中的特定物件,目的是驗證特定情況下,函式的行為是否正常。
而這樣的行為,我們讓測試的過程中不再受限於 call_external_api()
的變化,而是透過 Mock 來作出「環境隔離」,藉著隔離這些具有「變異性」的物件,將測試的焦點著重在我們的邏輯判斷 (例如 : response_value > 0.5
時要 return True
)。
Mock 語法整理
初接觸 Mock 時,網路上關於 Mock 的範例會根據測試框架而有所不同,有時候會有點混淆。
了解 Mock 的概念後,接下來我們來整理不同框架間對於 Mock 的語法,今天想要針對兩種不同框架的 Mock 寫法進行分享 :
unittest
pytest_mock
unittest
我認為 unittest
套件的寫法是最好理解 (但可能不是最簡潔) 的。
以上圖為例,假設我們要建立一個 mock_api_1()
來取代 call_external_api()
,我們該怎麼做呢 ?
使用 unittest
前,需要掌握幾個重點 :
unittest
是 python 原生套件,不需要額外安裝unittest
可以透過 Context Switchmock.patch()
來建立 Mock 物件,語法如下 :from unittest import mock with mock.patch(<被替換的物件>) as <別名> : # 在 Context Switcher 範圍內只要出現 <被替換的物件>,就會被替換成 Mock 物件 ...
讓我們試著實作剛剛的範例 :
from unittest import mock # unittest 為 python 原生套件
def test_check_response_greater_than_0_5():
# 將 src.check_response 中的 call_external_api 替換成 mock_api_1 物件
with mock.patch("src.check_response.call_external_api") as mock_api_1:
# 指定 mock_api_1 物件的回傳值
mock_api_1.return_value = {"response_value": 0.95}
# 因為在 mock 的 context switcher 中,所以該 Function 執行過程中 \
# 會把 call_external_api() 替換為 mock_api_1
check_response_greater_than_0_5()
pytest-mock
而 pytest-mock
則是另一個我喜歡使用的工具,好處是寫法簡潔!
然而,對於初接觸 Mock 的使用者來說,可能會覺得概念有點跳躍。
但在理解 unittest
的寫法後,你可以將 pytest-mock
視為 unittest
的簡潔版!
使用 pytest-mock
前需要先安裝套件 :
pip install pytest-mock
在使用 pytest-mock
時需要掌握 1 個重點 :
使用前須先將
mocker
Fixture 加入 Test Case 中
你可以將 pytest-mock
想像成是幫你寫好一個名叫 mocker
的 Fixture,當你在 Test Case 中加入 mocker
Fixture 後,就可以直接呼叫 mocker.patch()
來進行替換,相較於 unittest
框架的 Context Switcher 來說,使用上會更簡潔。
from pytest_mock import MockFixture
from src.check_response import check_response_greater_than_0_5
def test_check_response_greater_than_0_5(mocker: MockFixture):
mock_api_2 = mocker.patch("src.check_response.call_external_api", return_value={"response_value": 0.25})
result = check_response_greater_than_0_5()
assert result is False
總結
讓我們來做個總結,在原先的架構中 :
由於我們無法確定 call_external_api()
的 Response 為何,當 Response 無法確定時,當然無法驗證後續的條件是否正確。
因此,我們透過 Mock 來替換 call_external_api()
,使得在測試過程中,call_external_api()
可以輸出「特定」的 Response。
有了固定的前提,我們才可以驗證後續 Function 的行為是否正確 :
最終的測試案例如下 :
from src.check_response import check_response_greater_than_0_5
from pytest_mock import MockFixture
from unittest import mock
import pytest
# 使用 unittest 寫法
def test_check_response_greater_than_0_5_true():
with mock.patch("src.check_response.call_external_api") as mock_api_1:
mock_api_1.return_value = {"response_value": 0.95}
result = check_response_greater_than_0_5()
assert result is True
# 使用 pytest-mock 寫法
def test_check_response_greater_than_0_5_false(mocker: MockFixture):
return_value = {"response_value": 0.25}
mock_api_2 = mocker.patch("src.check_response.call_external_api", return_value=return_value)
result = check_response_greater_than_0_5()
assert result is False
# 使用 pytest-mock 寫法
def test_check_response_greater_than_0_5_error(mocker: MockFixture):
return_value = {}
mock_api_3 = mocker.patch("src.check_response.call_external_api", return_value=return_value)
with pytest.raises(KeyError):
check_response_greater_than_0_5() # 預期要拋出 KeyError
在今天的筆記中,我們用圖解的方式說明 Mock 在測試中扮演的角色,並且整理了 Python 開發者常會使用到的 Mock 語法。
希望以上內容能幫助你更了解 Mock 是什麼,並且讓你可以實際在測試中使用 Mock!
有問題歡迎在下方留言!我們下次見!