014、测试之道:使用Pytest进行单元测试、集成测试与异步测试
014、测试之道:使用Pytest进行单元测试、集成测试与异步测试
一、从线上一个诡异bug说起
上周排查一个生产环境问题,用户上传文件偶尔会超时,日志里没有任何异常堆栈。最初怀疑是Nginx配置问题,折腾半天无果。后来在本地用curl循环测试了上百次,终于复现了一次——服务端日志显示请求进来了,但业务逻辑根本没执行。
问题出在依赖注入的一个异步函数里,某个条件分支下忘了写await。这种问题在开发时很难发现,因为大多数测试用例都走了正常流程。这件事让我重新审视了测试策略:光有单元测试不够,集成测试覆盖不全更危险,而异步代码的测试必须用对工具和方法。
今天我们就用Pytest这把瑞士军刀,把FastAPI的测试体系彻底讲透。
二、Pytest基础:别再用unittest那套了
先看一个典型错误示范:
# test_wrong.py
import unittest
from fastapi.testclient import TestClient
from main import app
class TestApp(unittest.TestCase):
def setUp(self):
self.client = TestClient(app)
def test_read_item(self):
response = self.client.get("/items/42")
self.assertEqual(response.status_code, 200) # 太啰嗦了
Pytest的写法清爽得多:
# test_right.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_read_item():
response = client.get("/items/42")
assert response.status_code == 200 # 直接用assert,多自然
assert response.json()["item_id"] == 42 # 链式断言
关键优势:
- 不用写类,函数就是测试用例
assert后面可以接任何表达式,失败时会自动输出详细信息- 夹具(fixture)系统比
setUp/tearDown灵活十倍
三、夹具(Fixture):测试的共享工具箱
夹具是Pytest的灵魂,很多人只用了皮毛。看个实际场景:
# conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from main import app, get_db
from database import Base
# 数据库夹具 - 每个测试用独立的数据库
@pytest.fixture(scope="function")
def test_db():
engine = create_engine("sqlite:///./test.db")
TestingSessionLocal = sessionmaker(bind=engine)
# 创建表
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
# 清理表,避免测试间相互影响
Base.metadata.drop_all(bind=engine)
# 客户端夹具 - 依赖数据库夹具
@pytest.fixture
def client(test_db):
def override_get_db():
try:
yield test_db
finally:
pass
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear() # 重要!一定要清理覆盖
使用起来极其顺手:
def test_create_item(client):
response = client.post("/items/", json={"name": "测试物品"})
assert response.status_code == 201
data = response.json()
assert data["name"] == "测试物品"
assert "id" in data # 验证生成了ID
经验之谈:
conftest.py里的夹具对整个目录生效- 作用域(scope)选
function最安全,module级可能引入测试污染 - 夹具可以嵌套,形成依赖链
- 记得清理
dependency_overrides,我在这栽过跟头
四、单元测试:专注单个组件
单元测试要快、要隔离。FastAPI的依赖注入系统让这变得简单:
# 测试纯函数
def test_password_hash():
from auth import hash_password
hashed = hash_password("mypassword")
assert hashed != "mypassword" # 确实加密了
assert len(hashed) == 64 # SHA256输出长度
# 测试依赖项
def test_get_current_user():
from auth import get_current_user
from unittest.mock import Mock
mock_request = Mock()
mock_request.headers = {"Authorization": "Bearer valid_token"}
# 模拟验证逻辑
user = get_current_user(mock_request, token="valid_token")
assert user.username == "testuser"
关键点:
- 用
unittest.mock模拟外部依赖 - 每个测试只关注一个函数或类
- 测试边界条件和异常分支
五、集成测试:验证组件协作
集成测试要模拟真实调用链,但不用启动完整服务:
def test_full_flow(client, test_db):
# 1. 创建用户
user_resp = client.post("/users/", json={
"username": "test",
"password": "secret"
})
user_id = user_resp.json()["id"]
# 2. 登录获取token
login_resp = client.post("/login", data={
"username": "test",
"password": "secret"
})
token = login_resp.json()["access_token"]
# 3. 用token创建物品
item_resp = client.post(
"/items/",
json={"title": "我的物品"},
headers={"Authorization": f"Bearer {token}"}
)
assert item_resp.status_code == 201
# 验证数据库确实写入了
from models import Item
db_item = test_db.query(Item).first()
assert db_item.title == "我的物品"
assert db_item.owner_id == user_id # 关键:验证关联关系
踩坑提醒:
- 测试顺序很重要,上面的测试依赖下面的夹具
- 用
test_db直接查库验证,比只检查API响应更可靠 - 集成测试可以暴露接口设计缺陷,比如缺少必要的关联查询
六、异步测试:FastAPI的核心难点
异步测试最容易出错,重点看三种写法:
import pytest
from httpx import AsyncClient
# 方法1:使用AsyncClient - 推荐
@pytest.mark.asyncio
async def test_async_endpoint():
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.get("/async-endpoint")
assert response.status_code == 200
# 方法2:测试异步依赖
@pytest.mark.asyncio
async def test_async_dependency():
from dependencies import async_redis_client
# 直接调用异步依赖函数
value = await async_redis_client.get("key")
assert value is None # 或具体的预期值
# 方法3:测试后台任务
import asyncio
from tasks import process_upload
@pytest.mark.asyncio
async def test_background_task():
# 模拟文件上传
mock_file = Mock()
mock_file.read.return_value = b"test content"
# 直接调用任务函数
result = await process_upload(mock_file)
assert result["status"] == "processed"
# 验证副作用,比如数据库写入
from models import TaskLog
log = test_db.query(TaskLog).first()
assert log.filename == "upload.txt"
特别注意事项:
- 一定要加
@pytest.mark.asyncio装饰器 TestClient不支持异步,必须用AsyncClient- 异步夹具要用
@pytest_asyncio.fixture - 测试
BackgroundTasks时,最好直接测试任务函数本身
七、测试覆盖率与实战技巧
光写测试不够,要知道覆盖了哪些代码:
# 安装覆盖率工具
pip install pytest-cov
# 运行测试并生成报告
pytest --cov=app --cov-report=html tests/
# 查看哪些行没覆盖到
pytest --cov=app --cov-report=term-missing tests/
我的经验规则:
- 控制器层(路由)重点测状态码和响应格式
- 业务逻辑层测所有分支,包括异常路径
- 数据访问层测CRUD和查询边界
- 工具函数要求100%覆盖率
一个实用技巧——用monkeypatch模拟环境变量:
def test_config_with_env(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///test.db")
from config import settings
assert settings.database_url == "sqlite:///test.db"
八、个人建议
测试不是写完就完事的,要让它成为开发流程的一部分。我在团队里推行这些实践:
- 本地提交前:跑一遍相关模块的测试,用
pytest -xvs tests/test_specific.py快速验证 - CI流水线:配置
pytest --cov --cov-fail-under=80,覆盖率不达标就失败 - 遇到bug时:先写一个复现bug的测试用例,修复后再确保它通过
- 异步代码:用
asyncio.run()在同步环境里调试,但测试一定要用异步方式
最后说个真事:我们有个服务曾经因为测试没覆盖timeout参数,上线后在高并发下频繁超时。后来补了个压力测试才发现问题。所以记住——测试不仅要覆盖“应该发生什么”,还要覆盖“可能发生什么”。
好的测试像安全网,让你敢在代码高空作业。开始可能觉得麻烦,等它救过你几次命,你就离不开了。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)