FastAPI 系列 · (十):测试——从单元到集成

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.pyasync_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 的数据同步架构方案。

相关推荐
li星野4 小时前
FastAPI 入门:异步与同步端点的性能差异与并发测试解析
fastapi
dinl_vin10 小时前
FastAPI 系列 ·(九):中间件与错误处理:让服务更健壮
中间件·状态模式·fastapi
圣殿骑士-Khtangc12 小时前
Python后端开发实战:FastAPI构建高性能RESTful API完整指南
python·restful·fastapi
展示猪肝13 小时前
FastAPI 全局异常处理最佳实践:自定义异常、统一响应、兜底处理
python·异常处理·fastapi·后端开发
青衫客361 天前
从零实现多智能体 Runtime(一):系统架构、状态机与任务编排设计
agent·fastapi
曲幽1 天前
FastApiAdmin 后端接口开发好了,前端管理界面怎么调用与显示?
python·vue3·api·fastapi·web·ant design·view·menu·frontend
dinl_vin1 天前
FastAPI 系列·(七):Redis 集成——缓存、分布式锁与 Session 管理
redis·缓存·fastapi
还是鼠鼠2 天前
AI掘金头条新闻系统 (Toutiao News)-封装通用成功响应格式
数据库·后端·python·fastapi·web
dinl_vin2 天前
FastAPI 系列·(三):依赖注入——用 Depends 构建分层架构
架构·fastapi