Python 單元測試與除錯:深入了解 mock 模組
簡介
在開發 Python 應用程式時,單元測試是保證程式正確性與可維護性的根本工具。
然而,許多程式碼會與外部資源(例如資料庫、網路服務、檔案系統)或複雜的物件互動,直接對這些依賴進行測試往往既慢又不穩定。
這時 mock(模擬)技術就顯得格外重要。Python 標準庫中的 unittest.mock(在 Python 3.3 之後直接內建)允許我們 「偽造」 任何函式、類別或屬性,讓測試僅聚焦於被測試單元本身的行為,而不必真的呼叫外部服務。
本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整介紹如何在 Python 測試中活用 mock 模組,幫助初學者快速上手,也讓中階開發者提升測試品質與除錯效率。
核心概念
1. 為什麼需要 Mock?
- 隔離測試:將測試對象(System Under Test, SUT)與其依賴分離,避免外部因素干擾測試結果。
- 提升效能:不必真的連線資料庫或呼叫第三方 API,測試執行速度可提升數十倍。
- 可預測的回傳:使用 mock 可以自行定義回傳值或拋出例外,方便驗證錯誤處理邏輯。
簡單來說,mock 就是「假裝」某個物件真的存在,卻讓我們自行決定它的行為。
2. unittest.mock 的主要工具
| 工具 | 說明 |
|---|---|
Mock |
最基礎的偽造物件,可自行設定屬性、回傳值與呼叫次數驗證。 |
MagicMock |
繼承自 Mock,自動為大部分「魔術方法」 (__len__、__getitem__ 等) 提供 mock 行為。 |
patch |
裝飾器或上下文管理器,用於 暫時取代 指定模組或類別的實作。 |
patch.object |
只針對已載入的物件(例如類別或實例)進行替換。 |
call |
用於驗證 mock 被呼叫的參數與次序。 |
3. 使用 patch 的原理
patch 會在測試執行期間 把目標名稱指向一個新的 Mock 物件,測試結束後自動還原。
重要的是 要 patch 目標的「引用路徑」,而不是原始定義的路徑。
例如,若
module_a.py內from module_b import fetch_data,在測試module_a時必須 patchmodule_a.fetch_data,而不是module_b.fetch_data,因為module_a已經把fetch_data直接引用進自己的命名空間。
程式碼範例
以下示範 5 個常見且實用的 mock 用法,均以 Python 3 為例,程式碼區塊使用 python 標記。
範例 1:最簡單的 Mock 物件
from unittest.mock import Mock
# 建立一個 Mock,預設回傳值為 None
dummy = Mock()
print(dummy()) # => None
# 設定回傳值
dummy.return_value = 42
print(dummy()) # => 42
# 驗證呼叫次數
dummy()
dummy(1, 2, key='value')
print(dummy.call_count) # => 2
print(dummy.call_args) # => call(1, 2, key='value')
重點:
Mock會自動記錄每一次的呼叫資訊,方便在測試結束後斷言 (assert)。
範例 2:使用 patch 替換函式
假設有一個簡單的 HTTP 客戶端 api.py:
# api.py
import requests
def get_user(user_id):
resp = requests.get(f"https://example.com/users/{user_id}")
resp.raise_for_status()
return resp.json()
在測試 get_user 時,我們不想真的發出網路請求:
# test_api.py
import unittest
from unittest.mock import patch
from api import get_user
class TestGetUser(unittest.TestCase):
@patch('api.requests.get') # patch 目標是 api 模組內的 requests.get
def test_success(self, mock_get):
# 設定 mock 的回傳值
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {'id': 1, 'name': 'Alice'}
result = get_user(1)
self.assertEqual(result['name'], 'Alice')
mock_get.assert_called_once_with('https://example.com/users/1')
技巧:
mock_get.return_value.json.return_value直接鏈式設定json()方法的回傳結果。
範例 3:使用 MagicMock 模擬容器行為
from unittest.mock import MagicMock
# 假設有一個函式會遍歷傳入的序列
def process_items(seq):
total = 0
for item in seq:
total += item
return total
# 用 MagicMock 來偽造一個支援迭代的物件
mock_seq = MagicMock()
mock_seq.__iter__.return_value = iter([1, 2, 3, 4])
print(process_items(mock_seq)) # => 10
MagicMock 自動提供 __iter__、__len__、__getitem__ 等魔術方法,使其可以像真實容器一樣被使用。
範例 4:patch.object 直接替換類別方法
# db.py
class Database:
def connect(self):
# 真實環境會連線資料庫
...
def fetch(self, sql):
# 執行查詢
...
# test_db.py
import unittest
from unittest.mock import patch, MagicMock
from db import Database
class TestDatabase(unittest.TestCase):
def test_fetch(self):
db = Database()
# 只 patch db 實例的 fetch 方法
with patch.object(db, 'fetch', return_value=[{'id': 1}]) as mock_fetch:
result = db.fetch('SELECT * FROM users')
self.assertEqual(result, [{'id': 1}])
mock_fetch.assert_called_once_with('SELECT * FROM users')
patch.object 讓我們 只針對單一實例 進行 mock,避免影響其他同類別的物件。
範例 5:驗證多次呼叫的參數順序(call)
from unittest.mock import Mock, call
def workflow(service):
service.start()
service.process('step1')
service.process('step2')
service.finish()
mock_service = Mock()
workflow(mock_service)
expected_calls = [
call.start(),
call.process('step1'),
call.process('step2'),
call.finish()
]
assert mock_service.mock_calls == expected_calls
mock_calls 會以 呼叫順序 收集所有方法與參數,使用 call 物件可以直觀比對。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| Patch 錯誤路徑 | 把目標 patch 成錯誤的模組路徑,導致測試仍然呼叫真實實作。 | 先確認 引用位置(使用 print(module.__file__) 或 inspect.getsource)再 patch。 |
| 忘記還原 | 手動替換屬性後未還原,會影響其他測試。 | 使用 with patch(...): 或裝飾器,自動還原。 |
| 過度 Mock | 把太多的依賴都 mock,測試失去意義,等於測試 mock 本身。 | 只 mock 外部或成本高 的依賴,保留核心邏輯的真實執行。 |
Mock 物件的預設行為是 MagicMock |
有時忘記使用 return_value,導致呼叫 .json() 時回傳另一個 Mock。 |
明確設定 mock_obj.method.return_value,或使用 side_effect。 |
| Side Effect 與 Return Value 同時使用 | 兩者同時設定會產生衝突,導致測試不預期。 | 只使用其中一種,或在 side_effect 中自行處理回傳值。 |
最佳實踐
遵循「Arrange-Act-Assert」:先安排(Arrange)mock 的行為與狀態,接著執行(Act)被測試函式,最後斷言(Assert)呼叫與結果。
使用
autospec=True:在patch時加入autospec=True,可以自動產生與原始函式相同的簽名,避免傳入錯誤參數。@patch('module.func', autospec=True) def test_func(mock_func): mock_func.return_value = 123 ...盡量使用
assert_called_once_with:比手動檢查call_count更直觀。分層測試:單元測試只 mock 直接依賴,整合測試(integration test)則使用真實服務,確保系統整體運作。
文件化 Mock 行為:在測試程式碼中加入註解,說明為何要 mock、預期的回傳與例外情形,提升可讀性與維護性。
實際應用場景
| 場景 | 目的 | Mock 方式 |
|---|---|---|
| 呼叫第三方 REST API | 測試 API 客戶端的錯誤處理與資料解析。 | patch('package.requests.get'),設定 status_code、json() 回傳。 |
| 資料庫存取 | 測試 ORM 模型的商業邏輯,避免真的寫入資料庫。 | patch('module.Session'),使用 MagicMock 模擬 query()、add()、commit()。 |
| 檔案 I/O | 測試檔案讀寫流程,如 CSV 解析或設定檔載入。 | patch('builtins.open', mock_open(read_data='a,b\\n1,2'))。 |
| 長時間運算或外部程式呼叫 | 測試程式在子程序失敗時的回復機制。 | patch('subprocess.run', side_effect=CalledProcessError(...))。 |
| 時間與隨機性 | 測試依賴 datetime.now() 或 random.random() 的程式。 |
patch('module.datetime')、patch('module.random.random'),設定固定回傳值。 |
這些情境下,使用 mock 能讓測試快速、可靠且可重複,同時讓開發者在除錯時能清楚看到每一次依賴被呼叫的參數與次數。
總結
unittest.mock是 Python 標準庫中最強大的測試輔助工具,能 偽造函式、類別與屬性,讓單元測試聚焦於核心邏輯。- 正確的 patch 路徑、適度的 Mock 範圍、以及 斷言呼叫 的技巧,是寫出可靠測試的關鍵。
- 常見的陷阱(錯誤路徑、過度 Mock、忘記還原)只要遵循最佳實踐(
with patch、autospec、分層測試)即可輕鬆避免。 - 在實務開發中,從 API 客戶端、資料庫操作、檔案 I/O、子程序呼叫 到 時間與隨機性,幾乎所有與外部環境交互的程式碼,都可以透過
mock進行快速、可預測的測試。
掌握了 mock 的使用方法,你不僅能寫出 更快、更穩定 的測試套件,也能在除錯時更快定位問題根源,提升整體開發效能。祝你在 Python 測試的旅程中玩得開心、寫得順利!