用 AI 写代码的时代,测试不是可选项,而是必选项。
你正在用 Copilot、Claude Code 或 Cursor 写 Python?你一天能产出过去一周的代码量?
很好。但问题来了——这些代码你敢直接上线吗?
AI 生成的代码”看起来对”,却未必经过深思熟虑。逻辑漏洞藏在角落里,边界条件被优雅的代码风格掩盖。过去你写一个函数要半小时,至少在脑子里过了几遍;现在 AI 三秒生成,你扫一眼就接受了。
测试,是你对 AI 代码唯一可靠的审查手段。
好消息是:AI 同样擅长写测试。过去团队跳过测试,最大的理由是”没时间写”。现在这个理由不成立了——让 AI 写代码的同时生成测试,再用测试反过来验证代码,形成闭环。写测试的成本,从”人天”降到了”人分钟”。
本文是一份面向 Python 开发者的全场景测试指南。不讲空泛的理论,从”为什么要测”讲起,到”每层测什么、怎么测”,最后落地到项目结构和 CI/CD。每个环节都有可运行的代码示例。
全文分三个部分:
- Part 1: WHY — AI 时代,为什么测试比以往更重要
- Part 2: HOW — 测试金字塔、各层级实战、工具链
- Part 3: WHAT — 项目结构、覆盖率、CI/CD 工程化落地
不管你是独立开发者还是团队成员,不管你写的是库、服务还是产品——读完这篇,你会知道每一行代码该怎么被测试保护。
Part 1: WHY — AI 时代,为什么测试比以往更重要
代码在加速,风险也在加速
2024 年之后,AI 辅助编程不再是尝鲜,而是日常。GitHub Copilot、Claude Code、Cursor——这些工具让代码产出速度提升了数倍。一个下午就能搭出过去需要一周的功能。
但速度本身不是目的,可靠的速度才是。
AI 生成的代码有一个微妙的特点:它看起来很专业。变量命名规范,结构清晰,甚至带着注释。这种”专业感”会让你放松警惕——扫一眼就接受,觉得”应该没问题”。
然而 AI 并不真正理解你的业务逻辑。它基于模式匹配生成代码,擅长的是”像正确答案”,而不是”就是正确答案”。常见的隐患包括:
- 边界条件遗漏:
price = 0时折扣函数返回NaN,AI 不会主动替你想到 - 隐式假设:AI 默认输入总是合法的,不会主动加防御性检查
- 逻辑漏洞:多条件分支中某个路径永远走不到,但代码看上去完整
- 依赖误用:调用了一个 API 的过时用法,语法正确但行为不符合预期
这些问题不会在 code review 中轻易暴露——因为代码”看起来对”。
让我们把话说明白:
- AI 写的代码 + 测试通过 = 有一定可信度的代码
- AI 写的代码 + 没有测试 = 不可信的代码
- AI 写的代码 + AI 写的测试 + 人工审查测试逻辑 = 最佳实践
人的角色变了:从写代码到审代码
AI 时代,开发者的核心工作从”写代码”转向了”审代码”。你不再逐行敲键盘,而是评估 AI 的产出是否正确、是否安全、是否符合业务需求。
人工审查有用,但有极限。你能盯着一个函数看 5 分钟,但你盯不住 50 个函数。特别是当 AI 一口气生成了一整个模块的时候——你需要一个自动化的、可重复的审查机制。
这就是测试。
测试不是”写完代码后的额外工作”,它是对 AI 代码的验收标准。没有测试通过,代码就不算完成。
写测试的成本,被 AI 打下来了
过去团队不写测试,最常见的理由是:
“业务赶,没时间写测试。”
这个理由曾经成立。手写测试确实耗时——构造 mock 数据、处理边界 case、维护 fixture……一个函数的测试可能比函数本身还长。
但现在,AI 把写测试的成本打到了地板上。
你可以这样工作:
- 让 AI 生成业务代码
- 紧接着让 AI 为这段代码生成测试
- 运行测试,如果失败,说明代码或测试有问题
- 修正后再跑,直到全部通过
这个循环的时间成本,从”人天”变成了”人分钟”。AI 写测试的速度和写业务代码一样快,而且它特别擅长生成参数化测试和边界 case——这些恰恰是人类最容易偷懒跳过的部分。
Part 2: HOW — 怎么测试
测试金字塔:先搞清楚测哪层
在动手写测试之前,先回答一个问题:这段代码应该用什么级别的测试来覆盖?
经典的测试金字塔把测试分为三层1:
每层的定位不同,成本和收益也不同:
| 测试层级 | 涉及范围 | 运行速度 | 易写易维护性 | 典型工具 | 擅长发现 | 容易漏检 |
|---|---|---|---|---|---|---|
| 端到端测试 | 产品全流程(UI/API) | 慢(分钟级) | 低 | Playwright, Selenium, httpx | 真实用户场景、跨系统集成 | 边界条件、异常路径(case 太重,只覆盖主流程) |
| 集成测试 | 模块间交互、数据库、外部服务 | 中(秒级) | 中 | pytest + testcontainers, requests | 接口不匹配、数据流转错误、配置问题 | 完整用户路径、UI 交互问题 |
| 单元测试 | 单个函数/类 | 快(毫秒级) | 高 | pytest, unittest, Hypothesis | 逻辑错误、边界条件、异常处理 | 模块间集成问题、真实环境差异 |
各层测什么:
单元测试 — 验证单个函数/类的逻辑正确性:
- 正常路径:给合法输入,返回预期结果
- 边界条件:空值、零值、极大极小、空列表、空字符串
- 异常处理:非法输入是否抛出正确的异常
- 分支覆盖:if/else 的每条路径都走到
集成测试 — 验证模块间交互、与外部服务的对接:
- API 接口:请求/响应格式、状态码、错误处理
- 数据库操作:CRUD 是否正确写入和读取、事务是否正常回滚
- 外部服务调用:第三方 API 不可用时的降级逻辑
端到端测试 — 只覆盖核心路径,不测边界条件:
- 核心用户路径:注册 → 登录 → 核心操作 → 结果验证
- 跨系统集成:前端 → 后端 → 数据库 → 第三方服务的全链路
- 关键页面交互:表单提交、页面跳转、错误提示展示
不同项目类型,侧重点不同:
- Python 库(如一个工具包):重单元测试,确保每个公开 API 行为正确
- Web 服务(如 FastAPI 后端):重集成测试,确保接口和数据库协作正常
- 产品应用(如带 UI 的全栈应用):三层都要有,端到端测试覆盖核心路径
不管哪种项目,AI 场景下每层都不可省略。AI 生成的代码可能在任何层级出问题——函数逻辑可能错,模块拼接可能错,整体流程也可能错。
逻辑验证方法
验证单个函数/类的输入→输出是否正确。这是金字塔的底座,数量最多、跑得最快。
假设我们有一个折扣计算函数:
def calculate_discount(price: float, rate: float) -> float:
"""计算折后价格。rate 为折扣率,0.1 表示打九折。"""
if not 0 <= rate <= 1:
raise ValueError(f"折扣率必须在 0~1 之间,收到: {rate}")
return price * (1 - rate)
基本断言 — 用 pytest
最直接的方式,一个测试验证一个预期:
def test_calculate_discount_normal():
assert calculate_discount(100, 0.1) == 90.0
def test_calculate_discount_zero_rate():
assert calculate_discount(100, 0) == 100.0
def test_calculate_discount_invalid_rate():
with pytest.raises(ValueError, match="折扣率必须在 0~1 之间"):
calculate_discount(100, 1.5)
参数化 — 用 @pytest.mark.parametrize
当你需要用多组数据测同一个逻辑时,别写 N 个函数,用参数化:
@pytest.mark.parametrize("price, rate, expected", [
(100, 0.1, 90.0), # 正常折扣
(0, 0.1, 0.0), # 零价格
(100, 0, 100.0), # 无折扣
(100, 1, 0.0), # 全额折扣
(99.99, 0.5, 49.995),# 小数精度
])
def test_calculate_discount_cases(price, rate, expected):
assert calculate_discount(price, rate) == pytest.approx(expected)
属性测试 — 用 Hypothesis
人类容易漏想边界 case,Hypothesis 帮你自动探索。你不指定具体输入,而是描述输入的”性质”,让框架随机生成几百组数据来跑:
from hypothesis import given
from hypothesis import strategies as st
@given(
price=st.floats(min_value=0, max_value=10000, allow_nan=False),
rate=st.floats(min_value=0, max_value=1, allow_nan=False),
)
def test_discount_always_in_range(price, rate):
result = calculate_discount(price, rate)
assert 0 <= result <= price
如果 Hypothesis 发现了某组让断言失败的输入,它会自动缩小到最小反例(shrinking),告诉你具体是哪个边界值出了问题。
形式化验证 — 用 Z3
Hypothesis 通过随机输入来寻找 bug,但它不能证明没有 bug——跑 200 组没出错,不代表第 201 组也没问题。如果你需要数学级别的保证,可以用 Z3。
Z3 是微软开源的 SMT 求解器(pip install z3-solver)。思路是把代码逻辑翻译成约束,让 Z3 尝试找反例——如果找不到,就说明性质对所有合法输入都成立:
from z3 import Real, Solver, And, Not, unsat
def test_discount_in_range_proven():
price = Real('price')
rate = Real('rate')
result = price * (1 - rate)
s = Solver()
# 前提:price >= 0, 0 <= rate <= 1
s.add(price >= 0, rate >= 0, rate <= 1)
# 尝试找反例:result < 0 或 result > price
s.add(Not(And(result >= 0, result <= price)))
assert s.check() == unsat # unsat = 无反例 = 性质恒成立
Hypothesis 说”我试了 200 组都没问题”,Z3 说”我证明了不可能有问题”。代价是你需要把逻辑手动建模成约束——适合核心算法和关键不变量,不适合大面积铺开。
交互验证方法
验证模块间协作、用户流程是否正确。从浏览器交互到 API 全链路到框架接口测试。
浏览器测试 — Playwright
# 安装:pip install pytest-playwright && playwright install
from playwright.sync_api import expect
def test_user_login_flow(page):
# 1. 访问登录页
page.goto("http://localhost:3000/login")
# 2. 填写表单
page.fill("#email", "alice@example.com")
page.fill("#password", "correct-password")
# 3. 点击登录
page.click("button[type=submit]")
# 4. 验证跳转到首页,显示欢迎信息
expect(page).to_have_url("http://localhost:3000/dashboard")
expect(page.locator(".welcome-message")).to_contain_text("Hello, Alice")
def test_user_login_wrong_password(page):
page.goto("http://localhost:3000/login")
page.fill("#email", "alice@example.com")
page.fill("#password", "wrong-password")
page.click("button[type=submit]")
# 应该停留在登录页,显示错误提示
expect(page).to_have_url("http://localhost:3000/login")
expect(page.locator(".error-message")).to_be_visible()
Web 框架接口测试 — FastAPI TestClient
不需要启动服务器,直接测接口:
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.mark.asyncio
async def test_create_user(client):
resp = await client.post("/users", json={"name": "Alice", "email": "alice@example.com"})
assert resp.status_code == 201
assert resp.json()["name"] == "Alice"
@pytest.mark.asyncio
async def test_create_user_duplicate_email(client):
await client.post("/users", json={"name": "Alice", "email": "alice@example.com"})
resp = await client.post("/users", json={"name": "Bob", "email": "alice@example.com"})
assert resp.status_code == 409
Mock & 真实方案
测试中如何处理外部依赖——mock 替身 vs 真实服务。
Mock 外部 HTTP — respx
你的代码调用了第三方天气 API,测试时不应该真的去请求。用 respx 模拟响应:
import respx
from httpx import Response
@respx.mock
@pytest.mark.asyncio
async def test_fetch_weather_success():
respx.get("https://api.weather.com/v1/hangzhou").mock(
return_value=Response(200, json={"temp": 22, "condition": "sunny"})
)
result = await fetch_weather("hangzhou")
assert result.temp == 22
assert result.condition == "sunny"
@respx.mock
@pytest.mark.asyncio
async def test_fetch_weather_fallback_on_error():
respx.get("https://api.weather.com/v1/hangzhou").mock(
return_value=Response(500)
)
result = await fetch_weather("hangzhou")
assert result == DEFAULT_WEATHER # 验证降级逻辑
Mock 内部依赖 — pytest-mock
当被测模块依赖数据库、文件系统或其他还未准备好联调的组件时,用 mocker.patch 隔离:
async def test_get_user_name(client, mocker):
mocker.patch("app.db.find_user", return_value={"name": "Alice"})
resp = await client.get("/users/1/name")
assert resp.json()["name"] == "Alice"
async def test_get_user_name_not_found(client, mocker):
mocker.patch("app.db.find_user", return_value=None)
resp = await client.get("/users/999/name")
assert resp.status_code == 404
原则:只 mock 你不拥有的东西。 数据库、第三方 API、文件系统——这些 mock 掉。自己写的内部模块尽量不 mock,否则测试和实现脱节。
不一定要 Mock——能重置的基础设施直接用真的
Mock 的目的是隔离不可控的依赖。但如果你的基础设施本身就能快速重置——比如用 IaC 一键拉起的测试数据库、每次 CI 都重建的 Redis 实例、或者云厂商提供的临时环境——那直接跑真实服务反而更可靠。Mock 越多,测试和生产环境的差距越大;能用真的就用真的,只在”不可控、不稳定、有副作用”的地方才 mock。
工具链速查表
前面的示例已经把主要工具带了出来。这里给一张汇总表,方便速查:
| 场景 | 推荐工具 | 一句话说明 |
|---|---|---|
| 单元测试 | pytest | Python 测试的事实标准 |
| Mock / Stub | unittest.mock, pytest-mock | 隔离外部依赖 |
| 参数化测试 | @pytest.mark.parametrize | 一组逻辑,多组数据 |
| 属性测试 | Hypothesis | 自动生成随机输入,发现边界 case |
| 形式化验证 | z3-solver | 用 SMT 求解器证明性质恒成立 |
| 异步测试 | pytest-asyncio | async/await 测试支持 |
| HTTP Mock | respx, pytest-httpx | 模拟 HTTP 响应,不发真实请求 |
| Web 框架测试 | FastAPI TestClient, Flask test_client | 不启动服务器,直接测接口 |
| CLI 测试 | click.testing.CliRunner, typer.testing | 测命令行工具 |
| 数据库测试 | 真实数据库 / IaC 临时环境 | 能重置就用真的,比 mock 更可靠 |
| 数据工厂 | factory_boy | 构造复杂测试数据 |
| 性能基准 | pytest-benchmark, locust | 衡量和对比性能 |
| 快照测试 | syrupy | 输出对比上次快照,检测意外变化 |
| 端到端 / UI | Playwright (pytest-playwright) | 浏览器自动化测试 |
| 覆盖率 | pytest-cov | 查看测试覆盖了多少代码 |
| 静态分析 | mypy, ruff | 不运行代码就能发现类型和风格问题 |
Part 3: WHAT — 工程化落地
知道了为什么要测、怎么测之后,最后一个问题是:怎么在真实项目中落地?
测试不只是写几个 test_ 函数的事。项目结构怎么组织、覆盖率怎么衡量、CI 怎么跑——这些决定了测试能不能长期存活,而不是写完就烂掉。
项目结构与最佳实践
推荐目录结构
my_project/
├── src/
│ └── my_project/
│ ├── __init__.py
│ ├── models.py
│ ├── services.py
│ ├── api.py
│ └── auth/
│ ├── __init__.py
│ ├── login.py
│ └── permissions.py
├── tests/
│ ├── conftest.py # 全局 fixture
│ ├── unit/ # 镜像 src/ 结构,一个源文件对应一个测试文件
│ │ ├── conftest.py
│ │ ├── test_models.py
│ │ ├── test_services.py
│ │ ├── test_api.py
│ │ └── auth/
│ │ ├── test_login.py
│ │ └── test_permissions.py
│ ├── integration/
│ │ ├── conftest.py # 集成测试专用 fixture(数据库、HTTP mock)
│ │ ├── test_api.py
│ │ └── test_db.py
│ └── e2e/
│ ├── conftest.py # 端到端专用 fixture(浏览器、服务启动)
│ └── test_flows.py
└── pyproject.toml
按测试层级分目录,而不是按业务模块。理由:
- 不同层级的 fixture 和依赖差异很大,混在一起会互相干扰
- 可以按层级单独运行:
pytest tests/unit跑单元,pytest tests/integration跑集成 - CI 中可以并行跑不同层级,加速流水线
unit/ 内部镜像源码目录结构——src/my_project/auth/login.py 对应 tests/unit/auth/test_login.py,一眼就能看出哪个模块还没有测试。
conftest.py 分层
conftest.py 是 pytest 的 fixture 共享机制。放在哪个目录,就对那个目录下所有测试生效。
# tests/conftest.py — 全局共享
@pytest.fixture
def sample_user():
return {"name": "Alice", "email": "alice@example.com"}
# tests/integration/conftest.py — 集成测试专用
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:16") as pg:
yield pg.get_connection_url()
@pytest.fixture
def mock_db(mocker):
return mocker.patch("app.db.session")
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
测试命名规范
# 好:清楚说明 测什么_在什么条件下_预期什么结果
def test_calculate_discount_with_zero_price_returns_zero(): ...
def test_create_user_with_duplicate_email_returns_409(): ...
# 不好:太模糊
def test_discount(): ...
def test_user(): ...
def test_it_works(): ...
常见反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 一个测试验证十件事 | 失败时不知道哪里出了问题 | 一个测试一个断言(或一组紧密相关的断言) |
| Mock 自己写的代码 | 测试和实现脱节,重构后测试还在”通过” | 只 mock 外部依赖 |
| 测试之间有依赖 | test_A 创建数据,test_B 依赖 test_A 的数据 | 每个测试独立,用 fixture 准备数据 |
| 追求 100% 覆盖率 | 为了凑数字写无意义的测试 | 覆盖率是信号,不是目标 |
| 忽略测试可读性 | 测试代码也是代码,也需要维护 | 给测试起好名字,保持简洁 |
覆盖率与质量门禁
覆盖率是怎么算出来的? Python 提供了
sys.settrace()函数,允许注册一个 trace 回调。每当解释器执行 call(函数调用)、line(新的一行代码)、return(函数返回)、exception(异常抛出)事件时都会触发回调。coverage.py利用这个机制,在每一行代码执行时记录下(文件名, 行号),从而知道哪些行被执行过。
配置 pytest-cov
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing --cov-report=html"
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 80
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"if __name__ == .__main__.",
]
运行 pytest 时自动输出覆盖率报告:
$ pytest
---------- coverage: platform linux, python 3.12 ----------
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------------------------
src/my_project/models.py 45 3 12 2 91% 23, 45-46
src/my_project/services.py 62 8 18 4 84% 31-35, 78-82
src/my_project/api.py 38 0 10 0 100%
---------------------------------------------------------------------
TOTAL 145 11 40 6 90%
Required test coverage of 80% reached. Total coverage: 90.00%
覆盖率不是目标,是信号
覆盖率告诉你哪些代码没被测试到,帮你发现盲区。但:
- 80% 覆盖率不意味着 80% 的 bug 被发现了
- 100% 覆盖率不意味着没有 bug
- 一行代码被执行过 ≠ 这行代码的行为被验证过
合理用法:
- 设定一个基线(比如 80%),新代码不能拉低整体覆盖率
- 关注
Missing列,看哪些分支没覆盖到,判断是否需要补测试 - 不要为了凑数字写无意义的测试
CI/CD 集成
测试写好了,不放进 CI 就等于没写。
GitHub Actions 示例
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-
- name: Install dependencies
run: pip install -e ".[test]"
- name: Run unit tests
run: pytest tests/unit --cov=src --cov-report=xml -q
- name: Run integration tests
if: success()
run: pytest tests/integration -q
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: coverage.xml
fail_ci_if_error: true
关键点
多版本矩阵:至少测你声明支持的最低版本和最新版本。如果你的库声明 requires-python = ">=3.11",那 3.11 和 3.13 都要跑。
按层级分步运行:单元测试和集成测试分开跑。单元测试失败了就不用浪费时间跑集成了。
缓存依赖:每次 CI 都重新 pip install 很慢。用 actions/cache 缓存 pip 下载的包,只在 pyproject.toml 变化时重新安装。
覆盖率门禁:用 --cov-fail-under=80 或 Codecov 的 PR 检查,确保新代码不拉低覆盖率。
pyproject.toml 完整测试配置参考
[project.optional-dependencies]
test = [
"pytest>=8.0",
"pytest-cov>=5.0",
"pytest-asyncio>=0.24",
"pytest-mock>=3.14",
"hypothesis>=6.100",
"respx>=0.21",
"httpx>=0.27",
"testcontainers[postgres]>=4.0",
"factory-boy>=3.3",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-q --tb=short"
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
]
[tool.coverage.run]
source = ["src"]
branch = true
[tool.coverage.report]
show_missing = true
fail_under = 80
在 AI 时代,不写测试的代码是不完整的代码。而写测试,从来没有像今天这么容易。