Python unittest:编写并运行单元测试(完整指南)
Updated on
你写了一个处理用户数据的函数。用少量输入手动测试时一切正常。然后同事改了一个你依赖的辅助函数,而你的代码开始悄悄返回错误结果——整整三周都没人发现。这个 bug 最终进入生产环境,客户记录被破坏。根因只是一个没人对既有行为做验证的一行改动。这正是单元测试要避免的失败模式。Python 标准库自带 unittest——一个功能完整的测试框架,可以在回归问题进入生产前将其捕获,并且完全不依赖第三方库。
什么是 unittest,为什么要用它?
unittest 是 Python 内置的测试框架,建模自 Java 的 JUnit。从 Python 2.1 起它就属于标准库,这意味着每个 Python 安装都自带它:不需要 pip install,不需要依赖管理。你编写测试类、定义测试方法,然后从命令行运行。
单元测试用于验证代码中的独立单元(函数、方法、类)在隔离条件下是否按预期工作。当每个单元单独都正确时,把它们集成起来就更不容易产生隐藏 bug。
下面是 unittest 开箱即用提供的能力:
- 测试用例类(Test case classes),支持自动测试发现
- 丰富的断言方法(相等、真值、异常、警告等)
- setUp/tearDown 钩子,支持方法级与类级
- 测试套件(Test suites),用于组织和分组测试
- Mocking:通过
unittest.mock(Python 3.3 加入) - 命令行测试运行器,支持输出详细程度控制
你的第一个单元测试
从一个待测函数和对应的测试类开始。
# calculator.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b# test_calculator.py
import unittest
from calculator import add, divide
class TestCalculator(unittest.TestCase):
def test_add_positive_numbers(self):
self.assertEqual(add(2, 3), 5)
def test_add_negative_numbers(self):
self.assertEqual(add(-1, -1), -2)
def test_add_zero(self):
self.assertEqual(add(0, 0), 0)
def test_divide_normal(self):
self.assertEqual(divide(10, 2), 5.0)
def test_divide_by_zero_raises(self):
with self.assertRaises(ValueError):
divide(10, 0)
if __name__ == "__main__":
unittest.main()运行:
python -m unittest test_calculator.py -v输出:
test_add_negative_numbers (test_calculator.TestCalculator) ... ok
test_add_positive_numbers (test_calculator.TestCalculator) ... ok
test_add_zero (test_calculator.TestCalculator) ... ok
test_divide_by_zero_raises (test_calculator.TestCalculator) ... ok
test_divide_normal (test_calculator.TestCalculator) ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.001s
OK所有以 test_ 开头的方法都会被自动发现并执行。测试类继承自 unittest.TestCase,后者提供了所有断言方法。
断言方法速查
unittest.TestCase 包含一套完整的断言方法。每个断言在检查不通过时都会给出清晰的失败信息。
相等与身份断言
| Method | Checks | Example |
|---|---|---|
assertEqual(a, b) | a == b | self.assertEqual(result, 42) |
assertNotEqual(a, b) | a != b | self.assertNotEqual(result, 0) |
assertIs(a, b) | a is b | self.assertIs(singleton, instance) |
assertIsNot(a, b) | a is not b | self.assertIsNot(obj1, obj2) |
assertIsNone(x) | x is None | self.assertIsNone(result) |
assertIsNotNone(x) | x is not None | self.assertIsNotNone(user) |
布尔与成员关系断言
| Method | Checks | Example |
|---|---|---|
assertTrue(x) | bool(x) is True | self.assertTrue(is_valid) |
assertFalse(x) | bool(x) is False | self.assertFalse(has_errors) |
assertIn(a, b) | a in b | self.assertIn("admin", roles) |
assertNotIn(a, b) | a not in b | self.assertNotIn("deleted", status) |
assertIsInstance(a, b) | isinstance(a, b) | self.assertIsInstance(result, dict) |
数值与集合断言
| Method | Checks | Example |
|---|---|---|
assertAlmostEqual(a, b) | round(a-b, 7) == 0 | self.assertAlmostEqual(0.1 + 0.2, 0.3) |
assertGreater(a, b) | a > b | self.assertGreater(len(results), 0) |
assertLess(a, b) | a < b | self.assertLess(latency, 1.0) |
assertCountEqual(a, b) | 元素相同,顺序任意 | self.assertCountEqual([3,1,2], [1,2,3]) |
assertListEqual(a, b) | 列表相等 | self.assertListEqual(result, expected) |
assertDictEqual(a, b) | 字典相等 | self.assertDictEqual(config, defaults) |
异常与警告断言
import unittest
import warnings
class TestExceptions(unittest.TestCase):
def test_raises_value_error(self):
"""assertRaises checks that the exception is raised."""
with self.assertRaises(ValueError):
int("not_a_number")
def test_raises_with_message(self):
"""assertRaisesRegex checks both the exception and its message."""
with self.assertRaisesRegex(ValueError, "invalid literal"):
int("not_a_number")
def test_warns_deprecation(self):
"""assertWarns checks that a warning is issued."""
with self.assertWarns(DeprecationWarning):
warnings.warn("old function", DeprecationWarning)始终优先使用更具体的断言,而不是 assertTrue。与其写 self.assertTrue(result == 42),不如写 self.assertEqual(result, 42)。具体断言会给出清晰的失败信息,例如 42 != 41,而 assertTrue 只会提示 False is not true。
setUp 和 tearDown:测试夹具(Fixtures)
多数测试都需要一些初始状态——数据库连接、临时文件或预配置对象。setUp 和 tearDown 会在每个测试方法前后运行,确保每个测试都有一个全新的起点。
import unittest
import os
import tempfile
class TestFileProcessor(unittest.TestCase):
def setUp(self):
"""Runs before EACH test method."""
self.test_dir = tempfile.mkdtemp()
self.test_file = os.path.join(self.test_dir, "data.txt")
with open(self.test_file, "w") as f:
f.write("line1\nline2\nline3\n")
def tearDown(self):
"""Runs after EACH test method."""
os.remove(self.test_file)
os.rmdir(self.test_dir)
def test_read_lines(self):
with open(self.test_file, "r") as f:
lines = f.readlines()
self.assertEqual(len(lines), 3)
def test_file_exists(self):
self.assertTrue(os.path.exists(self.test_file))
def test_first_line_content(self):
with open(self.test_file, "r") as f:
first_line = f.readline().strip()
self.assertEqual(first_line, "line1")每个测试方法都会调用一次 setUp。即使 test_read_lines 修改了文件,test_first_line_content 仍会看到原始内容,因为 setUp 会重新创建它。
setUpClass 和 tearDownClass:一次性初始化
有些资源创建成本很高——数据库连接、大型数据夹具、服务器进程。使用 setUpClass 和 tearDownClass 在整个测试类级别只创建一次。
import unittest
import sqlite3
class TestDatabase(unittest.TestCase):
@classmethod
def setUpClass(cls):
"""Runs ONCE before all tests in this class."""
cls.conn = sqlite3.connect(":memory:")
cls.cursor = cls.conn.cursor()
cls.cursor.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
""")
cls.cursor.executemany(
"INSERT INTO users (name, email) VALUES (?, ?)",
[
("Alice", "alice@example.com"),
("Bob", "bob@example.com"),
("Charlie", "charlie@example.com"),
],
)
cls.conn.commit()
@classmethod
def tearDownClass(cls):
"""Runs ONCE after all tests in this class."""
cls.conn.close()
def test_user_count(self):
self.cursor.execute("SELECT COUNT(*) FROM users")
count = self.cursor.fetchone()[0]
self.assertEqual(count, 3)
def test_find_user_by_email(self):
self.cursor.execute(
"SELECT name FROM users WHERE email = ?",
("bob@example.com",),
)
name = self.cursor.fetchone()[0]
self.assertEqual(name, "Bob")
def test_unique_emails(self):
with self.assertRaises(sqlite3.IntegrityError):
self.cursor.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
("Duplicate", "alice@example.com"),
)| Hook | Runs | Decorator | Use Case |
|---|---|---|---|
setUp | 每个测试方法之前 | None | 创建全新对象、重置状态 |
tearDown | 每个测试方法之后 | None | 清理文件、重置 mock |
setUpClass | 本类所有测试之前运行一次 | @classmethod | 数据库连接、昂贵夹具 |
tearDownClass | 本类所有测试之后运行一次 | @classmethod | 关闭连接、删除共享资源 |
使用 unittest.mock 进行 Mocking
真实应用会依赖数据库、API、文件系统和网络服务。你不希望单元测试去调用生产 API,也不希望测试必须依赖一个正在运行的数据库。unittest.mock 可以用可控的替身替换这些依赖。
基础 Mock 对象
from unittest.mock import Mock
# Create a mock object
api_client = Mock()
# Configure return values
api_client.get_user.return_value = {"id": 1, "name": "Alice"}
# Use it like a real object
user = api_client.get_user(user_id=1)
print(user) # {'id': 1, 'name': 'Alice'}
# Verify it was called correctly
api_client.get_user.assert_called_once_with(user_id=1)使用 @patch 进行替换(Patching)
@patch 装饰器会在测试期间替换某个模块中的对象。这是将被测单元与其依赖隔离开的主要工具。
# user_service.py
import requests
def get_user_name(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()["name"]# test_user_service.py
import unittest
from unittest.mock import patch, Mock
from user_service import get_user_name
class TestUserService(unittest.TestCase):
@patch("user_service.requests.get")
def test_get_user_name_success(self, mock_get):
"""Mock the requests.get call to avoid hitting the real API."""
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
result = get_user_name(1)
self.assertEqual(result, "Alice")
mock_get.assert_called_once_with("https://api.example.com/users/1")
@patch("user_service.requests.get")
def test_get_user_name_http_error(self, mock_get):
"""Verify that HTTP errors propagate correctly."""
import requests
mock_get.return_value.raise_for_status.side_effect = (
requests.exceptions.HTTPError("404 Not Found")
)
with self.assertRaises(requests.exceptions.HTTPError):
get_user_name(999)将 patch 作为上下文管理器使用
import unittest
from unittest.mock import patch
class TestConfig(unittest.TestCase):
def test_reads_environment_variable(self):
with patch.dict("os.environ", {"DATABASE_URL": "sqlite:///test.db"}):
import os
self.assertEqual(os.environ["DATABASE_URL"], "sqlite:///test.db")MagicMock vs Mock
MagicMock 是 Mock 的子类,预先配置了魔术方法(__len__、__iter__、__getitem__ 等)。当被测代码会在 mock 对象上调用这些 dunder 方法时,用 MagicMock 更合适。
from unittest.mock import MagicMock
mock_list = MagicMock()
mock_list.__len__.return_value = 5
mock_list.__getitem__.return_value = "item"
print(len(mock_list)) # 5
print(mock_list[0]) # item使用 side_effect 实现复杂行为
side_effect 可以让 mock 抛出异常、在连续调用时返回不同值,或执行自定义函数。
from unittest.mock import Mock
# Raise an exception
mock_db = Mock()
mock_db.connect.side_effect = ConnectionError("Database unreachable")
# Return different values on successive calls
mock_api = Mock()
mock_api.fetch.side_effect = [{"page": 1}, {"page": 2}, StopIteration]
print(mock_api.fetch()) # {'page': 1}
print(mock_api.fetch()) # {'page': 2}
# Custom function
def validate_input(x):
if x < 0:
raise ValueError("Negative input")
return x * 2
mock_fn = Mock(side_effect=validate_input)
print(mock_fn(5)) # 10测试发现(Test Discovery)
你不需要手动列出每个测试文件。Python 的测试发现会查找并运行符合命名约定的全部测试。
# Discover and run all tests in the current directory tree
python -m unittest discover
# Specify a start directory and pattern
python -m unittest discover -s tests -p "test_*.py"
# Verbose output
python -m unittest discover -v测试发现会搜索匹配 test_*.py(默认模式)的文件,导入它们,并运行所有继承自 unittest.TestCase 的类。
推荐的项目结构
my_project/
src/
calculator.py
user_service.py
utils.py
tests/
__init__.py
test_calculator.py
test_user_service.py
test_utils.py
setup.py在项目根目录运行全部测试:
python -m unittest discover -s tests -v使用测试套件组织测试(Test Suites)
如果你需要更细粒度地控制运行哪些测试,可以手动构建测试套件。
import unittest
from test_calculator import TestCalculator
from test_user_service import TestUserService
def fast_tests():
"""Suite of tests that run quickly (no I/O, no network)."""
suite = unittest.TestSuite()
suite.addTest(TestCalculator("test_add_positive_numbers"))
suite.addTest(TestCalculator("test_add_negative_numbers"))
suite.addTest(TestCalculator("test_divide_normal"))
return suite
def all_tests():
"""Full test suite."""
loader = unittest.TestLoader()
suite = unittest.TestSuite()
suite.addTests(loader.loadTestsFromTestCase(TestCalculator))
suite.addTests(loader.loadTestsFromTestCase(TestUserService))
return suite
if __name__ == "__main__":
runner = unittest.TextTestRunner(verbosity=2)
runner.run(fast_tests())跳过测试与预期失败(Expected Failures)
有时测试只应在特定条件下运行——比如特定 OS、特定 Python 版本,或外部服务可用时。
import unittest
import sys
class TestPlatformSpecific(unittest.TestCase):
@unittest.skip("Temporarily disabled while refactoring")
def test_feature_under_construction(self):
pass
@unittest.skipIf(sys.platform == "win32", "Not supported on Windows")
def test_unix_permissions(self):
import os
self.assertTrue(os.access("/tmp", os.W_OK))
@unittest.skipUnless(sys.platform.startswith("linux"), "Linux only")
def test_proc_filesystem(self):
import os
self.assertTrue(os.path.exists("/proc"))
@unittest.expectedFailure
def test_known_bug(self):
"""This test documents a known bug. It SHOULD fail."""
self.assertEqual(1 + 1, 3)| Decorator | Effect |
|---|---|
@unittest.skip(reason) | 始终跳过该测试 |
@unittest.skipIf(condition, reason) | condition 为 True 时跳过 |
@unittest.skipUnless(condition, reason) | 除非 condition 为 True,否则跳过 |
@unittest.expectedFailure | 标记为预期失败;若意外通过则被报告为错误 |
使用 subTest 的参数化测试
用不同输入测试同一段逻辑很常见。与其为每个 case 写一个单独的测试方法,不如用 subTest 在一个方法里运行参数化断言。
import unittest
def is_palindrome(s):
cleaned = s.lower().replace(" ", "")
return cleaned == cleaned[::-1]
class TestPalindrome(unittest.TestCase):
def test_palindromes(self):
test_cases = [
("racecar", True),
("hello", False),
("A man a plan a canal Panama", True),
("", True),
("ab", False),
("madam", True),
("Nurses Run", True),
]
for text, expected in test_cases:
with self.subTest(text=text):
self.assertEqual(is_palindrome(text), expected)使用 subTest 时,某个 case 失败不会阻止其他 case 继续运行。输出会标识到底是哪一个子用例失败:
FAIL: test_palindromes (test_palindrome.TestPalindrome) (text='Nurses Run')
AssertionError: False != True如果不使用 subTest,第一次失败就会终止整个方法,你也无法知道其他用例是否同样失败。
unittest vs pytest vs doctest
Python 有三种内置或常用的测试工具,各自适用于不同场景。
| Feature | unittest | pytest | doctest |
|---|---|---|---|
| Included in stdlib | Yes | No (pip install) | Yes |
| Test style | Class-based (TestCase) | Function-based (plain def test_) | Embedded in docstrings |
| Assertions | self.assertEqual, self.assertTrue, etc. | Plain assert statement | Expected output matching |
| Fixtures | setUp/tearDown, setUpClass | @pytest.fixture with dependency injection | None |
| Parameterized tests | subTest (limited) | @pytest.mark.parametrize (powerful) | One example per docstring |
| Mocking | unittest.mock (built-in) | unittest.mock + monkeypatch | Not applicable |
| Test discovery | python -m unittest discover | pytest (auto-discovers) | python -m doctest module.py |
| Output on failure | Basic diff | Detailed diff with context | Shows expected vs actual output |
| Plugins | None | 1000+ plugins (coverage, fixtures, etc.) | None |
| Learning curve | Moderate (OOP patterns) | Low (plain functions) | Very low |
| Best for | Standard library projects, no extra dependencies | Most Python projects, complex test setups | Simple examples in documentation |
何时选择 unittest:
- 你希望零外部依赖
- 你的组织或项目已经在使用 unittest
- 你需要基于类的测试组织方式
- 你希望直接使用
unittest.mock且无需额外配置
何时选择 pytest:
- 你想要更简单的语法和更好的失败输出
- 你需要更强的参数化或 fixture 体系
- 你依赖 pytest 插件生态(coverage、async、Django 等)
何时选择 doctest:
- 你想验证文档里的代码示例仍然可运行
- 测试只是简单的输入/输出对
注意:pytest 能无需修改直接运行 unittest 风格的测试。很多团队会从 unittest 起步,然后切换到 pytest 作为 runner,同时保留已有测试类。
测试一个真实世界的类:完整示例
下面给出一个对购物车实现进行测试的完整示例。
# cart.py
class Product:
def __init__(self, name, price):
if price < 0:
raise ValueError("Price cannot be negative")
self.name = name
self.price = price
class ShoppingCart:
def __init__(self):
self.items = []
def add(self, product, quantity=1):
if quantity <= 0:
raise ValueError("Quantity must be positive")
self.items.append({"product": product, "quantity": quantity})
def total(self):
return sum(
item["product"].price * item["quantity"]
for item in self.items
)
def remove(self, product_name):
self.items = [
item for item in self.items
if item["product"].name != product_name
]
def item_count(self):
return sum(item["quantity"] for item in self.items)# test_cart.py
import unittest
from cart import Product, ShoppingCart
class TestProduct(unittest.TestCase):
def test_create_product(self):
p = Product("Widget", 9.99)
self.assertEqual(p.name, "Widget")
self.assertAlmostEqual(p.price, 9.99)
def test_negative_price_raises(self):
with self.assertRaises(ValueError):
Product("Bad", -5.00)
class TestShoppingCart(unittest.TestCase):
def setUp(self):
self.cart = ShoppingCart()
self.apple = Product("Apple", 1.50)
self.bread = Product("Bread", 3.00)
def test_empty_cart_total(self):
self.assertEqual(self.cart.total(), 0)
def test_add_single_item(self):
self.cart.add(self.apple)
self.assertEqual(self.cart.item_count(), 1)
self.assertAlmostEqual(self.cart.total(), 1.50)
def test_add_multiple_items(self):
self.cart.add(self.apple, quantity=3)
self.cart.add(self.bread, quantity=2)
self.assertEqual(self.cart.item_count(), 5)
self.assertAlmostEqual(self.cart.total(), 10.50)
def test_remove_item(self):
self.cart.add(self.apple, quantity=2)
self.cart.add(self.bread)
self.cart.remove("Apple")
self.assertEqual(self.cart.item_count(), 1)
self.assertAlmostEqual(self.cart.total(), 3.00)
def test_remove_nonexistent_item(self):
self.cart.add(self.apple)
self.cart.remove("Nonexistent")
self.assertEqual(self.cart.item_count(), 1)
def test_add_zero_quantity_raises(self):
with self.assertRaises(ValueError):
self.cart.add(self.apple, quantity=0)
def test_add_negative_quantity_raises(self):
with self.assertRaises(ValueError):
self.cart.add(self.apple, quantity=-1)
if __name__ == "__main__":
unittest.main()unittest 的最佳实践
1. 每个测试方法聚焦一个概念
每个测试方法应验证一个逻辑概念。只要都在验证同一操作的不同方面,多条断言是没问题的。
# GOOD -- multiple assertions about the same operation
def test_user_creation(self):
user = create_user("alice", "alice@example.com")
self.assertEqual(user.name, "alice")
self.assertEqual(user.email, "alice@example.com")
self.assertIsNotNone(user.id)
# BAD -- testing unrelated things in one method
def test_everything(self):
user = create_user("alice", "alice@example.com")
self.assertEqual(user.name, "alice")
users = list_all_users()
self.assertGreater(len(users), 0) # Unrelated assertion2. 使用描述性测试名称
测试名称应描述场景与预期结果。CI 里一旦失败,光看名字就应该能看出哪里出了问题。
# GOOD
def test_divide_by_zero_raises_value_error(self):
...
def test_empty_cart_returns_zero_total(self):
...
# BAD
def test_divide(self):
...
def test1(self):
...3. 测试必须相互独立
任何测试都不应依赖另一个测试的输出或执行顺序。每个测试都应该自己搭建状态,并在结束后自行清理。
4. 保持测试快速
单元测试应在毫秒级完成。如果测试需要数据库,就 mock 它;如果需要 API,就 mock 它。把慢的集成测试留到单独的测试套件里。
5. 测试边界情况
始终测试边界条件:空输入、0、None、超大输入、以及无效类型。
def test_edge_cases(self):
test_cases = [
([], 0), # empty list
([0], 0), # single zero
([-1, -2], -3), # all negative
([999999999], 999999999), # large number
]
for inputs, expected in test_cases:
with self.subTest(inputs=inputs):
self.assertEqual(sum(inputs), expected)6. 不要测试实现细节
测试公共接口与行为,而不是内部状态或私有方法。即使你重构内部实现,测试也应该仍然通过。
在命令行运行测试
# Run a specific test file
python -m unittest test_calculator.py
# Run a specific test class
python -m unittest test_calculator.TestCalculator
# Run a specific test method
python -m unittest test_calculator.TestCalculator.test_add_positive_numbers
# Verbose output (shows each test name and result)
python -m unittest -v
# Discover all tests in a directory
python -m unittest discover -s tests -p "test_*.py" -v
# Stop on first failure (fail-fast mode)
python -m unittest -f使用 RunCell 编写与调试测试
当你在 Jupyter notebooks 中开发(在数据科学与探索式编程里很常见)时,编写和运行单元测试会显得很别扭。notebook 以交互式 cell 执行,但 unittest 更偏向模块与测试 runner。你经常不得不在 notebook 与测试文件之间复制代码,导致交互反馈循环被打断。
RunCell (opens in a new tab) 是一个面向 Jupyter 的 AI agent,用于弥合这个差距。它可以为你在 notebook cell 中定义的函数生成兼容 unittest 的测试用例,在 notebook 环境内运行它们,并结合上下文解释失败原因。如果 mock 设置不正确或断言失败,RunCell 会检查当前的实时变量,告诉你实际值是什么,而不只是抛出断言错误信息。对于数据流水线(data pipelines),当你需要验证 DataFrame 的变换是否产生正确的输出形状与数值时,RunCell 可以帮你搭建测试结构与断言,让你把精力集中在逻辑本身,而不是样板代码上。
FAQ
unittest 和 pytest 有什么区别?
unittest 是 Python 内置的测试框架,提供基于类的 API。pytest 是第三方框架,使用普通函数与 assert 语句。pytest 有更丰富的插件生态和更好的失败输出,但需要安装;unittest 在任何能运行 Python 的地方都可用,且无需额外依赖。
如何在 unittest 中只运行一个测试方法?
使用命令 python -m unittest test_module.TestClass.test_method。例如:python -m unittest test_calculator.TestCalculator.test_add_positive_numbers。它只运行指定方法,不会执行文件中的其他测试。
setUp 和 setUpClass 有什么区别?
setUp 在每个测试方法前运行,为每次测试创建全新的状态。setUpClass 在该测试类的所有测试之前只运行一次,并且是一个 @classmethod。昂贵的初始化(如数据库连接)用 setUpClass;轻量的每测试初始化用 setUp。
如何在 unittest 中 mock 外部 API?
使用 unittest.mock.patch 替换调用 API 的函数。要 patch “被使用的位置”的导入路径,而不是函数的定义位置。例如如果 user_service.py 导入并使用了 requests.get,应 patch user_service.requests.get,而不是 requests.get。
pytest 能运行 unittest 测试吗?
可以。pytest 与 unittest 风格的测试类完全兼容。你可以在使用 unittest.TestCase 的项目中直接运行 pytest 而无需修改。这使得迁移可以渐进式进行:新测试用 pytest 风格写,旧的 unittest 测试继续保留。
如何测试函数会抛出异常?
把 self.assertRaises(ExceptionType) 当作上下文管理器使用。如果 with 块里的代码抛出指定异常则测试通过;如果没有抛出异常或抛出不同异常则失败。使用 assertRaisesRegex 还可以同时校验异常消息。
总结
Python 的 unittest 框架是一套随每个 Python 安装一起提供的完整测试工具箱。它提供测试用例类、丰富的断言方法、方法级与类级的 setup/teardown 钩子、通过 unittest.mock 提供的 mocking 能力,以及内置的测试发现。你无需安装任何东西,就能开始编写可靠的测试。
基础用法很直接:继承 TestCase,方法名以 test_ 开头,使用具体的断言方法,并通过 python -m unittest 运行。随着项目增长,引入 setUp/tearDown 来确保测试隔离,用 @patch mock 外部依赖,用 subTest 做参数化测试。把测试组织在 tests/ 目录下,让测试发现机制处理剩下的工作。
写测试会在前期花时间,但在后期能节省更多时间。每一次被单元测试捕获的回归,都意味着一次从未发生的生产事故、一次从未出现的客户投诉,以及一次从未开始的 debug 过程。无论你坚持使用 unittest 还是最终迁移到 pytest,你围绕 unittest 模式建立的测试习惯——隔离、清晰断言、mock 依赖、全面覆盖边界情况——都可以通用于 Python 测试的各个体系。