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

思考題

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

實作練習

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

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