當測試的程式碼依賴外部資源(檔案系統、網路、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()  # 42

side_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)  # 10

side_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()  # 確保清理

思考題

  1. patch 的目標為什麼是匯入位置而非定義位置?
  2. MockMagicMock 有什麼區別?
  3. 什麼時候應該使用 autospec=True

實作練習

  1. get_current_branch() 撰寫使用 Mock 的測試
  2. 測試一個讀取檔案的函式,使用 mock_open
  3. 測試一個會拋出異常的外部呼叫,使用 side_effect

上一章:unittest 基礎 下一模組:物件導向設計