跳转至

两个测试都在测“创建计划”,为什么一个还不够

假设核心规则把 8 小时错误地分成了 9 小时。直接调用 build_plan 的测试会精确 告诉你规则错了。再假设核心规则正确,但 FastAPI exception handler 把 PlanConflict 变成了 500。直接调用 core 的测试看不到这个问题。

同一条用户路径需要两种证据,不是因为“测试越多越安心”,而是两种缺陷只有跨过 不同边界才可见。

概念|单元测试 在外部依赖受控的条件下,直接验证一个聚焦的行为边界。边界可以包含几个小型 协作对象,不由函数或文件数量决定。

概念|集成测试 让目标真实组件共同参与,验证它们之间的协作契约;不相关的数据库或公网服务 仍然可以保持受控。

上一章的验证清单最后一列已经填了“单元”和“集成”,但只说了两者各自的用途:单元 直接调用 core 且依赖受控,集成让真实 FastAPI 组件共同参与。本章补上判定依据—— 边界该划到多大、凭什么为一项风险选择层级,以及两层各自应该断言什么。

运行环境

示例已在 Python 3.12.3、pytest 9.1.1、FastAPI 0.115.14、Pydantic 2.13.4 与 HTTPX 0.28.1 验证。本章同时展示同步和异步的进程内 HTTP 测试入口,具体工具在 对应小节出现时说明。

1. 先问“这个边界能看见哪种错误”

概念|测试边界 为目标风险选择能够观察它的最小真实范围。边界越大不一定证据越强,关键是目标 缺陷是否可见,以及失败后能否定位。

考虑两项风险:

缺陷 直接调用 core 通过 FastAPI 请求
计划时长规则错误 可见,定位精确 可见,但链路更长
业务错误被映射成 500 不可见 可见

这比“单元测试快,集成测试慢”更接近本质。速度重要,但测试层级首先决定被测 系统包含哪些真实组件、哪些依赖受控,以及目标缺陷是否能被观察。

2. 单元测试包住一个行为,不一定只包一个函数

上一章说单元是“直接调用 core、依赖受控”的位置,那是它的用途;这里补上它的 范围——单元不等于一个函数。假设 core 接收一个可控 adapter:

from dataclasses import dataclass

@dataclass(frozen=True)
class Plan:
    topic: str
    weekly_hours: int

class PlanConflict(Exception):
    pass

def build_plan(topic: str, weekly_hours: int, existing_topics: set[str]) -> Plan:
    if topic in existing_topics:
        raise PlanConflict(topic)
    return Plan(topic=topic, weekly_hours=weekly_hours)

一个聚焦的单元测试可以直接证明业务规则:

import pytest

def test_build_plan_rejects_existing_topic():
    with pytest.raises(PlanConflict):
        build_plan("pytest", 8, {"pytest"})

这里的“单元”是 build_plan 所代表的小型行为边界。未来它可能拆成两个 helper, 只要公开行为不变,测试不必跟着私有结构重写。单元大小由行为与诊断价值决定, 不是由文件数量决定。

3. 集成测试让真实 FastAPI 组件共同工作

上一章说集成是“让真实 FastAPI 组件共同参与”,具体到 HTTP 层,就是路由匹配、 请求校验、handler 和响应序列化都要真实参与:

概念|进程内 ASGI 测试客户端 不监听真实端口,但让请求经过真实 ASGI 应用的测试入口。同步场景可使用 TestClient;需要在测试中直接 await 时可使用 ASGITransportAsyncClient

同步入口:让请求经过真实应用

from fastapi.testclient import TestClient

def test_create_plan_returns_conflict(client: TestClient):
    client.post("/plans", json={"topic": "pytest", "weekly_hours": 8})

    response = client.post(
        "/plans", json={"topic": "pytest", "weekly_hours": 8}
    )

    assert response.status_code == 409
    assert response.json()["error"]["code"] == "plan_conflict"

测试签名里的 client 不是全局变量,也不是测试自己造的对象。这里先只需要知道: pytest 会按参数名把已经准备好的对象交给测试,client 代表一个装好真实应用的 进程内 HTTP 客户端。准备函数怎样声明、对象能被多少测试共用、测试结束后怎样 清理,都留到 W01-L04-P04;下面 async 示例里的 app 参数同理。

TestClient 不需要真的监听端口,但请求仍经过 ASGI 应用。它能够发现:

  • 路由是否注册;
  • Pydantic 是否正确绑定并校验请求;
  • 领域错误是否被 handler 转换;
  • 响应模型是否按公开契约序列化。

直接调用 route 函数并手工传入模型会绕过部分框架行为;让一个 mock client 直接 返回预制 409,更是让替身提前给出了正确答案,不能证明真实集成。

异步入口:调用同一应用,但不代管 lifespan

如果测试本身必须是 async,可使用:

import pytest
from httpx import ASGITransport, AsyncClient

@pytest.mark.anyio
async def test_create_plan_success(app):
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as client:
        response = await client.post(
            "/plans", json={"topic": "pytest", "weekly_hours": 8}
        )
    assert response.status_code == 201

一个重要边界:HTTPX 的 ASGITransport 不负责触发应用 lifespan。若测试依赖 startup/shutdown,需要显式管理 lifespan;不能因为路由测试通过就推断启动资源 已经验证。本课的最小示例不依赖 lifespan。

4. 两层不是把同一张卷子复印两遍

对冲突路径,可以这样分工:

层级 主要问题 主要 oracle
core 单元 已存在 topic 是否被拒绝 抛出具体 PlanConflict,状态不变
HTTP 集成 领域错误是否正确公开 409,公开错误代码与响应结构

单元层不需要断言 HTTP 状态码;core 不认识 HTTP。集成层也不必复制 core 的每个 边界组合,它更关注组件契约。这样任一层失败时,信号更有诊断价值。

“最低充分层级”意味着:优先在能够发现目标缺陷的最小边界验证,同时保留必要的 真实集成。两个极端都不好:所有测试都 mock 会失去兼容性证据;所有测试都启动 完整系统会变慢、变脆,也更难定位。

常见误区

  • 单元就是一个函数。 行为边界可能包含小型协作单元;关键是依赖是否受控、 失败是否聚焦。
  • 集成就是使用了 client。 如果 client 或 route 被整体替身化,真实组件并未 协作。
  • 两层要覆盖完全相同的输入。 这会制造重复而不是互补证据。
  • 连接越多组件越真实。 本课需要真实 FastAPI 边界,不需要 PostgreSQL 或公网。

本章小结

测试层级由风险可见性决定。core 单元在受控依赖下精确证明业务规则;进程内 ASGI 集成让真实 FastAPI 组件协作,证明 HTTP 契约。两层应使用不同目的和 oracle, 共同覆盖同一路径的规则风险与兼容风险。

练习

请为以下风险选择测试位置,并说明另一层为什么不充分或不必重复:

  1. weekly_hours=8 被算法错误地分配为 9 小时。
  2. 请求 JSON 中 weekly_hours="eight" 被接受。
  3. PlanConflict 被 handler 返回为 500。
  4. 响应意外泄露内部字段 debug_notes

练习解析

  1. 主要放 core 单元层,直接断言时长不变量,定位最精确;HTTP 层只需代表性成功 案例,不必复制全部算法边界。
  2. 放 HTTP 集成层,因为字符串到整数的请求绑定与 Pydantic 校验属于框架边界。
  3. 放 HTTP 集成层;core 单元可以证明抛出 PlanConflict,却看不到 HTTP 映射。
  4. 放 HTTP 集成层,因为 response model/序列化是否过滤内部字段需要真实响应链。

参考资料