﻿---
title: "Python 全场景测试指南"
description: "AI 写代码的时代，测试不是可选项。从 WHY 到 HOW 到 WHAT，一份面向 Python 开发者的全场景测试实战手册。"
pubDate: 2025-02-21
tags: ["Python", "测试", "pytest", "AI"]
clc: "TP311.5"
hero: "./hero.svg"
refs:
  - id: "1"
    title: "Python 测试全景：单元测试、集成测试与端到端测试实战指南"
    url: "https://blog.csdn.net/windowshht/article/details/149383813"
---

> 用 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]：

<svg viewBox="0 0 600 260" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="测试金字塔：底层单元测试（大量）、中层集成测试（适量）、顶层端到端测试（少量）" style={{display:"block",margin:"1.5em auto",maxWidth:"600px",width:"100%"}}>
  <style>{`
    .pyramid-fill { fill: var(--c-code-bg, #f5f5f5); }
    .pyramid-stroke { stroke: var(--c-border, #e5e5e5); fill: none; stroke-width: 1.5; }
    .pyramid-text { fill: var(--c-text, #1a1a1a); font-family: var(--font-sans, sans-serif); text-anchor: middle; }
    .pyramid-label { fill: var(--c-text-muted, #666); font-family: var(--font-sans, sans-serif); font-size: 13px; }
  `}</style>
  <polygon points="210,20 150,100 270,100" className="pyramid-fill" />
  <polygon points="150,100 90,180 330,180 270,100" className="pyramid-fill" />
  <polygon points="90,180 30,260 390,260 330,180" className="pyramid-fill" />
  <polygon points="210,20 30,260 390,260" className="pyramid-stroke" />
  <line x1="150" y1="100" x2="270" y2="100" className="pyramid-stroke" />
  <line x1="90" y1="180" x2="330" y2="180" className="pyramid-stroke" />
  <text x="210" y="68" className="pyramid-text" style={{fontSize:"15px",fontWeight:600}}>E2E</text>
  <text x="210" y="148" className="pyramid-text" style={{fontSize:"15px",fontWeight:600}}>集成测试</text>
  <text x="210" y="228" className="pyramid-text" style={{fontSize:"15px",fontWeight:600}}>单元测试</text>
  <text x="282" y="68" className="pyramid-label">← 少量，覆盖核心路径</text>
  <text x="342" y="148" className="pyramid-label">← 适量，验证模块间协作</text>
  <text x="402" y="228" className="pyramid-label">← 大量，快速验证逻辑</text>
</svg>

每层的定位不同，成本和收益也不同：

| 测试层级 | 涉及范围 | 运行速度 | 易写易维护性 | 典型工具 | 擅长发现 | 容易漏检 |
|---------|---------|---------|------------|---------|---------|---------|
| 端到端测试 | 产品全流程（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 生成的代码可能在任何层级出问题——函数逻辑可能错，模块拼接可能错，整体流程也可能错。

---

### 逻辑验证方法

验证单个函数/类的输入→输出是否正确。这是金字塔的底座，数量最多、跑得最快。

假设我们有一个折扣计算函数：

```python
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**

最直接的方式，一个测试验证一个预期：

```python
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 个函数，用参数化：

```python
@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 帮你自动探索。你不指定具体输入，而是描述输入的"性质"，让框架随机生成几百组数据来跑：

```python
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 尝试找反例——如果找不到，就说明性质对**所有**合法输入都成立：

```python
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**

```python
# 安装：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**

不需要启动服务器，直接测接口：

```python
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` 模拟响应：

```python
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` 隔离：

```python
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 共享机制。放在哪个目录，就对那个目录下所有测试生效。

```python
# 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
```

#### 测试命名规范

```python
# 好：清楚说明 测什么_在什么条件下_预期什么结果
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

```toml
# 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` 时自动输出覆盖率报告：

```bash
$ 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 示例

```yaml
# .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 完整测试配置参考

```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 时代，不写测试的代码是不完整的代码。而写测试，从来没有像今天这么容易。**

---

## 参考资料
