04 自动化测试
本章目标:用 pytest + TestClient 给博客 API 写自动化测试,包括「测试专用数据库」的标准配置。
1. 为什么要写测试
现在验证接口靠手点 /docs——每次改代码后把十几个接口全点一遍?不现实。结果就是「改了 A 坏了 B」却不知道。
自动化测试把「手动点一遍」变成一条命令:
pytest # 几秒钟跑完所有接口的所有场景
以后无论怎么重构,pytest 全绿就说明没改坏东西。这是敢于修改代码的底气。
2. 安装
pip install pytest httpx
(TestClient 基于 httpx;fastapi[standard] 通常已附带。)
3. TestClient 初体验
pytest 的约定:测试文件以 test_ 开头,测试函数以 test_ 开头,用 assert 断言。
项目根目录建 tests/ 目录(加空的 __init__.py),先写个最简单的 tests/test_smoke.py:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_read_root():
resp = client.get("/")
assert resp.status_code == 200
assert resp.json() == {"message": "博客 API 正在运行"}
运行 pytest -v,看到绿色的 PASSED 即成功。TestClient 不需要真的启动服务器——它在进程内直接调用你的 app,快且稳定。
4. 关键问题:测试不能污染真实数据库
上面的测试直接用了 blog.db。测试会创建、删除数据,绝不能和开发数据混在一起。标准解法:用依赖覆盖(dependency_overrides)把 get_db 换成指向测试数据库的版本。
创建 tests/conftest.py(pytest 会自动加载这个文件,里面的 fixture 全部测试可用):
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.main import app
# 测试用内存数据库:跑完即消失,天然隔离
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool, # 让所有连接共享同一个内存库
)
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
def override_get_db():
db = TestSessionLocal()
try:
yield db
finally:
db.close()
# 核心一招:把 app 里所有 Depends(get_db) 替换成测试版
app.dependency_overrides[get_db] = override_get_db
@pytest.fixture()
def client():
# 每个测试函数:建全新的表 → 提供 client → 测完删表
Base.metadata.create_all(engine)
with TestClient(app) as c:
yield c
Base.metadata.drop_all(engine)
两个关键点:
app.dependency_overrides[get_db] = override_get_db:依赖注入的又一价值——测试时可以整体替换任何依赖,业务代码一行不用改- fixture 里每个测试重建表:测试之间完全隔离,A 测试造的数据不会影响 B 测试
5. 测试用户接口
tests/test_users.py:
def test_create_user(client):
resp = client.post("/users", json={
"username": "xiaoming",
"email": "xm@qq.com",
"password": "123456",
})
assert resp.status_code == 201
data = resp.json()
assert data["username"] == "xiaoming"
assert data["id"] > 0
assert "password" not in data # 确保没泄露密码!
assert "hashed_password" not in data
def test_create_user_duplicate_username(client):
payload = {"username": "xiaoming", "email": "a@qq.com", "password": "123456"}
client.post("/users", json=payload)
payload["email"] = "b@qq.com" # 换邮箱,用户名重复
resp = client.post("/users", json=payload)
assert resp.status_code == 409
def test_create_user_invalid_password(client):
resp = client.post("/users", json={
"username": "xiaoming", "email": "xm@qq.com", "password": "123",
})
assert resp.status_code == 422 # 密码太短
def test_get_user_not_found(client):
assert client.get("/users/999").status_code == 404
注意测试覆盖的不只是「成功路径」,失败路径(409、422、404)同样重要——那正是最容易出 bug 的地方。
6. 测试需要登录的接口
登录流程也可以封装成 fixture(加到 conftest.py):
@pytest.fixture()
def auth_headers(client):
"""注册 + 登录,返回带 Token 的请求头"""
client.post("/users", json={
"username": "author", "email": "author@qq.com", "password": "123456",
})
resp = client.post("/login", data={ # 注意登录是表单:用 data= 不是 json=
"username": "author", "password": "123456",
})
token = resp.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
tests/test_posts.py:
def test_create_post_requires_login(client):
resp = client.post("/posts", json={"title": "t", "content": "c"})
assert resp.status_code == 401
def test_create_and_get_post(client, auth_headers):
resp = client.post("/posts", json={
"title": "第一篇", "content": "正文", "published": True,
"tags": ["python"],
}, headers=auth_headers)
assert resp.status_code == 201
post = resp.json()
assert post["author"]["username"] == "author"
assert post["tags"][0]["name"] == "python"
resp = client.get(f"/posts/{post['id']}")
assert resp.status_code == 200
def test_cannot_update_others_post(client, auth_headers):
# author 发一篇文章
post = client.post("/posts", json={"title": "t", "content": "c"},
headers=auth_headers).json()
# 另一个用户登录后尝试修改
client.post("/users", json={
"username": "hacker", "email": "h@qq.com", "password": "123456",
})
token = client.post("/login", data={
"username": "hacker", "password": "123456",
}).json()["access_token"]
resp = client.patch(f"/posts/{post['id']}", json={"title": "改掉"},
headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 403 # 权限检查生效
7. 运行与常用参数
pytest # 跑全部
pytest -v # 显示每个用例名
pytest tests/test_users.py # 只跑一个文件
pytest -k "duplicate" # 只跑名字含 duplicate 的用例
pytest -x # 遇到第一个失败就停
本章小结
TestClient(app)进程内调用接口,client.get/post(...)+assert就是测试app.dependency_overrides[get_db]替换数据库依赖 → 测试用独立内存库- conftest.py 放公共 fixture;每个测试重建表保证隔离
- 失败路径(401/403/404/409/422)和成功路径同样值得测
下一章:05-部署上线