用 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 把写测试的成本打到了地板上。

你可以这样工作:

  1. 让 AI 生成业务代码
  2. 紧接着让 AI 为这段代码生成测试
  3. 运行测试,如果失败,说明代码或测试有问题
  4. 修正后再跑,直到全部通过

这个循环的时间成本,从”人天”变成了”人分钟”。AI 写测试的速度和写业务代码一样快,而且它特别擅长生成参数化测试和边界 case——这些恰恰是人类最容易偷懒跳过的部分。


Part 2: HOW — 怎么测试

测试金字塔:先搞清楚测哪层

在动手写测试之前,先回答一个问题:这段代码应该用什么级别的测试来覆盖?

经典的测试金字塔把测试分为三层1

E2E集成测试单元测试← 少量,覆盖核心路径← 适量,验证模块间协作← 大量,快速验证逻辑

每层的定位不同,成本和收益也不同:

测试层级涉及范围运行速度易写易维护性典型工具擅长发现容易漏检
端到端测试产品全流程(UI/API)慢(分钟级)Playwright, Selenium, httpx真实用户场景、跨系统集成边界条件、异常路径(case 太重,只覆盖主流程)
集成测试模块间交互、数据库、外部服务中(秒级)pytest + testcontainers, requests接口不匹配、数据流转错误、配置问题完整用户路径、UI 交互问题
单元测试单个函数/类快(毫秒级)pytest, unittest, Hypothesis逻辑错误、边界条件、异常处理模块间集成问题、真实环境差异

各层测什么:

单元测试 — 验证单个函数/类的逻辑正确性:

  1. 正常路径:给合法输入,返回预期结果
  2. 边界条件:空值、零值、极大极小、空列表、空字符串
  3. 异常处理:非法输入是否抛出正确的异常
  4. 分支覆盖:if/else 的每条路径都走到

集成测试 — 验证模块间交互、与外部服务的对接:

  1. API 接口:请求/响应格式、状态码、错误处理
  2. 数据库操作:CRUD 是否正确写入和读取、事务是否正常回滚
  3. 外部服务调用:第三方 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。


工具链速查表

前面的示例已经把主要工具带了出来。这里给一张汇总表,方便速查:

场景推荐工具一句话说明
单元测试pytestPython 测试的事实标准
Mock / Stubunittest.mock, pytest-mock隔离外部依赖
参数化测试@pytest.mark.parametrize一组逻辑,多组数据
属性测试Hypothesis自动生成随机输入,发现边界 case
形式化验证z3-solver用 SMT 求解器证明性质恒成立
异步测试pytest-asyncioasync/await 测试支持
HTTP Mockrespx, pytest-httpx模拟 HTTP 响应,不发真实请求
Web 框架测试FastAPI TestClient, Flask test_client不启动服务器,直接测接口
CLI 测试click.testing.CliRunner, typer.testing测命令行工具
数据库测试真实数据库 / IaC 临时环境能重置就用真的,比 mock 更可靠
数据工厂factory_boy构造复杂测试数据
性能基准pytest-benchmark, locust衡量和对比性能
快照测试syrupy输出对比上次快照,检测意外变化
端到端 / UIPlaywright (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 时代,不写测试的代码是不完整的代码。而写测试,从来没有像今天这么容易。

Footnotes

  1. Python 测试全景:单元测试、集成测试与端到端测试实战指南