跳转到内容

单元测试规范

单元测试编写与覆盖率要求


  • [Requirement] 所有新增业务逻辑必须配套单元测试;修 Bug 时必须先复现 Bug 的测试用例,再修复。
  • [Requirement] 单元测试只验证被测单元本身的行为,不依赖外部系统(数据库、网络、文件系统、时钟);外部依赖一律 Mock 或注入桩。
  • [Requirement] 测试必须独立、可重复、无副作用:测试之间不共享可变状态,不依赖执行顺序,不读写共享资源。
  • [Standard] 遵循 AAA 模式:Arrange(准备)→ Act(执行)→ Assert(断言),三个阶段用空行分隔,逻辑清晰。
  • [Standard] 一个测试函数只验证一个行为;不要在一个测试中覆盖多个无关断言。
  • [Default] 测试代码与生产代码同等质量要求:命名清晰、无重复、可维护;测试代码也需要评审。
  • [Suggestion] 先写失败的测试(TDD),再写实现;对于复杂逻辑,优先用测试驱动理清边界。
  • [Caution] 禁止测试中出现随机数、当前时间等不确定输入导致测试偶发失败;必须 Mock 或固定输入。
# tests/services/test_order_service.py
from decimal import Decimal
 
import pytest
 
from myapp.services import OrderService
 
 
def test_calculate_discount_applies_10_percent_for_vip() -> None:
    # Arrange
    service = OrderService()
    order = {
        "user_type": "vip",
        "amount": Decimal("100.00"),
    }
 
    # Act
    result = service.calculate_discount(order)
 
    # Assert
    assert result == Decimal("10.00")
 
 
def test_calculate_discount_zero_for_normal_user() -> None:
    service = OrderService()
    order = {"user_type": "normal", "amount": Decimal("100.00")}
 
    result = service.calculate_discount(order)
 
    assert result == Decimal("0.00")
# 反模式:一个测试验证多个行为
def test_everything():
    assert calculate_discount(vip_order) == Decimal("10.00")
    assert calculate_discount(normal_order) == Decimal("0.00")
    assert calculate_discount(invalid_order) == Decimal("0.00")
 
# 反模式:使用真实时间导致偶发失败
def test_is_today_weekend():
    from datetime import date
    assert is_weekend(date.today())  # 周一到周五都会失败
 
# 反模式:测试之间共享状态
_users = []
 
def test_create_user():
    _users.append({"id": 1})
    assert len(_users) == 1
 
def test_list_users():
    assert _users == [{"id": 1}]  # 依赖 test_create_user 先执行

  • [Requirement] 测试文件命名:test_<模块名>.py,与被测源码目录镜像对应(src/a/b.py → tests/a/test_b.py)。
  • [Requirement] 测试函数命名:test_<被测函数>_<场景>_<预期结果>,例如 test_calculate_discount_when_vip_returns_10_percent。
  • [Standard] 测试类命名:Test<被测类名>,例如 TestOrderService。
  • [Standard] fixture 命名应描述其提供的数据,而非实现细节,例如 valid_order 而非 order_fixture_1。
  • [Default] 测试变量名应体现业务含义,避免 data、obj、tmp 等无意义命名。
  • [Suggestion] 对参数化测试,用 ids 参数为每组用例命名,便于失败时定位。
# tests/services/test_order_service.py
from decimal import Decimal
 
import pytest
 
 
class TestOrderService:
    def test_calculate_discount_when_vip_returns_10_percent(self) -> None:
        ...
 
    def test_calculate_discount_when_amount_below_minimum_returns_zero(self) -> None:
        ...
 
 
@pytest.mark.parametrize(
    "user_type, amount, expected",
    [
        ("vip", Decimal("100.00"), Decimal("10.00")),
        ("normal", Decimal("100.00"), Decimal("0.00")),
        ("vip", Decimal("9.99"), Decimal("0.00")),
    ],
    ids=["vip-above-minimum", "normal-user", "vip-below-minimum"],
)
def test_calculate_discount(user_type, amount, expected) -> None:
    ...
# 反模式:测试函数名无法表达意图
def test1():
    ...
 
def test_discount():
    ...
 
# 反模式:fixture 命名无业务含义
@pytest.fixture
def data():
    return {"id": 1, "name": "test"}
 
# 反模式:参数化无 ids,失败时只显示 [0]、[1]
@pytest.mark.parametrize("x, y, expected", [(1, 2, 3), (0, 0, 0)])
def test_add(x, y, expected):
    assert x + y == expected

  • [Requirement] 使用框架原生断言(pytest 用 assert,JUnit 用 assertEquals),禁止手写 if + raise AssertionError。
  • [Requirement] 断言必须包含清晰的失败信息,可通过 assert ..., "msg" 或 pytest.approx 等方式补充。
  • [Standard] 浮点数比较使用 pytest.approx 或 math.isclose,禁止直接 == 比较。
  • [Standard] 异常断言使用 pytest.raises 并校验异常类型和消息,禁止仅 try/except + pass。
  • [Default] 断言应聚焦被测行为的结果,避免断言实现细节(如内部状态、私有属性)。
  • [Suggestion] 复合断言拆分为多个独立断言,便于定位失败原因。
import pytest
from decimal import Decimal
 
 
def test_calculate_discount_vip() -> None:
    result = calculate_discount("vip", Decimal("100.00"))
    assert result == Decimal("10.00"), "VIP 用户应享受 10% 折扣"
 
 
def test_float_comparison() -> None:
    result = 0.1 + 0.2
    assert result == pytest.approx(0.3, abs=1e-9)
 
 
def test_invalid_user_type_raises() -> None:
    with pytest.raises(ValueError, match="unsupported user_type: invalid"):
        calculate_discount("invalid", Decimal("100.00"))
 
 
def test_create_user_returns_expected_fields() -> None:
    user = create_user(name="Alice", age=30)
    assert user.name == "Alice"
    assert user.age == 30
    assert user.id > 0
# 反模式:手写断言错误
def test_discount():
    result = calculate_discount("vip", Decimal("100"))
    if result != Decimal("10"):
        raise AssertionError("wrong")
 
# 反模式:浮点数直接比较
def test_float():
    assert 0.1 + 0.2 == 0.3  # 永远失败
 
# 反模式:吞掉异常不校验
def test_raises():
    try:
        calculate_discount("invalid", Decimal("100"))
    except ValueError:
        pass  # 没有断言异常类型和消息
 
# 反模式:断言私有实现细节
def test_service():
    service = OrderService()
    service.process()
    assert service._internal_cache == {}  # 不应断言私有状态

  • [Requirement] 核心业务模块行覆盖率不低于 80%,关键模块(支付、鉴权、订单)不低于 90%;低于阈值的合并请求不得合并。
  • [Requirement] 新增代码的行覆盖率不低于 80%;CI 中开启增量覆盖率检查。
  • [Standard] 覆盖率统计必须排除:生成代码、迁移脚本、配置文件、DTO/POJO 等无逻辑代码。
  • [Standard] 关注分支覆盖率与条件覆盖率,而非仅行覆盖率;关键逻辑应覆盖所有分支。
  • [Default] 不追求 100% 覆盖率;对无法覆盖的代码(如防御性分支)用 # pragma: no cover 标注并说明原因。
  • [Suggestion] 定期审视覆盖率报告,识别未覆盖的高风险分支并补充测试。
  • [Caution] 禁止为了提升覆盖率而写无意义断言的测试(如只调用函数不校验结果),这种测试不产生价值。
# pyproject.toml 覆盖率配置
[tool.coverage.run]
source = ["src/myapp"]
omit = [
    "src/myapp/migrations/*",
    "src/myapp/schemas/*",     # DTO/Pydantic Model
    "src/myapp/config.py",
    "*/__init__.py",
]
 
[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]
# 生成覆盖率报告
pytest --cov=src/myapp --cov-report=term-missing --cov-report=html
 
# 增量覆盖率(diff-cover)
diff-cover coverage.xml --compare-branch=origin/main --fail-under=80
# 对防御性分支标注排除
def get_config(key: str) -> str:
    value = os.environ.get(key)
    if value is None:  # pragma: no cover - 环境变量在生产中总是存在
        return "default"
    return value
# 反模式:为覆盖率凑数,无断言
def test_user_service():
    service = UserService()
    service.get_user(1)  # 没有断言,只是为了让行被执行到
 
# 反模式:忽略覆盖率门禁
# CI 配置中删除 --cov-fail-under=80,或设为 0
 
# 反模式:覆盖率统计包含无逻辑代码
# 把 DTO、枚举、常量文件纳入覆盖率,拉低有效覆盖率质量

  • [Requirement] 单元测试中所有外部依赖(数据库、HTTP 接口、消息队列、缓存、文件系统、时钟)必须 Mock,禁止访问真实外部系统。
  • [Requirement] Mock 必须基于接口/抽象,而非具体实现;依赖通过构造函数或参数注入,便于替换。
  • [Standard] Mock 只验证交互契约(是否调用、调用参数、返回值),不验证实现细节。
  • [Standard] Mock 返回值应贴近真实数据结构,避免用过于简化的假数据掩盖集成问题。
  • [Default] 优先使用桩(Stub)返回固定值,而非严格 Mock(Mock)校验调用次数;仅在需要验证交互时使用 Mock。
  • [Suggestion] 使用 pytest-mock 的 mocker fixture,避免手动 mock.patch 带来的清理问题。
  • [Caution] 禁止 Mock 被测类本身;禁止 Mock 掉被测函数内部调用的私有方法(这是在测实现而非行为)。
  • [Caution] Mock 必须设置 spec=True 或使用接口类型,防止 Mock 出不存在的方法导致测试通过但运行时失败。
# tests/services/test_user_service.py
import pytest
from myapp.repositories import UserRepository
from myapp.services import UserService
 
 
@pytest.fixture
def repo(mocker) -> UserRepository:
    return mocker.Mock(spec=UserRepository)
 
 
@pytest.fixture
def service(repo: UserRepository) -> UserService:
    return UserService(repo)
 
 
def test_get_user_returns_user_when_exists(service: UserService, repo) -> None:
    # Arrange
    repo.get_by_id.return_value = {"id": 1, "name": "Alice"}
 
    # Act
    user = service.get_user(1)
 
    # Assert
    assert user.name == "Alice"
    repo.get_by_id.assert_called_once_with(1)
 
 
def test_get_user_raises_when_not_found(service: UserService, repo) -> None:
    repo.get_by_id.return_value = None
 
    with pytest.raises(ValueError, match="user not found"):
        service.get_user(999)
# 反模式的 Mock:Mock 掉被测函数内部私有方法
def test_process(service, mocker):
    mocker.patch.object(service, "_validate")  # 不应 Mock 被测对象的私有方法
    mocker.patch.object(service, "_save")
    service.process()
    service._save.assert_called_once()  # 测的是实现细节
# 反模式:Mock 无 spec,可能 Mock 出不存在的方法
def test_service(mocker):
    repo = mocker.Mock()  # 无 spec
    service = UserService(repo)
    service.get_user(1)
    repo.get_by_idd.assert_called_once()  # 拼写错误也能通过,但运行时会 AttributeError
 
# 反模式:单元测试中访问真实数据库
def test_get_user():
    service = UserService(RealUserRepository())  # 连接真实 DB
    user = service.get_user(1)  # 依赖测试环境数据
 
# 反模式:Mock 被测类本身
def test_service(mocker):
    mocker.patch.object(UserService, "_calculate")
    service = UserService()
    assert service.process() == ...  # 核心逻辑被 Mock 掉,测试无意义