项目跑起来了,功能看着也正常——但没人敢动代码,因为改一处就不知道哪里会崩。单元测试就是解决这个问题的最低成本手段,而 Python 标准库自带的 unittest 不需要额外安装,开箱即用,是最直接的起点。
最小测试案例:从 TestCase 开始
unittest.TestCase 是所有测试的基类。每个以 test_ 开头的方法就是一个独立测试,框架会自动发现并执行它们。
下面是一个完整可运行的例子——测试一个简单的字符串处理函数:
# str_utils.py — 待测模块
def slugify(text: str, separator: str = "-") -> str:
"""把任意文本转成 URL-safe 的 slug。"""
import re
slug = re.sub(r"[^\w\s-]", "", text.lower())
slug = re.sub(r"[\s_]+", separator, slug.strip())
return slug
# test_str_utils.py — 测试文件
import unittest
from str_utils import slugify
class TestSlugify(unittest.TestCase):
def test_basic_conversion(self):
self.assertEqual(slugify("Hello World"), "hello-world")
def test_custom_separator(self):
self.assertEqual(slugify("Hello World", "_"), "hello_world")
def test_special_characters_removed(self):
self.assertEqual(slugify("Python <3 unittest!"), "python-3-unittest")
def test_leading_trailing_spaces(self):
self.assertEqual(slugify(" spaced out "), "spaced-out")
def test_empty_string(self):
self.assertEqual(slugify(""), "")
if __name__ == "__main__":
unittest.main()
运行方式:
python -m unittest test_str_utils -v
输出会逐条显示每个测试的通过状态。-v 开启 verbose 模式,方便定位失败项。
几个要点:
- 方法名必须以 test_ 开头,否则框架不会执行它。
- self.assertEqual 是最常用的断言;还有 assertTrue、assertRaises、assertIn 等,按场景选用。
- 测试文件单独存放,不要和业务代码混在一起。
测试夹具:setUp 与 tearDown
当多个测试共享前置条件——比如创建临时文件、初始化数据库连接、准备测试对象——重复写准备和清理代码既冗余又容易遗漏。unittest 提供了夹具机制来统一处理:
import unittest
import json
import tempfile
import os
class TestConfigLoader(unittest.TestCase):
def setUp(self):
"""每个测试方法运行前执行:创建临时配置文件。"""
self.tmp_dir = tempfile.mkdtemp()
self.config_path = os.path.join(self.tmp_dir, "config.json")
config = {"timeout": 30, "retries": 3}
with open(self.config_path, "w") as f:
json.dump(config, f)
def tearDown(self):
"""每个测试方法运行后执行:清理临时文件。"""
os.remove(self.config_path)
os.rmdir(self.tmp_dir)
def test_load_existing_config(self):
with open(self.config_path) as f:
config = json.load(f)
self.assertEqual(config["timeout"], 30)
self.assertEqual(config["retries"], 3)
def test_missing_file_raises_error(self):
os.remove(self.config_path) # setUp 创建了文件,这里故意删掉
with self.assertRaises(FileNotFoundError):
open(self.config_path)
if __name__ == "__main__":
unittest.main()
关键区别:
- setUp / tearDown 在每个 test_ 方法前后各跑一次,保证测试之间互不干扰。
- 如果只需要整个类级别的一次性准备,用 setUpClass / tearDownClass(必须加 @classmethod 装饰器)。
- assertRaises 可以验证异常是否被正确抛出,比手动 try/except 更简洁。
组织测试套件:TestSuite 与发现机制
单个测试文件直接跑没问题,但项目变大后需要批量组织和筛选。unittest 提供两种方式:
方式一:手动组装 TestSuite
import unittest
from test_str_utils import TestSlugify
from test_config_loader import TestConfigLoader
def suite():
test_suite = unittest.TestSuite()
test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestSlugify))
test_suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestConfigLoader))
return test_suite
if __name__ == "__main__":
runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite())
这种方式适合需要精确控制运行顺序或挑选特定测试的场景。
方式二:自动发现(推荐日常使用)
# 在项目根目录执行,自动发现 tests/ 下所有 test_*.py
python -m unittest discover -s tests -p "test_*.py" -v
只要文件和类遵循命名约定(文件名 test_*.py,类继承 TestCase,方法名 test_*),框架会自动收集并运行。这是最省心的做法,也是大多数项目的标准实践。
实战建议
-
断言选最具体的那个——能用
assertEqual就别用assertTrue(a == b),前者失败时会打印两边实际值,后者只告诉你False,调试效率差距很大。 -
每个测试只验证一件事——一个方法里堆五六个断言,失败时很难判断根因。拆开写,方法名本身就是文档。
-
测试边界和异常路径——正常流程容易想到,但空输入、超长字符串、类型错误才是最容易出 bug 的地方。上面的
test_empty_string就是一个例子。 -
别在测试里硬编码外部依赖——依赖真实数据库或网络服务的测试不稳定,用夹具创建临时数据或考虑 mock(
unittest.mock是标准库的一部分,配合使用很自然)。 -
把测试纳入日常流程——本地跑
discover只需几秒,CI 里加一步python -m unittest discover就能守住底线。先写测试再改代码,比改完再补测试成本低得多。
unittest 不是最花哨的测试框架,但它是标准库的一部分、零依赖、文档成熟,足以覆盖绝大多数单元测试需求。从上面这些模式起步,项目就能逐步建立起可靠的测试覆盖。