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
8class TestReadHookInput(unittest.TestCase):
9 """測試 read_hook_input 函式"""
10
11 def test_valid_json_input(self):
12 """測試有效的 JSON 輸入"""
13 test_data = {"tool_name": "Write", "file_path": "/test.txt"}
14 json_input = json.dumps(test_data)
15
16 # Mock sys.stdin
17 with patch("sys.stdin", StringIO(json_input)):
18 result = read_hook_input()
19
20 self.assertEqual(result, test_data)
21
22 def test_invalid_json_returns_empty_dict(self):
23 """測試無效的 JSON"""
24 with patch("sys.stdin", StringIO("not valid json")):
25 result = read_hook_input()
26
27 self.assertEqual(result, {})
28
29class TestWriteHookOutput(unittest.TestCase):
30 """測試 write_hook_output 函式"""
31
32 def test_output_json_format(self):
33 """測試輸出為有效的 JSON"""
34 test_data = {"decision": "allow"}
35
36 # Mock sys.stdout
37 with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
38 write_hook_output(test_data)
39 output = mock_stdout.getvalue()
40
41 # 驗證輸出是有效的 JSON
42 parsed = json.loads(output)
43 self.assertEqual(parsed["decision"], "allow")
44
45 def test_chinese_preserved(self):
46 """測試中文字元被保留"""
47 test_data = {"message": "你好"}
48
49 with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
50 write_hook_output(test_data, ensure_ascii=False)
51 output = mock_stdout.getvalue()
52
53 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
6class TestRunGitCommand(unittest.TestCase):
7
8 @patch("subprocess.run")
9 def test_successful_command(self, mock_run):
10 """測試成功的 git 命令"""
11 # 設定 mock 返回值
12 mock_result = Mock()
13 mock_result.returncode = 0
14 mock_result.stdout = "main\n"
15 mock_result.stderr = ""
16 mock_run.return_value = mock_result
17
18 success, output = run_git_command(["branch", "--show-current"])
19
20 self.assertTrue(success)
21 self.assertEqual(output, "main")
22
23 # 驗證 subprocess.run 被正確呼叫
24 mock_run.assert_called_once()
25 call_args = mock_run.call_args
26 self.assertEqual(call_args[0][0], ["git", "branch", "--show-current"])
27
28 @patch("subprocess.run")
29 def test_failed_command(self, mock_run):
30 """測試失敗的 git 命令"""
31 mock_result = Mock()
32 mock_result.returncode = 1
33 mock_result.stdout = ""
34 mock_result.stderr = "fatal: not a git repository"
35 mock_run.return_value = mock_result
36
37 success, output = run_git_command(["status"])
38
39 self.assertFalse(success)
40 self.assertIn("not a git repository", output)
41
42 @patch("subprocess.run")
43 def test_timeout(self, mock_run):
44 """測試命令超時"""
45 import subprocess
46 mock_run.side_effect = subprocess.TimeoutExpired("git", 10)
47
48 success, output = run_git_command(["status"], timeout=10)
49
50 self.assertFalse(success)
51 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 基礎 下一模組:物件導向設計