Python 单元测试最佳实践指南

什么是单元测试?

单元测试是聚焦最小可测试代码单元(通常是函数、类方法或模块)的验证工作,目标是确保该单元在给定输入、前置条件和执行路径下的输出/行为完全符合预期

额外提一句常用的配套开发模式:测试驱动开发(TDD),它要求先写失败的测试用例,再补功能让测试通过,最后重构优化,能从根源上倒逼代码模块化和可维护性。


为什么需要单元测试?

很多开发者会觉得“写测试浪费开发时间”,但从整个项目周期来看,它带来的收益远大于投入:

  1. 快速验证功能正确性:写完核心逻辑后几秒/分钟内扫一遍测试,就能发现低级错误;
  2. 防止回归Bug:重构、优化或添加新功能时,旧的测试能第一时间暴露被破坏的原有逻辑;
  3. 倒逼代码质量:要写可测试的代码,必须避免强耦合、全局依赖和过度复杂的函数,自然会把模块拆解得更清晰;
  4. 替代部分文档:测试用例本身就是「代码该怎么用、不该怎么用」的最鲜活、最不会过时的示例。

现代单元测试核心实践

1. 优先选 pytest 替代 unittest

Python 标准库的 unittest 虽然历史悠久,但 pytest 早已是 Python 社区的事实标准,核心优势太明显了:

  • 语法超简洁(不用写类继承、不用 assertEqual/assertTrue 这类冗余断言);
  • 自带强大的 fixture 依赖注入系统;
  • 支持一键实现参数化测试;
  • 插件生态爆炸(比如覆盖率、Mock、并发测试都有现成工具);
  • 兼容 unittest 代码库,迁移成本几乎为0。
# 对比 unittest 和 pytest 的简单测试写法
# ———— unittest ————
import unittest
from mydict import Dict

class TestDict(unittest.TestCase):
    def test_init(self):
        d = Dict(a=1, b='test')
        self.assertEqual(d.a, 1)
        self.assertEqual(d.b, 'test')
        self.assertIsInstance(d, dict)

# ———— pytest ————
import pytest
from mydict import Dict

def test_dict_init():
    d = Dict(a=1, b='test')
    assert d.a == 1
    assert d.b == 'test'
    assert isinstance(d, dict)

2. 遵循 FIRST 测试原则

好的测试用例一定满足这五个特性,缺一不可:

  • Fast(快速):单个测试毫秒级,全量测试秒级/分钟级,否则没人愿意常跑;
  • Isolated(隔离):每个测试不依赖其他测试的状态,也不影响外部环境(比如数据库、文件);
  • Repeatable(可重复):同一环境下反复运行结果完全一致;
  • Self-validating(自验证):测试结果只有「通过」或「失败」两种,不需要人工核对日志;
  • Timely(及时):尽量和功能代码同步写,最晚不要晚于功能上线前。

3. 合理关注测试覆盖率

pytest-cov 插件一键检查代码被测试覆盖的比例:

# 安装插件
pip install pytest-cov

# 只查核心模块的覆盖率,不生成HTML
pytest --cov=mymodule tests/

# 生成带高亮的覆盖率报告(更直观)
pytest --cov=mymodule --cov-report=html tests/
# 报告在当前目录的 htmlcov/index.html 打开

💡 覆盖率不是越高越好! 理想情况是核心业务逻辑100%覆盖,通用工具库80%以上覆盖即可。不要为了凑覆盖率去测试 getter/setter 这种无逻辑的代码,浪费时间。


现代测试完整示例

先写一个待测试的小工具库——支持属性访问的字典类带分数验证的学生类

# mytools.py
class Dict(dict):
    """像访问对象属性一样访问字典键值的工具类"""
    def __init__(self, **kw):
        super().__init__(**kw)

    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            # 捕获KeyError转成AttributeError,保持对象属性访问的一致性
            raise AttributeError(f"'Dict' object has no attribute '{key}'")

    def __setattr__(self, key, value):
        # 直接把属性赋值转成字典赋值,但要避免覆盖内置的特殊属性
        if key.startswith('__'):
            super().__setattr__(key, value)
        else:
            self[key] = value


class Student:
    """带分数验证的学生类"""
    def __init__(self, name, score):
        self.name = name
        if not 0 <= score <= 100:
            raise ValueError("Score must be between 0 and 100")
        self.score = score
        
    def get_grade(self):
        """根据分数返回等级:80+A,60-79B,0-59C"""
        if self.score >= 80:
            return 'A'
        if self.score >= 60:
            return 'B'
        return 'C'

1. 基础功能/异常测试

# test_mytools.py
import pytest
from mytools import Dict, Student

class TestDict:
    """测试支持属性访问的字典"""
    def test_init_with_kv(self):
        d = Dict(a=1, b='test_str', c=None)
        assert d.a == 1
        assert d.b == 'test_str'
        assert d.c is None
        assert isinstance(d, dict)

    def test_key_to_attr(self):
        """测试字典键赋值后可以用属性访问"""
        d = Dict()
        d['user_id'] = 1001
        d['is_vip'] = True
        assert d.user_id == 1001
        assert d.is_vip is True

    def test_attr_to_key(self):
        """测试对象属性赋值后可以用字典键访问"""
        d = Dict()
        d.username = 'alice'
        d.age = 25
        assert 'username' in d
        assert d['username'] == 'alice'
        assert d['age'] == 25

    def test_attr_error_for_missing(self):
        """测试访问不存在的属性时抛出AttributeError"""
        d = Dict()
        with pytest.raises(AttributeError, match=r"'Dict' object has no attribute 'empty_attr'"):
            _ = d.empty_attr

    def test_special_attr_not_overwritten(self):
        """测试内置特殊属性(比如__len__)不会被转成字典键"""
        d = Dict()
        # 直接调用__len__方法不会报错
        assert len(d) == 0
        # 不存在键__len__
        assert '__len__' not in d

2. 用 fixture 复用测试数据/对象

如果多个测试都需要用到相同的初始数据(比如同一个Dict实例、同一个Student实例),用 @pytest.fixture 装饰器封装,避免重复代码:

# test_mytools.py 继续追加
@pytest.fixture
def sample_dict():
    """返回一个预填充的Dict实例"""
    return Dict(a=1, b='test_str', nested=Dict(c=2))

@pytest.fixture
def grade_boundary_students():
    """返回分数边界的学生实例列表"""
    return [
        Student('Alice', 100),  # A的上限
        Student('Bob', 80),     # A的下限
        Student('Charlie', 79), # B的上限
        Student('Diana', 60),   # B的下限
        Student('Eve', 59),     # C的上限
        Student('Frank', 0),    # C的下限
    ]

def test_fixture_dict_usage(sample_dict):
    assert sample_dict.a == 1
    assert sample_dict.nested.c == 2

3. 用参数化测试覆盖边界条件

手动写一堆边界条件的测试太麻烦了,用 @pytest.mark.parametrize 可以一键搞定:

# test_mytools.py 继续追加
@pytest.mark.parametrize(
    "score, expected_grade",
    [(100, 'A'), (80, 'A'), (79, 'B'), (60, 'B'), (59, 'C'), (0, 'C')]
)
def test_grade_boundaries_param(score, expected_grade):
    s = Student('TestStudent', score)
    assert s.get_grade() == expected_grade

@pytest.mark.parametrize(
    "invalid_score",
    [-1, 101, 100.1, None, '80']
)
def test_student_invalid_score(invalid_score):
    with pytest.raises((ValueError, TypeError)):
        _ = Student('InvalidStudent', invalid_score)

常用 pytest 执行命令

# 基础命令
pytest                     # 运行当前目录及子目录下所有test_*.py或*_test.py文件
pytest test_mytools.py     # 只运行指定文件
pytest test_mytools.py::TestDict  # 只运行指定类
pytest test_mytools.py::TestDict::test_init_with_kv  # 只运行指定方法

# 增强选项
pytest -v                  # 显示每个测试的详细结果(通过/失败的测试名)
pytest -s                  # 显示测试中的print输出(默认会被pytest捕获隐藏)
pytest -k "init or boundary"  # 按名称过滤测试(支持简单的逻辑表达式)
pytest -x                  # 遇到第一个失败就停止运行
pytest --lf                # 只重新运行上一次失败的测试
pytest --tb=short          # 失败时只显示简短的堆栈信息

避坑最佳实践建议

  1. 测试命名要清晰:比如用 test_功能_场景_预期 的格式,一看就知道测什么、什么情况、要得到什么;
  2. 测试必须独立:每个测试自己创建/销毁数据,不要用全局变量;
  3. 避免测试实现细节:测试“代码应该做什么”,而不是“代码内部怎么实现的”——比如测试排序函数只看输出是否有序,不管用的是冒泡还是快排;
  4. Mock外部依赖:如果代码依赖数据库、API、文件系统,用 pytest-mockunittest.mock 替换掉,保证测试的快速和隔离;
  5. 把测试集成到CI/CD:每次提交代码自动跑一遍全量测试,有问题直接回滚或修复。

总结

现代 Python 单元测试的核心已经从「写简单的验证」变成了「用pytest生态构建高效、可维护的测试套件」。记住几个关键点:

  • 优先用pytest;
  • 遵循FIRST原则;
  • 核心逻辑必覆盖,边缘代码可适当放宽;
  • 把测试当成开发流程的一部分,而不是额外负担。

通过这些实践,你不仅能写出更健壮的代码,还能大幅减少后期的维护成本!