FastAPI 系列 · 第 10 篇:测试------从单元到集成
🎯 适合人群 :熟悉 Java Spring Boot 测试体系(JUnit5 + MockMvc + @MockBean),希望掌握 FastAPI 完整测试方案的工程师
⏱️ 阅读时间 :约 30 分钟
💬 一句话定位:用 pytest + httpx + dependency_overrides 构建完整的 FastAPI 测试体系,从 HTTP 接口测试到 Celery 任务单元测试,覆盖率报告到 CI/CD 流水线,一文打通。
一、测试工具栈概览
Spring Boot 的测试体系以 JUnit5 + MockMvc + @MockBean 为核心,FastAPI 有一套对应的工具链,但更轻量、更 Pythonic。
| Spring Boot | FastAPI | 用途 |
|---|---|---|
| JUnit5 | pytest |
测试框架 |
| MockMvc(同步) | TestClient(同步) |
HTTP 接口测试 |
| WebTestClient(响应式) | AsyncClient(异步) |
异步接口测试 |
@MockBean |
dependency_overrides |
依赖替换/Mock |
| H2 内存数据库 | SQLite in-memory / testcontainers | 测试数据库 |
@Transactional(测试回滚) |
Fixture 事务回滚 | 测试隔离 |
| Jacoco | pytest-cov |
覆盖率报告 |
| Spring Boot Test | pytest-asyncio |
异步测试支持 |
安装依赖:
bash
pip install pytest pytest-asyncio httpx pytest-cov anyio
# 如果要用 factory-boy 生成测试数据
pip install factory-boy
pyproject.toml 配置:
toml
[tool.pytest.ini_options]
asyncio_mode = "auto" # 所有 async 测试自动支持,无需手动加 @mark
testpaths = ["tests"]
addopts = "-v --tb=short"
[tool.coverage.run]
source = ["app"]
omit = ["*/migrations/*", "*/tests/*"]
[tool.coverage.report]
show_missing = true
skip_covered = false
fail_under = 80 # 覆盖率低于 80% 则 CI 失败
二、TestClient vs AsyncClient
FastAPI 提供两种客户端,选型原则:能用同步就用同步,异步路由需用 AsyncClient。
2.1 TestClient(同步,适合大多数场景)
TestClient 基于 httpx,内部通过 ASGI transport 直接调用应用,不需要启动真实服务器。对标 Spring MockMvc。
python
# tests/test_sync_example.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
2.2 AsyncClient(异步,适合 async 路由 + 复杂 fixture)
python
# tests/test_async_example.py
import pytest
import httpx
from app.main import app
@pytest.mark.asyncio
async def test_health_async():
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://test"
) as client:
response = await client.get("/health")
assert response.status_code == 200
💡 推荐方案 :用
AsyncClient统一写异步 fixture,这样conftest.py中只需维护一套客户端 fixture,测试函数里直接await client.get(...)即可。
三、conftest.py:测试夹具中枢
conftest.py 是 pytest 的全局配置文件,相当于 Spring Boot Test 的 @TestConfiguration。以下是 shop-api 完整版:
python
# tests/conftest.py
import pytest
import pytest_asyncio
from typing import AsyncGenerator
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
async_sessionmaker,
)
from sqlalchemy.pool import StaticPool
from app.main import app
from app.database import Base, get_db
from app.auth.security import create_access_token
from app.models.user import User
# -------------------------------------------------------
# 1. 测试数据库(SQLite in-memory)
# 对标 Spring Boot Test + H2 内存数据库
# -------------------------------------------------------
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest_asyncio.fixture(scope="function")
async def test_engine():
"""每个测试函数创建独立的内存数据库引擎"""
engine = create_async_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool, # 内存 DB 必须使用 StaticPool 共享同一连接
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) # 建表
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) # 清理
await engine.dispose()
@pytest_asyncio.fixture(scope="function")
async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
"""提供一个与测试事务绑定的 Session,测试结束自动回滚"""
TestSessionLocal = async_sessionmaker(
bind=test_engine,
expire_on_commit=False,
class_=AsyncSession,
)
async with TestSessionLocal() as session:
async with session.begin(): # 开启事务
yield session
await session.rollback() # 测试结束回滚,保持隔离
# -------------------------------------------------------
# 2. 覆盖依赖(dependency_overrides)
# 对标 Spring @MockBean
# -------------------------------------------------------
@pytest_asyncio.fixture(scope="function")
async def async_client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""注入测试 DB Session,覆盖真实数据库依赖"""
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
yield client
app.dependency_overrides.clear() # 测试结束后清除覆盖,防止污染其他测试
# -------------------------------------------------------
# 3. 认证 Token Fixture
# -------------------------------------------------------
@pytest.fixture
def admin_token() -> str:
"""生成管理员测试 Token"""
return create_access_token(
data={"sub": "admin@test.com", "role": "admin"},
expires_delta_minutes=30,
)
@pytest.fixture
def user_token() -> str:
"""生成普通用户测试 Token"""
return create_access_token(
data={"sub": "user@test.com", "role": "user"},
expires_delta_minutes=30,
)
@pytest.fixture
def auth_headers(user_token: str) -> dict:
"""普通用户认证 Header,直接注入测试函数"""
return {"Authorization": f"Bearer {user_token}"}
@pytest.fixture
def admin_headers(admin_token: str) -> dict:
"""管理员认证 Header"""
return {"Authorization": f"Bearer {admin_token}"}
四、dependency_overrides:FastAPI 的 @MockBean
dependency_overrides 是 FastAPI 内置机制,允许在测试时用任意函数替换依赖,不需要任何 Mock 库。
4.1 替换数据库依赖
在 conftest.py 的 async_client fixture 中已经演示了覆盖 get_db,核心是:
python
# ❌ 不要直接操作真实数据库
# ✅ 通过 dependency_overrides 注入测试 session
app.dependency_overrides[get_db] = override_get_db
4.2 替换当前用户依赖
python
# tests/conftest.py(补充)
from app.auth.dependencies import get_current_user
from app.models.user import User
@pytest_asyncio.fixture
async def authenticated_client(
db_session: AsyncSession,
async_client: AsyncClient,
) -> AsyncClient:
"""返回已登录状态的客户端,跳过 JWT 验证"""
mock_user = User(
id=1,
email="test@example.com",
role="user",
is_active=True,
)
async def override_get_current_user():
return mock_user
app.dependency_overrides[get_current_user] = override_get_current_user
yield async_client
# async_client fixture 已负责清理 dependency_overrides
4.3 替换外部服务依赖
python
# 假设有发邮件服务依赖
from app.services.email_service import EmailService, get_email_service
class MockEmailService:
async def send(self, to: str, subject: str, body: str) -> bool:
# 不真实发送,记录调用即可
self.sent_emails = getattr(self, "sent_emails", [])
self.sent_emails.append({"to": to, "subject": subject})
return True
@pytest_asyncio.fixture
async def mock_email_service() -> MockEmailService:
mock = MockEmailService()
app.dependency_overrides[get_email_service] = lambda: mock
yield mock
app.dependency_overrides.pop(get_email_service, None)
五、商品 CRUD 完整测试套件
python
# tests/test_products.py
import pytest
from httpx import AsyncClient
# -------------------------------------------------------
# 5.1 创建商品
# -------------------------------------------------------
async def test_create_product(async_client: AsyncClient, admin_headers: dict):
payload = {
"name": "iPhone 15",
"description": "Apple flagship phone",
"price": 5999.00,
"stock": 100,
"category": "electronics",
}
response = await async_client.post(
"/api/v1/products",
json=payload,
headers=admin_headers,
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "iPhone 15"
assert data["price"] == 5999.00
assert "id" in data
# -------------------------------------------------------
# 5.2 查询商品列表(分页)
# -------------------------------------------------------
async def test_list_products(async_client: AsyncClient):
# 先创建 3 个商品
for i in range(3):
await async_client.post(
"/api/v1/products",
json={"name": f"Product {i}", "price": float(i * 100), "stock": 10},
headers={"Authorization": "Bearer admin-token"}, # 此处应使用 fixture
)
response = await async_client.get("/api/v1/products?page=1&size=2")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
assert len(data["items"]) <= 2
# -------------------------------------------------------
# 5.3 查询单个商品
# -------------------------------------------------------
async def test_get_product_by_id(async_client: AsyncClient, admin_headers: dict):
# 创建
create_resp = await async_client.post(
"/api/v1/products",
json={"name": "Test Product", "price": 99.9, "stock": 5},
headers=admin_headers,
)
product_id = create_resp.json()["id"]
# 查询
response = await async_client.get(f"/api/v1/products/{product_id}")
assert response.status_code == 200
assert response.json()["id"] == product_id
async def test_get_product_not_found(async_client: AsyncClient):
response = await async_client.get("/api/v1/products/99999")
assert response.status_code == 404
assert response.json()["code"] == "NOT_FOUND" # 与第 09 篇统一错误响应对齐
# -------------------------------------------------------
# 5.4 更新商品
# -------------------------------------------------------
async def test_update_product(async_client: AsyncClient, admin_headers: dict):
create_resp = await async_client.post(
"/api/v1/products",
json={"name": "Old Name", "price": 10.0, "stock": 1},
headers=admin_headers,
)
product_id = create_resp.json()["id"]
update_resp = await async_client.put(
f"/api/v1/products/{product_id}",
json={"name": "New Name", "price": 20.0, "stock": 1},
headers=admin_headers,
)
assert update_resp.status_code == 200
assert update_resp.json()["name"] == "New Name"
assert update_resp.json()["price"] == 20.0
# -------------------------------------------------------
# 5.5 删除商品
# -------------------------------------------------------
async def test_delete_product(async_client: AsyncClient, admin_headers: dict):
create_resp = await async_client.post(
"/api/v1/products",
json={"name": "To Delete", "price": 1.0, "stock": 1},
headers=admin_headers,
)
product_id = create_resp.json()["id"]
delete_resp = await async_client.delete(
f"/api/v1/products/{product_id}",
headers=admin_headers,
)
assert delete_resp.status_code == 204
# 确认已删除
get_resp = await async_client.get(f"/api/v1/products/{product_id}")
assert get_resp.status_code == 404
# -------------------------------------------------------
# 5.6 权限控制测试:非管理员不能创建商品
# -------------------------------------------------------
async def test_create_product_forbidden(async_client: AsyncClient, auth_headers: dict):
response = await async_client.post(
"/api/v1/products",
json={"name": "Unauthorized", "price": 1.0, "stock": 1},
headers=auth_headers, # 普通用户
)
assert response.status_code == 403
六、认证接口完整测试
python
# tests/test_auth.py
import pytest
from httpx import AsyncClient
async def test_register_user(async_client: AsyncClient):
"""用户注册流程"""
response = await async_client.post(
"/api/v1/auth/register",
json={
"email": "newuser@example.com",
"password": "SecurePass123!",
"name": "Test User",
},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "newuser@example.com"
assert "password" not in data # 密码不应出现在响应中
assert "hashed_password" not in data # 哈希也不应暴露
async def test_register_duplicate_email(async_client: AsyncClient):
"""重复注册同一邮箱应返回 409"""
payload = {"email": "dup@example.com", "password": "Pass123!", "name": "Dup"}
await async_client.post("/api/v1/auth/register", json=payload)
response = await async_client.post("/api/v1/auth/register", json=payload)
assert response.status_code == 409
async def test_login_success(async_client: AsyncClient):
"""注册后登录,获取 JWT Token"""
# 先注册
await async_client.post(
"/api/v1/auth/register",
json={"email": "login@example.com", "password": "Pass123!", "name": "Login"},
)
# 登录(OAuth2 PasswordRequestForm 格式)
response = await async_client.post(
"/api/v1/auth/token",
data={ # 注意:form-data,不是 JSON
"username": "login@example.com",
"password": "Pass123!",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password(async_client: AsyncClient):
"""密码错误应返回 401"""
await async_client.post(
"/api/v1/auth/register",
json={"email": "wrong@example.com", "password": "CorrectPass!", "name": "W"},
)
response = await async_client.post(
"/api/v1/auth/token",
data={"username": "wrong@example.com", "password": "WrongPass!"},
)
assert response.status_code == 401
async def test_protected_endpoint_without_token(async_client: AsyncClient):
"""未携带 Token 访问受保护接口应返回 401"""
response = await async_client.get("/api/v1/users/me")
assert response.status_code == 401
async def test_protected_endpoint_with_valid_token(async_client: AsyncClient):
"""完整流程:注册 → 登录 → 访问受保护接口"""
# 注册
await async_client.post(
"/api/v1/auth/register",
json={"email": "full@example.com", "password": "Pass123!", "name": "Full"},
)
# 登录
token_resp = await async_client.post(
"/api/v1/auth/token",
data={"username": "full@example.com", "password": "Pass123!"},
)
token = token_resp.json()["access_token"]
# 访问受保护接口
me_resp = await async_client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
)
assert me_resp.status_code == 200
assert me_resp.json()["email"] == "full@example.com"
七、Celery 任务单元测试
Celery 提供了 CELERY_TASK_ALWAYS_EAGER 配置,使任务在调用时同步立即执行 ,而不发送到 Broker,这是单元测试的标准做法。对标 Spring Batch 的 JobLauncherTestUtils。
python
# tests/test_tasks.py
import pytest
from unittest.mock import patch, MagicMock
from app.tasks.email_tasks import send_order_confirmation_email
from app.tasks.inventory_tasks import sync_inventory_daily
# -------------------------------------------------------
# 7.1 配置 Celery 同步模式
# -------------------------------------------------------
@pytest.fixture(autouse=True)
def celery_eager_mode(celery_app):
"""让所有 Celery 任务在测试中同步执行"""
celery_app.conf.update(
task_always_eager=True, # 同步执行,不走 Broker
task_eager_propagates=True, # 任务异常直接抛出
)
# -------------------------------------------------------
# 7.2 邮件任务测试
# -------------------------------------------------------
def test_send_order_confirmation_email():
"""测试邮件任务正常执行,Mock 真实发送"""
with patch("app.tasks.email_tasks.smtp_client") as mock_smtp:
mock_smtp.send_message.return_value = True
result = send_order_confirmation_email.delay(
order_id=1001,
user_email="user@example.com",
total_amount=299.00,
)
assert result.successful()
mock_smtp.send_message.assert_called_once()
# 验证邮件内容
call_args = mock_smtp.send_message.call_args
assert "1001" in str(call_args)
assert "user@example.com" in str(call_args)
def test_send_email_retry_on_failure():
"""测试邮件任务失败时自动重试逻辑"""
with patch("app.tasks.email_tasks.smtp_client") as mock_smtp:
# 前两次失败,第三次成功
mock_smtp.send_message.side_effect = [
Exception("SMTP timeout"),
Exception("SMTP timeout"),
True,
]
result = send_order_confirmation_email.apply(
args=[1002, "retry@example.com", 100.0]
)
# max_retries=3,第三次应成功
assert result.successful()
assert mock_smtp.send_message.call_count == 3
# -------------------------------------------------------
# 7.3 幂等性测试
# -------------------------------------------------------
def test_task_idempotency():
"""相同 order_id 的邮件任务只执行一次"""
with patch("app.tasks.email_tasks.smtp_client") as mock_smtp:
with patch("app.tasks.email_tasks.redis_client") as mock_redis:
mock_smtp.send_message.return_value = True
mock_redis.exists.return_value = 0 # 第一次:未处理
mock_redis.set.return_value = True
# 第一次调用
send_order_confirmation_email.delay(1003, "test@example.com", 50.0)
assert mock_smtp.send_message.call_count == 1
# 模拟第二次调用(Redis 已记录)
mock_redis.exists.return_value = 1 # 已处理
send_order_confirmation_email.delay(1003, "test@example.com", 50.0)
# 任务应检查 Redis,发现已处理则跳过
assert mock_smtp.send_message.call_count == 1 # 未增加
# -------------------------------------------------------
# 7.4 异步任务状态查询测试
# -------------------------------------------------------
async def test_task_status_endpoint(async_client, admin_headers):
"""测试 /tasks/{task_id}/status 端点"""
with patch("app.tasks.email_tasks.smtp_client"):
result = send_order_confirmation_email.delay(1004, "s@e.com", 10.0)
task_id = result.id
response = await async_client.get(
f"/api/v1/tasks/{task_id}/status",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["task_id"] == task_id
assert data["status"] in ["SUCCESS", "FAILURE", "PENDING"]
八、测试数据库策略选型
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| SQLite in-memory | 极快、零配置、无外部依赖 | 部分 MySQL 语法不兼容(如 JSON 函数) | 单元测试、CI 快速验证 |
| 独立测试 MySQL(Docker) | 与生产一致、支持全部 SQL 特性 | 启动慢、需要 Docker | 集成测试、关键业务逻辑 |
| testcontainers-python | 自动管理容器生命周期 | 依赖 Docker daemon | CI 环境集成测试 |
| 生产 DB 独立 schema | 无迁移问题 | 数据污染风险高 | ❌ 强烈不推荐 |
8.1 使用 testcontainers 启动真实 MySQL
python
# tests/conftest_mysql.py(按需替换 conftest.py 中的数据库部分)
import pytest
from testcontainers.mysql import MySqlContainer
from sqlalchemy.ext.asyncio import create_async_engine
@pytest.fixture(scope="session")
def mysql_container():
"""整个测试会话复用一个 MySQL 容器"""
with MySqlContainer("mysql:8.0") as mysql:
yield mysql
@pytest_asyncio.fixture(scope="session")
async def test_engine_mysql(mysql_container):
url = mysql_container.get_connection_url().replace(
"mysql+mysqlconnector", "mysql+aiomysql"
)
engine = create_async_engine(url, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
💡 建议:CI 流水线跑两套测试:
pytest tests/unit/:用 SQLite in-memory,速度极快(<30s)pytest tests/integration/:用 MySQL Docker 容器,保证兼容性(<3min)
九、覆盖率配置与实践
9.1 生成覆盖率报告
bash
# 运行测试并生成覆盖率
pytest --cov=app --cov-report=html --cov-report=term-missing
# 只看覆盖率摘要
pytest --cov=app --cov-report=term
# 输出示例
# Name Stmts Miss Cover
# -------------------------------------------------
# app/routers/products.py 45 3 93%
# app/services/product_service.py 62 8 87%
# app/auth/security.py 38 2 95%
# -------------------------------------------------
# TOTAL 285 18 94%
9.2 100% 覆盖 vs 80% 覆盖的取舍
🤔 我的理解 :追求 100% 覆盖率往往是伪命题。真正重要的是关键路径的覆盖,而不是数字。
- 认证逻辑、支付流程、数据一致性代码:务必 ≥ 95%
- 工具函数、序列化代码:80% 以上即可
- 第三方库的 Wrapper、启动代码:可豁免
建议用
# pragma: no cover标记无需覆盖的代码,保持报告清晰。
python
# 豁免示例
def main(): # pragma: no cover
"""仅本地开发使用的启动入口,不纳入覆盖率"""
import uvicorn
uvicorn.run("app.main:app", reload=True)
十、GitHub Actions CI 流水线
yaml
# .github/workflows/test.yml
name: FastAPI Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
# 对标 Spring Boot Test 的 @SpringBootTest + Testcontainers
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: testpass
MYSQL_DATABASE: shop_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost"
--health-interval=10s
--health-timeout=5s
--health-retries=5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run Alembic migrations
env:
DATABASE_URL: mysql+aiomysql://root:testpass@localhost:3306/shop_test
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key-for-ci
run: alembic upgrade head
- name: Run unit tests(SQLite)
run: |
pytest tests/unit/ \
--cov=app \
--cov-report=xml \
-v
- name: Run integration tests(MySQL)
env:
DATABASE_URL: mysql+aiomysql://root:testpass@localhost:3306/shop_test
REDIS_URL: redis://localhost:6379/0
SECRET_KEY: test-secret-key-for-ci
TEST_ENV: ci
run: |
pytest tests/integration/ \
--cov=app \
--cov-append \
--cov-report=xml \
-v
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
fail_ci_if_error: true
- name: Coverage gate(< 80% 则 CI 失败)
run: pytest --cov=app --cov-fail-under=80 --no-header -q
十一、常见坑与最佳实践
坑 1:忘记加 asyncio_mode = "auto" 导致 async 测试不执行
python
# ❌ 不配置 asyncio_mode,async def 测试被 pytest 当作同步函数跳过
async def test_something():
result = await some_async_operation() # 这行根本不会执行!
assert result == expected
# ✅ 在 pyproject.toml 中配置
# [tool.pytest.ini_options]
# asyncio_mode = "auto"
#
# 或者每个测试手动加装饰器:
@pytest.mark.asyncio
async def test_something():
result = await some_async_operation()
assert result == expected
坑 2:事务未回滚导致测试间数据污染
python
# ❌ 没有事务保护,测试间数据互相影响
@pytest_asyncio.fixture
async def db_session(test_engine):
async with AsyncSession(test_engine) as session:
yield session
# 没有回滚!上一个测试创建的数据残留
# ✅ 用嵌套事务确保回滚隔离
@pytest_asyncio.fixture
async def db_session(test_engine):
async with AsyncSession(test_engine) as session:
async with session.begin():
yield session
await session.rollback() # 测试结束强制回滚
坑 3:dependency_overrides 忘记清理
python
# ❌ 覆盖后不清理,影响同 session 的后续测试
app.dependency_overrides[get_db] = fake_db
# ... 测试代码 ...
# 没有清理!
# ✅ 用 try/finally 或 fixture 生命周期管理
@pytest_asyncio.fixture
async def async_client(db_session):
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(...) as client:
yield client
app.dependency_overrides.clear() # ← 确保清理
坑 4:测试顺序依赖(Anti-Pattern)
python
# ❌ 测试 B 依赖测试 A 创建的数据,单独运行测试 B 会失败
async def test_a_create_product(async_client):
await async_client.post("/products", json={"name": "p1"})
async def test_b_list_products(async_client):
# 假设 test_a 先跑了,这里才有数据
resp = await async_client.get("/products")
assert resp.json()["total"] == 1 # 与 test_a 耦合!
# ✅ 每个测试自包含,使用 fixture 准备前置数据
@pytest_asyncio.fixture
async def sample_product(async_client, admin_headers):
resp = await async_client.post(
"/products",
json={"name": "Sample", "price": 1.0, "stock": 1},
headers=admin_headers,
)
return resp.json()
async def test_list_products(async_client, sample_product):
resp = await async_client.get("/products")
assert resp.json()["total"] >= 1 # 至少有 fixture 创建的那一条
坑 5:SQLite 与 MySQL 方言差异导致集成测试漏报 Bug
python
# ❌ 以下 MySQL JSON 查询在 SQLite 中会报错或行为不同
stmt = select(Product).where(
func.json_extract(Product.metadata, "$.color") == "red"
)
# ✅ 数据库无关逻辑用 SQLite 测,数据库特定功能用 MySQL 容器测
# 在 conftest.py 中区分:
# tests/unit/ -> SQLite in-memory
# tests/integration/ -> MySQL container
十二、总结
| 知识点 | FastAPI 方案 | Spring Boot 对应 |
|---|---|---|
| 测试框架 | pytest + pytest-asyncio | JUnit5 |
| HTTP 接口测试 | AsyncClient(httpx) | MockMvc / WebTestClient |
| 依赖替换 | dependency_overrides |
@MockBean |
| 测试 DB | SQLite in-memory / testcontainers | H2 / Testcontainers |
| 事务隔离 | session.rollback() fixture |
@Transactional(测试回滚) |
| 覆盖率 | pytest-cov | Jacoco |
| CI 流水线 | GitHub Actions + service containers | Jenkins / GitHub Actions |
| Celery 测试 | task_always_eager=True |
Spring Batch JobLauncherTestUtils |
| 测试数据 | factory-boy / fixture | Faker / @TestDataSource |
🎯 金句:测试不是为了证明代码正确,而是为了在代码出错时第一时间发现。dependency_overrides 是 FastAPI 测试的灵魂------用它把"真实世界"换成"可控环境",从此测试不再依赖外部服务。
参考资料
下期预告
📝 第 11 篇:ClickHouse 集成------大数据查询实战
MySQL 处理亿级订单明细查询时慢到令人绝望,而同样的查询在 ClickHouse 上不到 1 秒完成。下一篇将深入 ClickHouse 的列式存储原理,手把手集成
asynch驱动,实现shop-api的销售统计分析 API,还会讲解从 MySQL 到 ClickHouse 的数据同步架构方案。