单元测试规范
单元测试编写与覆盖率要求
- [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的mockerfixture,避免手动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 掉,测试无意义