用 Python unittest 写出靠谱的单元测试

2026-04-28 29 预计阅读时间:1 分钟
来源:realpython.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:7 分钟

项目跑起来了,功能看着也正常——但没人敢动代码,因为改一处就不知道哪里会崩。单元测试就是解决这个问题的最低成本手段,而 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 是最常用的断言;还有 assertTrueassertRaisesassertIn 等,按场景选用。 - 测试文件单独存放,不要和业务代码混在一起。

测试夹具: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_*),框架会自动收集并运行。这是最省心的做法,也是大多数项目的标准实践。

实战建议

  1. 断言选最具体的那个——能用 assertEqual 就别用 assertTrue(a == b),前者失败时会打印两边实际值,后者只告诉你 False,调试效率差距很大。

  2. 每个测试只验证一件事——一个方法里堆五六个断言,失败时很难判断根因。拆开写,方法名本身就是文档。

  3. 测试边界和异常路径——正常流程容易想到,但空输入、超长字符串、类型错误才是最容易出 bug 的地方。上面的 test_empty_string 就是一个例子。

  4. 别在测试里硬编码外部依赖——依赖真实数据库或网络服务的测试不稳定,用夹具创建临时数据或考虑 mock(unittest.mock 是标准库的一部分,配合使用很自然)。

  5. 把测试纳入日常流程——本地跑 discover 只需几秒,CI 里加一步 python -m unittest discover 就能守住底线。先写测试再改代码,比改完再补测试成本低得多。

unittest 不是最花哨的测试框架,但它是标准库的一部分、零依赖、文档成熟,足以覆盖绝大多数单元测试需求。从上面这些模式起步,项目就能逐步建立起可靠的测试覆盖。


相关推荐