5.3 unittest 基礎
5.3 unittest 基礎
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 ...思考題
setUp和setUpClass有什麼區別?什麼時候用哪個?- 為什麼測試方法必須以
test_開頭? - 如何測試一個需要讀取 stdin 的函式?
實作練習
- 為
get_current_branch()函式撰寫測試 - 測試一個會拋出異常的函式
- 使用
unittest.skip暫時跳過某個測試
上一章:異常處理策略 下一章:Mock 與測試隔離