跳转到内容

测试数据与mock规范

测试数据构造、Mock 策略


  • [Requirement] 测试数据必须显式构造,禁止依赖测试环境已有数据;每个测试的前置数据在测试内或 fixture 中明确创建。
  • [Requirement] 测试数据必须使用确定性值:固定 ID、固定时间戳、固定枚举值,禁止使用随机数、当前时间等不确定输入。
  • [Standard] 测试数据应最小化:只构造被测逻辑所需的字段,无关字段使用合理默认值,避免过度构造干扰阅读。
  • [Standard] 边界值数据必须显式构造并覆盖:空字符串、空集合、零值、最大值、null、超长字符串等。
  • [Default] 数据构造方式优先级:Factory > Fixture > 测试内直接构造 > 数据库预置脚本。
  • [Suggestion] 对常见业务对象(用户、订单、商品)建立共享的测试数据生成器,避免每个测试重复手写。
  • [Caution] 禁止在测试数据中使用真实用户信息(手机号、邮箱、身份证),必须使用脱敏或虚构数据。
# 正确:显式构造确定性数据
def test_calculate_discount() -> None:
    order = {
        "id": 1001,
        "user_type": "vip",
        "amount": Decimal("100.00"),
        "created_at": datetime(2024, 1, 1, 12, 0, 0),
    }
    assert calculate_discount(order) == Decimal("10.00")
 
 
# 正确:覆盖边界值
@pytest.mark.parametrize(
    "email",
    ["", "   ", "not-an-email", "a@b", "a" * 256 + "@example.com"],
    ids=["empty", "whitespace", "invalid-format", "missing-tld", "too-long"],
)
def test_validate_email_rejects_invalid(email: str) -> None:
    assert validate_email(email) is False
# 反模式:使用随机数据导致测试不稳定
import random
 
def test_create_user():
    user = {"name": random.choice(["a", "b", "c"])}  # 随机值导致行为不确定
    ...
 
# 反模式:使用当前时间
def test_is_expired():
    order = {"expire_at": datetime.now()}  # 不同时间点执行结果不同
    ...
 
# 反模式:使用真实用户信息
def test_send_sms():
    user = {"phone": "13800138000"}  # 真实手机号,可能误发短信

  • [Requirement] Mock 必须基于接口(Protocol/ABC)或明确的类,禁止对动态属性或字符串路径做无类型 Mock。
  • [Requirement] Mock 对象必须设置 spec / autospec,防止 Mock 出不存在的方法或属性导致”假通过”。
  • [Standard] Mock 的粒度原则:能 Mock 外部依赖就不 Mock 内部方法;能 Mock 返回值就不 Mock 调用链。
  • [Standard] Mock 返回值应贴近真实契约:结构完整、字段类型正确;不要返回过于简化的数据掩盖序列化问题。
  • [Default] 优先使用桩(Stub)返回固定值;仅在需要验证调用交互时使用严格 Mock(Mock + assert_called)。
  • [Suggestion] 对第三方 HTTP 接口,使用 responses/respx/WireMock 模拟 HTTP 层,而非 Mock HTTP 客户端库。
  • [Caution] 禁止 Mock 被测对象自身;禁止 Mock 被测函数内部调用的私有方法——这会让测试退化为验证实现细节。
  • [Caution] 禁止过度 Mock:当一个测试需要 Mock 5 个以上依赖时,说明被测对象职责过多,应重构。
# 正确:基于接口的 Mock,带 spec
from typing import Protocol
 
class UserRepository(Protocol):
    def get_by_id(self, user_id: int) -> dict | None: ...
 
 
def test_get_user(mocker):
    repo = mocker.Mock(spec=UserRepository)
    repo.get_by_id.return_value = {"id": 1, "name": "Alice"}
    service = UserService(repo)
 
    user = service.get_user(1)
 
    assert user.name == "Alice"
    repo.get_by_id.assert_called_once_with(1)
# 正确:模拟 HTTP 接口
import respx
 
@respx.mock
async def test_fetch_user(client):
    route = respx.get("https://api.example.com/users/1").mock(
        return_value=httpx.Response(200, json={"id": 1, "name": "Alice"})
    )
    user = await fetch_user(1)
    assert user.name == "Alice"
    assert route.called
# 反模式:无 spec 的 Mock,拼写错误也能通过
def test_service(mocker):
    repo = mocker.Mock()  # 无 spec
    service = UserService(repo)
    service.get_user(1)
    repo.get_by_idd.assert_called_once()  # 拼写错误,Mock 自动创建该方法
 
 
# 反模式:Mock 被测对象的私有方法
def test_process(mocker):
    service = OrderService()
    mocker.patch.object(service, "_validate")  # 不应 Mock 被测对象
    mocker.patch.object(service, "_save")
    service.process()
 
 
# 反模式:Mock 调用链过长,难以维护
def test_complex(mocker):
    mocker.patch("a.b.c.d.e.f.g")  # 深层路径 Mock,重构时极易断裂

  • [Requirement] Fixture 必须单一职责:一个 fixture 只准备一种资源或数据,禁止”上帝 fixture”准备一切。
  • [Requirement] Fixture 必须有明确的作用域(scope):函数级默认 function,昂贵资源(DB 连接、MQ)用 session,中间状态用 module/class。
  • [Standard] Fixture 必须保证可清理:使用 yield 语法在测试结束后释放资源(关闭连接、删除数据、回滚事务)。
  • [Standard] 共享 fixture 放在 conftest.py 中,按目录层级组织;测试文件内的 fixture 仅该文件使用。
  • [Default] Fixture 命名应描述其提供的内容(valid_user、db_session),而非实现细节(fixture1、setup_user)。
  • [Suggestion] 对有副作用的 fixture,使用 autouse=True 时需谨慎,确保不会意外影响所有测试。
  • [Caution] session-scoped fixture 中禁止存放可变状态,避免测试间污染。
# conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
 
 
@pytest.fixture(scope="session")
def engine():
    """数据库引擎,整个测试会话共享"""
    eng = create_engine("postgresql://test:test@localhost:5433/testdb")
    yield eng
    eng.dispose()
 
 
@pytest.fixture
def db_session(engine):
    """每个测试独立的数据库会话,结束回滚"""
    Session = sessionmaker(bind=engine)
    session = Session()
    yield session
    session.rollback()
    session.close()
 
 
@pytest.fixture
def valid_user(db_session):
    """构造一个有效用户,测试结束自动回滚"""
    user = User(id=1001, name="Alice", email="alice@example.com")
    db_session.add(user)
    db_session.commit()
    return user
# 反模式:上帝 fixture 准备所有数据
@pytest.fixture
def setup_everything(db_session):
    user = User(id=1, name="Alice")
    order = Order(id=1, user=user)
    product = Product(id=1, name="Book")
    db_session.add_all([user, order, product])
    db_session.commit()
    return user, order, product  # 返回元组,使用方需解包,难以维护
 
 
# 反模式:无清理的 fixture
@pytest.fixture
def temp_file():
    f = open("/tmp/test.txt", "w")
    yield f  # 文件未关闭
 
# 反模式:session 级 fixture 存可变状态
@pytest.fixture(scope="session")
def shared_state():
    state = {"count": 0}
    yield state  # 测试 A 修改后影响测试 B

  • [Requirement] 对重复出现的业务对象(用户、订单、商品)必须使用工厂模式构造,禁止在测试中重复手写对象字典。
  • [Standard] 工厂应提供合理默认值,测试只需覆盖被测逻辑相关的字段;默认值应满足校验规则。
  • [Standard] 工厂支持链式覆盖:UserFactory(name="Bob") 覆盖默认 name,其余字段保持默认。
  • [Default] 优先使用 factory_boy(Python)或 Fixture Monkey(Java)等成熟库,不重复造轮子。
  • [Suggestion] 工厂方法命名体现业务语义:UserFactory.vip()、OrderFactory.paid(),提高测试可读性。
  • [Caution] 工厂默认值必须通过业务校验,否则每个测试都要手动补字段,失去工厂意义。
# tests/factories/user_factory.py
import factory
from myapp.models import User
 
 
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
    class Meta:
        model = User
        sqlalchemy_session = db_session
        sqlalchemy_session_persistence = "commit"
 
    id = factory.Sequence(lambda n: 1000 + n)
    name = factory.Faker("name")
    email = factory.Sequence(lambda n: f"user{n}@example.com")
    user_type = "normal"
    created_at = datetime(2024, 1, 1, 0, 0, 0)
 
    @classmethod
    def vip(cls, **kwargs):
        return cls(user_type="vip", **kwargs)
 
 
# tests/services/test_user_service.py
def test_get_vip_user_discount() -> None:
    user = UserFactory.vip()  # 只需关心 user_type
    assert calculate_discount(user) == Decimal("10.00")
 
 
def test_get_normal_user_no_discount() -> None:
    user = UserFactory()  # 默认 normal
    assert calculate_discount(user) == Decimal("0.00")
# 反模式:每个测试手写完整对象
def test_discount_vip():
    user = {
        "id": 1, "name": "Alice", "email": "a@b.com",
        "user_type": "vip", "created_at": "...", "status": "active",
    }
    assert calculate_discount(user) == Decimal("10.00")
 
def test_discount_normal():
    user = {
        "id": 2, "name": "Bob", "email": "b@b.com",
        "user_type": "normal", "created_at": "...", "status": "active",
    }
    assert calculate_discount(user) == Decimal("0.00")
# 大量重复字段,维护成本高
 
 
# 反模式:工厂默认值不满足校验
class UserFactory(factory.Factory):
    name = ""  # 空字符串,业务要求非空
    email = "invalid"  # 不满足邮箱格式
# 每个使用该工厂的测试都要手动补 name 和 email

  • [Requirement] 禁止以下测试数据与 Mock 反模式,一旦发现须在 CR 中标记为 Blocking。
  • [Caution] 反模式会导致测试不稳定、误导性通过、维护成本飙升,须严格避免。

以下为常见反模式清单,均应避免:

# 1. 测试数据硬编码且与生产数据耦合
def test_get_user():
    user = get_user(1)  # 假设生产/测试库 id=1 存在
    assert user is not None
 
# 2. Mock 返回值与真实契约不一致
def test_parse_response(mocker):
    client = mocker.Mock()
    client.fetch.return_value = "ok"  # 真实返回是 dict,这里是 str
    result = parse_response(client.fetch())  # 测试通过,但生产会 AttributeError
 
# 3. 断言 Mock 调用而非业务行为
def test_order(mocker):
    repo = mocker.Mock()
    service = OrderService(repo)
    service.create_order(...)
    repo.save.assert_called_once()  # 只验证调用,不验证保存的数据是否正确
 
# 4. 测试数据未清理导致脏数据
def test_create_order():
    order_repo.create(Order(id=9999, ...))
    assert order_repo.get(9999) is not None
    # 测试结束后 id=9999 的订单残留,下次运行冲突
 
# 5. 使用全局可变状态
counter = 0
 
def test_increment():
    global counter
    counter += 1
    assert counter == 1  # 依赖执行顺序
 
# 6. Mock 掉被测函数的核心逻辑
def test_calculate(mocker):
    mocker.patch("module.calculate", return_value=42)
    assert calculate(1, 2) == 42  # 被测函数被 Mock,测试无意义
□ 测试数据是否依赖环境已有数据?
□ Mock 是否带 spec/接口约束?
□ Mock 返回值是否符合真实数据结构?
□ 是否 Mock 了被测对象自身或其私有方法?
□ 测试结束后是否清理了创建的数据/资源?
□ 是否使用了随机数、当前时间等不确定输入?
□ 一个 fixture 是否承担了过多职责?
□ 工厂默认值是否能通过业务校验?