Python 测试
测试是用代码验证代码。它不是为了“写给测试人员看”,而是为了让开发者在修改、重构、上线前知道核心行为有没有被破坏。
零基础阶段可以先理解成:你写一个函数,再写另一个函数验证它在不同输入下是否返回正确结果。进阶以后,测试要覆盖业务规则、异常分支、数据库访问、Web API、外部依赖 Mock、持续集成和回归场景。
学习目标
学完本页你应该能回答:
- 为什么要写测试,不写测试会怎样。
- 单元测试、集成测试、接口测试、端到端测试有什么区别。
- pytest 如何发现和执行测试。
assert、参数化、fixture、mock 分别解决什么问题。- 如何测试异常分支、边界条件和业务规则。
- 如何测试 FastAPI 接口和数据库逻辑。
- 覆盖率怎么看,为什么不能只追求覆盖率。
- CI 中如何让测试成为发布门禁。
为什么要写测试
没有测试时,项目会越来越不敢改:
- 修一个 bug,可能引入另一个 bug。
- 重构后不知道原功能有没有坏。
- 只能靠手动点击页面验证,效率低且容易漏。
- 团队协作时别人不敢改你的代码。
- 上线前没有自动化门禁,只能靠经验。
测试的价值不是保证永远没有 bug,而是让核心行为可验证、可回归、可交接。
测试金字塔
flowchart TD
A["测试体系"] --> B["单元测试:最多"]
A --> C["集成测试:适量"]
A --> D["接口测试:关键接口"]
A --> E["端到端测试:少量核心流程"]
B --> F["函数 类 Service 业务规则"]
C --> G["数据库 缓存 消息队列"]
D --> H["HTTP API 入参 出参 权限"]
E --> I["从用户操作到系统完成"]为什么单元测试最多?因为它快、稳定、定位清楚。端到端测试虽然接近真实用户,但慢、不稳定、失败后定位成本高,所以只覆盖最核心流程。
| 类型 | 测什么 | 优点 | 缺点 |
|---|---|---|---|
| 单元测试 | 函数、类、Service | 快、稳定、定位准 | 不能证明依赖真实可用 |
| 集成测试 | 数据库、缓存、外部组件 | 更接近真实环境 | 准备环境成本高 |
| 接口测试 | HTTP API | 验证路由、校验、响应 | 仍可能 Mock 部分依赖 |
| 端到端测试 | 完整用户流程 | 最贴近用户 | 慢、脆弱、维护成本高 |
pytest 如何运行
安装:
pip install pytestpytest 默认会寻找:
test_*.py文件。*_test.py文件。- 以
test_开头的函数。 - 以
Test开头的测试类。
最小示例:
calculator.py:
def add(a: int, b: int) -> int:
return a + b
def divide(a: int, b: int) -> float:
if b == 0:
raise ValueError("除数不能为 0")
return a / btest_calculator.py:
import pytest
from calculator import add, divide
def test_add():
assert add(1, 2) == 3
def test_divide():
assert divide(6, 2) == 3
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(1, 0)运行:
pytest运行更详细输出:
pytest -v只运行某个文件:
pytest tests/test_calculator.py只运行某个测试:
pytest tests/test_calculator.py::test_addassert 怎么写
assert 的意思是“我期望这个条件为真”。
推荐:
assert result == expected
assert len(users) == 3
assert "admin" in roles
assert user is not None不推荐:
assert result因为失败时不知道期望值是什么。好的测试应该让失败信息尽量清楚。
异常测试:
import pytest
def test_invalid_age():
with pytest.raises(ValueError, match="年龄不能小于 0"):
create_user(name="Tom", age=-1)这里不仅验证抛异常,还验证异常信息包含预期内容。
参数化测试
同一个函数有多组输入时,不要复制很多测试函数。
import pytest
def is_even(value: int) -> bool:
return value % 2 == 0
@pytest.mark.parametrize(
"value, expected",
[
(0, True),
(1, False),
(2, True),
(-2, True),
],
)
def test_is_even(value, expected):
assert is_even(value) == expected参数化适合:
- 边界值测试。
- 多种状态转换。
- 多种错误输入。
- 金额、折扣、权限等规则矩阵。
fixture 原理和用法
fixture 用来准备测试依赖。pytest 在测试函数需要某个参数时,会找同名 fixture 执行,并把返回值注入进去。
flowchart TD
A["pytest 发现 test_user_name 需要 user 参数"] --> B["查找 user fixture"]
B --> C["执行 fixture 准备数据"]
C --> D["把返回值传给测试函数"]
D --> E["执行测试断言"]示例:
import pytest
@pytest.fixture
def user():
return {"id": 1, "name": "张三", "active": True}
def test_user_name(user):
assert user["name"] == "张三"带清理逻辑的 fixture:
import tempfile
from pathlib import Path
import pytest
@pytest.fixture
def temp_file():
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "demo.txt"
path.write_text("hello", encoding="utf-8")
yield path
def test_read_temp_file(temp_file):
assert temp_file.read_text(encoding="utf-8") == "hello"yield 前是准备,yield 后是清理。即使测试失败,pytest 也会执行清理逻辑。
fixture 作用域
fixture 可以设置作用域:
| scope | 生命周期 | 适合 |
|---|---|---|
function | 每个测试函数一次 | 默认,隔离最好 |
class | 每个测试类一次 | 类内共享对象 |
module | 每个测试文件一次 | 初始化较重资源 |
session | 整个测试会话一次 | 测试数据库、容器资源 |
示例:
@pytest.fixture(scope="session")
def app_config():
return {"env": "test"}作用域越大,性能可能越好,但隔离越弱。初学者优先用默认 function,避免测试之间互相污染。
mock 外部依赖
单元测试不要真的发短信、扣款、调用第三方接口、请求大模型。应该 Mock 外部依赖。
业务代码:
def register_user(user, email_client):
if not user["email"]:
raise ValueError("邮箱不能为空")
email_client.send(user["email"], "欢迎注册")
return True测试:
from unittest.mock import Mock
def test_register_user_send_email():
email_client = Mock()
user = {"email": "test@example.com"}
result = register_user(user, email_client)
assert result is True
email_client.send.assert_called_once_with("test@example.com", "欢迎注册")Mock 的边界:Mock 是为了隔离外部依赖,不是为了把所有真实逻辑都替换掉。如果一个测试全是 Mock,可能只能证明“Mock 被调用”,证明不了业务真的对。
monkeypatch 修改环境
测试配置读取时,可以用 monkeypatch 临时修改环境变量。
import os
def get_database_url():
return os.environ["DATABASE_URL"]
def test_get_database_url(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///test.db")
assert get_database_url() == "sqlite:///test.db"测试结束后,pytest 会自动恢复环境,避免污染其他测试。
业务规则测试 Demo
以资产导入为例:来源系统只允许 HIS、LIS、PACS、EMR。
业务代码:
class AssetService:
allowed_sources = {"HIS", "LIS", "PACS", "EMR"}
def create_asset(self, name: str, source_system: str) -> dict:
if not name.strip():
raise ValueError("资产名称不能为空")
if source_system not in self.allowed_sources:
raise ValueError("来源系统不支持")
return {"name": name.strip(), "source_system": source_system}测试:
import pytest
def test_create_asset_success():
service = AssetService()
asset = service.create_asset(" 检验报告 ", "LIS")
assert asset["name"] == "检验报告"
assert asset["source_system"] == "LIS"
@pytest.mark.parametrize("source", ["UNKNOWN", "", "CRM"])
def test_create_asset_invalid_source(source):
service = AssetService()
with pytest.raises(ValueError, match="来源系统不支持"):
service.create_asset("检验报告", source)
def test_create_asset_empty_name():
service = AssetService()
with pytest.raises(ValueError, match="资产名称不能为空"):
service.create_asset(" ", "LIS")这个测试覆盖了成功、非法来源、空名称三个关键分支。
FastAPI 接口测试
接口测试验证路由、参数校验、状态码、响应结构。
main.py:
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class AssetCreate(BaseModel):
name: str = Field(min_length=1)
source_system: str
@app.post("/assets")
def create_asset(request: AssetCreate):
return {"id": 1, "name": request.name, "source_system": request.source_system}test_api.py:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create_asset_success():
response = client.post(
"/assets",
json={"name": "检验报告", "source_system": "LIS"},
)
assert response.status_code == 200
assert response.json()["name"] == "检验报告"
def test_create_asset_validation_error():
response = client.post("/assets", json={"name": "", "source_system": "LIS"})
assert response.status_code == 422接口测试不要只测成功,也要测参数缺失、类型错误、权限失败、业务异常。
数据库测试
数据库测试要解决两个问题:
- 数据从哪里来。
- 测试结束后怎么清理。
SQLite 示例:
import sqlite3
import pytest
@pytest.fixture
def db_conn():
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
yield conn
conn.close()
def test_insert_user(db_conn):
db_conn.execute("INSERT INTO users (name) VALUES (?)", ("张三",))
db_conn.commit()
row = db_conn.execute("SELECT name FROM users WHERE id = 1").fetchone()
assert row[0] == "张三"生产中如果使用 MySQL/PostgreSQL,集成测试可以使用测试库或容器。原则是:
- 不连接生产数据库。
- 测试数据可重复创建。
- 测试后清理或事务回滚。
- 测试之间相互隔离。
测试数据设计
测试数据不要只写“正常值”。要覆盖:
| 类型 | 示例 |
|---|---|
| 正常值 | 合法资产名称、合法来源系统 |
| 边界值 | 空字符串、最大长度、0、负数 |
| 异常值 | 不存在的 ID、非法状态 |
| 重复值 | 重复导入同一资产 |
| 权限场景 | 无 Token、普通用户、管理员 |
| 并发相关 | 重复提交、幂等键冲突 |
常见错误是只测一个成功场景,然后认为功能没问题。真正容易出 bug 的往往是边界和异常分支。
覆盖率怎么看
安装:
pip install pytest-cov运行:
pytest --cov=app --cov-report=term-missing覆盖率告诉你哪些代码被测试执行过,但不代表断言有效。
坏测试:
def test_create_user():
create_user("Tom")它执行了代码,但没有断言结果。
好测试:
def test_create_user():
user = create_user("Tom")
assert user.name == "Tom"
assert user.status == "ACTIVE"覆盖率是参考指标,不是最终目标。商业项目更关注关键业务规则是否被测到。
CI 中运行测试
CI 是持续集成。每次提交代码或合并分支前自动运行测试,可以阻止明显错误进入主分支。
简单流程:
flowchart TD
A["提交代码"] --> B["安装依赖"]
B --> C["运行格式检查"]
C --> D["运行单元测试"]
D --> E["运行接口或集成测试"]
E --> F{"是否通过"}
F -- "通过" --> G["允许合并或部署"]
F -- "失败" --> H["阻止合并并修复"]GitHub Actions 示例:
name: python-test
on:
pull_request:
push:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -r requirements.txt
- run: pytest --cov=app如果没有 CI,测试很容易变成“我本地记得跑一下”,最后总会有人忘。
测试失败怎么定位
flowchart TD
A["测试失败"] --> B["看失败用例名称"]
B --> C["看断言差异"]
C --> D["确认输入数据"]
D --> E{"是代码错还是测试错"}
E -- "代码错" --> F["修业务代码"]
E -- "测试错" --> G["修测试期望或测试数据"]
F --> H["重新运行相关测试"]
G --> H
H --> I["补充回归用例"]不要一看到测试失败就改测试让它通过。测试失败有两种可能:
- 业务代码真的错了。
- 需求变了,测试期望需要更新。
如果是需求变了,要先确认新规则,再更新测试。不能为了通过而删除测试。
商业场景:资产导入测试策略
医疗数据资产导入功能至少要测:
| 场景 | 测试类型 | 重点 |
|---|---|---|
| 来源系统校验 | 单元测试 | 非法来源要失败 |
| 字段类型转换 | 单元测试 | 日期、数字、枚举边界 |
| CSV 解析 | 单元测试 | 空行、缺列、中文编码 |
| 写入资产和字段 | 集成测试 | 事务一致性 |
| 提交导入任务接口 | 接口测试 | 参数校验、状态码 |
| 重复导入 | 单元/集成 | 幂等或重复提示 |
| 外部 AI 分析失败 | Mock 测试 | 不影响主流程或进入补偿 |
测试不是“所有东西都测一遍”,而是围绕最容易出事故的业务规则建立保护网。
常见坑
| 问题 | 后果 | 正确做法 |
|---|---|---|
| 只测成功场景 | 异常分支上线才暴露 | 成功、失败、边界都测 |
| 测试依赖执行顺序 | 单独运行会失败 | 测试之间相互独立 |
| 测试连接生产库 | 可能污染真实数据 | 使用测试库或内存库 |
| Mock 过度 | 测试失真 | 只 Mock 外部依赖 |
| 没有断言 | 覆盖率虚高 | 明确断言结果和副作用 |
| 随机数据不固定 | 测试偶发失败 | 固定随机种子或明确数据 |
| 为了通过删除测试 | 回归风险变大 | 找原因,修代码或确认需求 |
| CI 不跑测试 | 本地遗漏 | 合并前自动跑 |
面试标准回答
Python 测试怎么分层?
通常按单元测试、集成测试、接口测试、端到端测试分层。单元测试最多,覆盖函数、类和 Service 业务规则;集成测试验证数据库、缓存等组件;接口测试验证 HTTP 入参出参和状态码;端到端测试只覆盖少量核心流程。
pytest 的 fixture 是什么?
fixture 用来准备测试依赖,例如测试数据、临时文件、数据库连接、Mock 对象。pytest 根据测试函数参数名自动执行同名 fixture,并把结果注入测试函数。使用 yield 可以在测试后执行清理逻辑。
Mock 应该怎么用?
Mock 用于隔离外部依赖,例如短信、邮件、支付、第三方接口、AI 模型调用。单元测试应该测试自己的业务规则,不依赖真实外部服务。但不能过度 Mock,否则测试只能证明 Mock 被调用,证明不了业务逻辑正确。
覆盖率高就代表测试好吗?
不一定。覆盖率只说明代码被执行过,不说明断言有效。好的测试要覆盖关键业务规则、边界条件和异常分支,并且有明确断言。商业项目更关注核心风险是否被测试保护。
关联知识点
小结
测试的本质是把经验变成自动化验证。零基础先掌握 pytest、assert、参数化、fixture 和 mock;进阶要会测试业务规则、接口、数据库和异常分支;商业项目要把测试放进 CI,让它成为合并和发布前的门禁。测试写得好,代码才敢改、敢重构、敢上线。
