5.4 Mock 與測試隔離
5.4 Mock 與測試隔離
當測試的程式碼依賴外部資源(檔案系統、網路、stdin/stdout)時,我們需要使用 Mock 來隔離這些依賴,確保測試的可靠性和速度。
為什麼需要 Mock?
問題場景
1def read_hook_input() -> dict:
2 """從 stdin 讀取 JSON 輸入"""
3 return json.load(sys.stdin)
4
5# 測試時如何提供 stdin?使用 Mock 解決
1from unittest.mock import patch
2from io import StringIO
3
4def test_read_hook_input():
5 json_input = '{"key": "value"}'
6
7 # 用 StringIO 替換 sys.stdin
8 with patch("sys.stdin", StringIO(json_input)):
9 result = read_hook_input()
10
11 assert result == {"key": "value"}unittest.mock 基礎
patch 裝飾器
1from unittest.mock import patch
2
3class TestMyFunction(unittest.TestCase):
4
5 @patch("module.function_to_mock")
6 def test_something(self, mock_func):
7 mock_func.return_value = "mocked result"
8 result = my_function()
9 self.assertEqual(result, "expected")patch 上下文管理器
1def test_something(self):
2 with patch("module.function") as mock_func:
3 mock_func.return_value = "mocked"
4 result = my_function()
5 self.assertEqual(result, "expected")實際範例:測試 Hook IO
來自 .claude/lib/tests/test_hook_io.py:
1import json
2import unittest
3from io import StringIO
4from unittest.mock import patch
5
6from hook_io import read_hook_input, write_hook_output
7
8
9class TestReadHookInput(unittest.TestCase):
10 """測試 read_hook_input 函式"""
11
12 def test_valid_json_input(self):
13 """測試有效的 JSON 輸入"""
14 test_data = {"tool_name": "Write", "file_path": "/test.txt"}
15 json_input = json.dumps(test_data)
16
17 # Mock sys.stdin
18 with patch("sys.stdin", StringIO(json_input)):
19 result = read_hook_input()
20
21 self.assertEqual(result, test_data)
22
23 def test_invalid_json_returns_empty_dict(self):
24 """測試無效的 JSON"""
25 with patch("sys.stdin", StringIO("not valid json")):
26 result = read_hook_input()
27
28 self.assertEqual(result, {})
29
30
31class TestWriteHookOutput(unittest.TestCase):
32 """測試 write_hook_output 函式"""
33
34 def test_output_json_format(self):
35 """測試輸出為有效的 JSON"""
36 test_data = {"decision": "allow"}
37
38 # Mock sys.stdout
39 with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
40 write_hook_output(test_data)
41 output = mock_stdout.getvalue()
42
43 # 驗證輸出是有效的 JSON
44 parsed = json.loads(output)
45 self.assertEqual(parsed["decision"], "allow")
46
47 def test_chinese_preserved(self):
48 """測試中文字元被保留"""
49 test_data = {"message": "你好"}
50
51 with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
52 write_hook_output(test_data, ensure_ascii=False)
53 output = mock_stdout.getvalue()
54
55 self.assertIn("你好", output)Mock 物件的設定
return_value
1from unittest.mock import Mock
2
3mock_func = Mock()
4mock_func.return_value = 42
5
6result = mock_func() # 42side_effect - 動態返回值
1from unittest.mock import Mock
2
3mock_func = Mock()
4
5# 依序返回不同值
6mock_func.side_effect = [1, 2, 3]
7mock_func() # 1
8mock_func() # 2
9mock_func() # 3
10
11# 根據輸入返回不同值
12def side_effect_func(x):
13 return x * 2
14
15mock_func.side_effect = side_effect_func
16mock_func(5) # 10side_effect - 拋出異常
1from unittest.mock import Mock
2
3mock_func = Mock()
4mock_func.side_effect = ValueError("Error!")
5
6mock_func() # 拋出 ValueError驗證 Mock 被呼叫
1from unittest.mock import Mock, call
2
3mock_func = Mock()
4
5# 呼叫 mock
6mock_func(1, 2, key="value")
7mock_func(3, 4)
8
9# 驗證呼叫
10mock_func.assert_called() # 被呼叫過
11mock_func.assert_called_once() # 只被呼叫一次(這會失敗)
12mock_func.assert_called_with(3, 4) # 最後一次呼叫的參數
13
14# 檢查所有呼叫
15mock_func.assert_has_calls([
16 call(1, 2, key="value"),
17 call(3, 4)
18])
19
20# 呼叫次數
21self.assertEqual(mock_func.call_count, 2)實際範例:測試 Git 工具
1import unittest
2from unittest.mock import patch, Mock
3
4from git_utils import run_git_command, get_current_branch
5
6
7class TestRunGitCommand(unittest.TestCase):
8
9 @patch("subprocess.run")
10 def test_successful_command(self, mock_run):
11 """測試成功的 git 命令"""
12 # 設定 mock 返回值
13 mock_result = Mock()
14 mock_result.returncode = 0
15 mock_result.stdout = "main\n"
16 mock_result.stderr = ""
17 mock_run.return_value = mock_result
18
19 success, output = run_git_command(["branch", "--show-current"])
20
21 self.assertTrue(success)
22 self.assertEqual(output, "main")
23
24 # 驗證 subprocess.run 被正確呼叫
25 mock_run.assert_called_once()
26 call_args = mock_run.call_args
27 self.assertEqual(call_args[0][0], ["git", "branch", "--show-current"])
28
29 @patch("subprocess.run")
30 def test_failed_command(self, mock_run):
31 """測試失敗的 git 命令"""
32 mock_result = Mock()
33 mock_result.returncode = 1
34 mock_result.stdout = ""
35 mock_result.stderr = "fatal: not a git repository"
36 mock_run.return_value = mock_result
37
38 success, output = run_git_command(["status"])
39
40 self.assertFalse(success)
41 self.assertIn("not a git repository", output)
42
43 @patch("subprocess.run")
44 def test_timeout(self, mock_run):
45 """測試命令超時"""
46 import subprocess
47 mock_run.side_effect = subprocess.TimeoutExpired("git", 10)
48
49 success, output = run_git_command(["status"], timeout=10)
50
51 self.assertFalse(success)
52 self.assertIn("timed out", output)MagicMock
MagicMock 自動支援魔術方法:
1from unittest.mock import MagicMock
2
3mock = MagicMock()
4
5# 自動支援各種操作
6mock[0] # 不會報錯
7mock.anything() # 返回另一個 MagicMock
8len(mock) # 返回預設值
9str(mock) # 返回字串測試檔案操作
使用 mock_open
1from unittest.mock import patch, mock_open
2
3def test_read_config():
4 config_content = '{"key": "value"}'
5
6 with patch("builtins.open", mock_open(read_data=config_content)):
7 result = load_config("config.json")
8
9 self.assertEqual(result["key"], "value")測試 Path 物件
1from unittest.mock import patch, Mock
2
3def test_check_file_exists():
4 with patch("pathlib.Path.exists") as mock_exists:
5 mock_exists.return_value = True
6
7 result = check_file_exists("/some/path")
8
9 self.assertTrue(result)patch 的位置
重要:patch 的目標是模組匯入的位置,而非定義的位置。
1# module_a.py
2from os import getcwd
3
4def my_function():
5 return getcwd()
6
7# test_module_a.py
8# 正確:patch 匯入的位置
9@patch("module_a.getcwd")
10def test_my_function(mock_getcwd):
11 ...
12
13# 錯誤:patch 定義的位置
14@patch("os.getcwd") # 不會生效!
15def test_my_function(mock_getcwd):
16 ...最佳實踐
1. 只 Mock 外部依賴
1# 好:Mock 外部系統
2@patch("subprocess.run")
3def test_git_command(self, mock_run):
4 ...
5
6# 不好:Mock 內部邏輯
7@patch("my_module.internal_helper")
8def test_my_function(self, mock_helper):
9 ... # 過度 mock 會讓測試變脆弱2. 使用 autospec
1from unittest.mock import patch
2
3# autospec 確保 mock 的簽名與原函式相同
4@patch("module.function", autospec=True)
5def test_something(self, mock_func):
6 # 如果呼叫簽名錯誤會報錯
7 mock_func("wrong", "args") # 可能報錯3. 清理 Mock
1def setUp(self):
2 self.patcher = patch("module.function")
3 self.mock_func = self.patcher.start()
4
5def tearDown(self):
6 self.patcher.stop() # 確保清理思考題
- patch 的目標為什麼是匯入位置而非定義位置?
Mock和MagicMock有什麼區別?- 什麼時候應該使用
autospec=True?
實作練習
- 為
get_current_branch()撰寫使用 Mock 的測試 - 測試一個讀取檔案的函式,使用
mock_open - 測試一個會拋出異常的外部呼叫,使用
side_effect
上一章:unittest 基礎 下一模組:物件導向設計