unittest 是 Python 內建的測試框架,提供了測試組織、斷言和測試執行等功能。Hook 系統的測試都使用 unittest 撰寫。

基本結構

最簡單的測試

 1import unittest
 2
 3class TestCalculator(unittest.TestCase):
 4
 5    def test_add(self):
 6        result = 1 + 1
 7        self.assertEqual(result, 2)
 8
 9    def test_subtract(self):
10        result = 5 - 3
11        self.assertEqual(result, 2)
12
13if __name__ == "__main__":
14    unittest.main()

執行測試:

1$ python -m unittest test_calculator.py
2..
3----------------------------------------------------------------------
4Ran 2 tests in 0.001s
5
6OK

測試類別結構

 1import unittest
 2
 3class TestMyModule(unittest.TestCase):
 4
 5    @classmethod
 6    def setUpClass(cls):
 7        """在所有測試前執行一次"""
 8        cls.shared_resource = create_expensive_resource()
 9
10    @classmethod
11    def tearDownClass(cls):
12        """在所有測試後執行一次"""
13        cls.shared_resource.cleanup()
14
15    def setUp(self):
16        """在每個測試前執行"""
17        self.test_data = {"key": "value"}
18
19    def tearDown(self):
20        """在每個測試後執行"""
21        self.test_data = None
22
23    def test_something(self):
24        """測試方法必須以 test_ 開頭"""
25        self.assertTrue(True)

常用斷言方法

方法檢查
assertEqual(a, b)a == b
assertNotEqual(a, b)a != b
assertTrue(x)x is True
assertFalse(x)x is False
assertIs(a, b)a is b
assertIsNone(x)x is None
assertIn(a, b)a in b
assertIsInstance(a, b)isinstance(a, b)
assertRaises(Error)拋出指定異常

實際範例:測試 Hook IO

來自 .claude/lib/tests/test_hook_io.py

  1import json
  2import sys
  3import unittest
  4from io import StringIO
  5from unittest.mock import patch
  6
  7# 導入被測試的模組
  8sys.path.insert(0, str(Path(__file__).parent.parent))
  9from hook_io import (
 10    read_hook_input,
 11    write_hook_output,
 12    create_pretooluse_output,
 13    create_posttooluse_output,
 14)
 15
 16
 17class TestReadHookInput(unittest.TestCase):
 18    """測試 read_hook_input 函式"""
 19
 20    def test_valid_json_input(self):
 21        """測試有效的 JSON 輸入"""
 22        test_data = {"tool_name": "Write", "file_path": "/test.txt"}
 23        json_input = json.dumps(test_data)
 24
 25        with patch("sys.stdin", StringIO(json_input)):
 26            result = read_hook_input()
 27
 28        self.assertEqual(result, test_data)
 29
 30    def test_invalid_json_returns_empty_dict(self):
 31        """測試無效的 JSON 應返回空字典"""
 32        with patch("sys.stdin", StringIO("not valid json")):
 33            result = read_hook_input()
 34
 35        self.assertEqual(result, {})
 36
 37    def test_empty_input_returns_empty_dict(self):
 38        """測試空輸入應返回空字典"""
 39        with patch("sys.stdin", StringIO("")):
 40            result = read_hook_input()
 41
 42        self.assertEqual(result, {})
 43
 44
 45class TestWriteHookOutput(unittest.TestCase):
 46    """測試 write_hook_output 函式"""
 47
 48    def test_output_json_format(self):
 49        """測試輸出為有效的 JSON 格式"""
 50        test_data = {"decision": "allow", "reason": "OK"}
 51
 52        with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
 53            write_hook_output(test_data)
 54            output = mock_stdout.getvalue()
 55
 56        parsed = json.loads(output)
 57        self.assertEqual(parsed["decision"], "allow")
 58
 59    def test_chinese_characters_preserved(self):
 60        """測試中文字元被保留"""
 61        test_data = {"message": "你好"}
 62
 63        with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
 64            write_hook_output(test_data, ensure_ascii=False)
 65            output = mock_stdout.getvalue()
 66
 67        self.assertIn("你好", output)
 68
 69
 70class TestCreatePretoolueOutput(unittest.TestCase):
 71    """測試 create_pretooluse_output 函式"""
 72
 73    def test_basic_output_structure(self):
 74        """測試基本輸出結構"""
 75        result = create_pretooluse_output(
 76            decision="allow",
 77            reason="Test reason"
 78        )
 79
 80        self.assertIn("hookSpecificOutput", result)
 81        self.assertEqual(
 82            result["hookSpecificOutput"]["permissionDecision"],
 83            "allow"
 84        )
 85
 86    def test_with_user_prompt(self):
 87        """測試包含 userPrompt 的輸出"""
 88        result = create_pretooluse_output(
 89            decision="ask",
 90            reason="Need confirmation",
 91            user_prompt="Continue?"
 92        )
 93
 94        self.assertEqual(
 95            result["hookSpecificOutput"]["userPrompt"],
 96            "Continue?"
 97        )
 98
 99
100if __name__ == "__main__":
101    unittest.main()

測試異常

assertRaises

 1def test_raises_value_error(self):
 2    """測試函式是否拋出 ValueError"""
 3    with self.assertRaises(ValueError):
 4        int("not a number")
 5
 6def test_raises_with_message(self):
 7    """測試異常訊息"""
 8    with self.assertRaises(ValueError) as context:
 9        raise ValueError("invalid input")
10
11    self.assertIn("invalid", str(context.exception))

測試檔案操作

使用臨時檔案

 1import tempfile
 2import unittest
 3
 4class TestFileOperations(unittest.TestCase):
 5
 6    def test_read_file(self):
 7        """測試檔案讀取"""
 8        with tempfile.NamedTemporaryFile(
 9            mode="w",
10            suffix=".txt",
11            delete=False
12        ) as f:
13            f.write("test content")
14            temp_path = f.name
15
16        try:
17            result = read_file(temp_path)
18            self.assertEqual(result, "test content")
19        finally:
20            os.unlink(temp_path)

使用臨時目錄

 1import tempfile
 2import unittest
 3
 4class TestDirectoryOperations(unittest.TestCase):
 5
 6    def setUp(self):
 7        self.temp_dir = tempfile.mkdtemp()
 8
 9    def tearDown(self):
10        import shutil
11        shutil.rmtree(self.temp_dir)
12
13    def test_create_file(self):
14        file_path = os.path.join(self.temp_dir, "test.txt")
15        create_file(file_path, "content")
16        self.assertTrue(os.path.exists(file_path))

執行測試

執行單一測試檔案

1python -m unittest tests/test_hook_io.py

執行單一測試類別

1python -m unittest tests.test_hook_io.TestReadHookInput

執行單一測試方法

1python -m unittest tests.test_hook_io.TestReadHookInput.test_valid_json_input

執行所有測試

1python -m unittest discover -s tests -p "test_*.py"

詳細輸出

1python -m unittest -v tests/test_hook_io.py

測試組織

目錄結構

 1.claude/lib/
 2├── __init__.py
 3├── git_utils.py
 4├── hook_io.py
 5├── hook_logging.py
 6└── tests/
 7    ├── __init__.py
 8    ├── test_git_utils.py
 9    ├── test_hook_io.py
10    └── test_hook_logging.py

命名慣例

  • 測試檔案:test_<module>.py
  • 測試類別:Test<ClassName>
  • 測試方法:test_<behavior>
1# test_git_utils.py
2class TestRunGitCommand(unittest.TestCase):
3    def test_successful_command_returns_true(self):
4        ...
5    def test_failed_command_returns_false(self):
6        ...
7    def test_timeout_returns_error_message(self):
8        ...

最佳實踐

1. 一個測試驗證一件事

 1# 好:每個測試只驗證一個行為
 2def test_valid_input_returns_true(self):
 3    result = validate("valid")
 4    self.assertTrue(result)
 5
 6def test_invalid_input_returns_false(self):
 7    result = validate("invalid")
 8    self.assertFalse(result)
 9
10# 不好:一個測試驗證多件事
11def test_validate(self):
12    self.assertTrue(validate("valid"))
13    self.assertFalse(validate("invalid"))
14    self.assertEqual(validate(""), None)

2. 使用描述性的測試名稱

1# 好:清楚說明測試內容
2def test_empty_input_returns_empty_dict(self):
3    ...
4
5# 不好:模糊的名稱
6def test_input(self):
7    ...

3. 使用 setUp 避免重複

 1class TestMarkdownChecker(unittest.TestCase):
 2
 3    def setUp(self):
 4        self.checker = MarkdownLinkChecker()
 5        self.test_content = "# Test\n[link](/python/05-error-testing/unittest/file.md)"
 6
 7    def test_check_valid_link(self):
 8        # 使用 setUp 中建立的物件
 9        result = self.checker.check(self.test_content)
10        ...

思考題

  1. setUpsetUpClass 有什麼區別?什麼時候用哪個?
  2. 為什麼測試方法必須以 test_ 開頭?
  3. 如何測試一個需要讀取 stdin 的函式?

實作練習

  1. get_current_branch() 函式撰寫測試
  2. 測試一個會拋出異常的函式
  3. 使用 unittest.skip 暫時跳過某個測試

上一章:異常處理策略 下一章:Mock 與測試隔離