为什么你的FastAPI测试覆盖率总是低得让人想哭?

147 阅读4分钟

一、测试环境配置与基础框架搭建

在 FastAPI 开发中,完善的测试环境和基础框架是保证代码质量和可维护性的关键。以下是具体实现步骤:

1.1 环境配置与依赖管理

使用 pipenvpoetry 管理虚拟环境和依赖:

# 安装 pipenv  
pip install pipenv  

# 创建虚拟环境并安装依赖  
pipenv install fastapi uvicorn pytest httpx pydantic==2.0.0 sqlalchemy==2.0.0

依赖说明:

  • fastapi: Web 框架核心
  • uvicorn: ASGI 服务器
  • pytest: 测试框架
  • httpx: 测试 HTTP 请求
  • pydantic: 数据验证(v2.0 新特性支持严格类型校验)
  • sqlalchemy: ORM 工具

1.2 基础框架结构

创建项目目录结构:

project/
├── app/
│   ├── main.py           # 应用入口
│   ├── routes/           # API 路由
│   ├── models/           # Pydantic 数据模型
│   ├── database.py       # 数据库连接
│   └── config.py         # 配置文件
├── tests/
│   ├── conftest.py       # 测试配置
│   └── test_api.py       # API 测试用例
└── requirements.txt

1.3 核心框架代码

database.py (SQLAlchemy 配置):

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

# 依赖注入数据库会话
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

config.py (Pydantic 配置管理):

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    app_name: str = "FastAPI Demo"
    debug_mode: bool = False

    class Config:
        env_file = ".env"

settings = Settings()

main.py (FastAPI 入口):

from fastapi import FastAPI, Depends
from .database import get_db
from .routes import items_router
from .config import settings

app = FastAPI(title=settings.app_name)

# 挂载路由
app.include_router(items_router, prefix="/items")

@app.get("/")
async def root():
    return {"message": "Hello World"}

🔍 课后 Quiz 1

问题: 为什么使用 yield 而不是 return 提供数据库会话?
答案解析:
get_db 中使用 yield 实现依赖注入的生命周期管理:

  1. yield 前的代码在请求开始时执行(创建会话)
  2. yield 后的代码在请求结束时执行(关闭会话)
  3. 这种方式确保即使出现异常也能正确释放资源

⚠️ 常见报错解决方案 (1.X)

报错: 422 Unprocessable Entity
原因: 请求体不符合 Pydantic 模型定义
解决方案:

  1. 检查请求的 JSON 数据结构
  2. 验证模型字段是否匹配,例如:
    class Item(BaseModel):
        name: str  # 要求必须字符串类型
        price: float
    
  3. 使用 curl -v 查看详细错误信息

预防建议:

  • 为模型字段添加默认值,如 name: str = "default"
  • 使用 Union 支持多类型,如 price: Union[float, None] = None

二、测试覆盖率检测工具配置

测试覆盖率是衡量代码质量的核心指标。FastAPI 推荐使用:

  • pytest:测试运行器
  • coverage.py:覆盖率检测
  • pytest-cov:集成插件

2.1 配置 pytest

tests/conftest.py (测试依赖注入):

import pytest
from httpx import AsyncClient
from app.main import app

@pytest.fixture
async def client():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        yield ac

2.2 编写测试用例

tests/test_api.py

import pytest

# 测试 API 端点
@pytest.mark.asyncio
async def test_create_item(client):
    response = await client.post(
        "/items/",
        json={"name": "Test Item", "price": 9.99}  # 符合 Pydantic 模型
    )
    assert response.status_code == 200
    assert response.json()["name"] == "Test Item"

# 测试无效数据
@pytest.mark.asyncio
async def test_invalid_item(client):
    response = await client.post(
        "/items/",
        json={"price": "invalid"}  # 缺少必要字段 name
    )
    assert response.status_code == 422  # 触发 Pydantic 验证错误

2.3 覆盖率检测配置

  1. 安装依赖:
    pipenv install coverage pytest-cov
    
  2. 运行测试并生成报告:
    pytest --cov=app --cov-report=html tests/
    
  3. 查看 HTML 报告: open htmlcov/index.html

2.4 持续集成集成

.github/workflows/ci.yml 中配置:

name: CI Pipeline
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Set up Python
      uses: actions/setup-python@v4
    - name: Install dependencies
      run: pip install pipenv && pipenv install --dev
    - name: Run tests
      run: pytest --cov=app --cov-fail-under=80  # 要求覆盖率≥80%

🔍 课后 Quiz 2

问题: 覆盖率报告中 --cov-fail-under=80 参数的作用是什么?
答案解析:
该参数设置最低覆盖率阈值:

  1. 如果整体覆盖率低于 80%,测试将失败
  2. 防止未经充分测试的代码合并到主分支
  3. 在 CI/CD 流程中强制质量门禁

⚠️ 常见报错解决方案 (2.X)

报错: ModuleNotFoundError: No module named 'app'
原因: 测试运行路径错误
解决方案:

  1. 从项目根目录运行测试:
    cd /project && pytest
    
  2. pytest.ini 中添加:
    [pytest]
    pythonpath = .
    

预防建议:

  • 使用 __init__.py 将目录转为 Python 包
  • 避免在测试中硬编码绝对路径

三、测试覆盖率优化策略

3.1 分支覆盖率测试

# 测试不同业务分支
@pytest.mark.parametrize("price, discount", [
    (100, 10),  # 正常折扣
    (50, 0),    # 无折扣
    (30, -5)    # 无效折扣
])
async def test_discount_logic(client, price, discount):
    response = await client.post(
        "/items/",
        json={"name": "Test", "price": price, "discount": discount}
    )
    if discount < 0: 
        assert response.status_code == 400  # 验证业务规则
    else:
        assert response.status_code == 200

3.2 异步任务覆盖率

对于后台异步任务:

from fastapi import BackgroundTasks

async def notify_admins(email: str):
    # 模拟发送邮件
    print(f"Sending email to {email}")

@app.post("/report")
async def create_report(background_tasks: BackgroundTasks):
    background_tasks.add_task(notify_admins, "admin@example.com")
    return {"message": "Report scheduled"}

测试策略:

# Mock 后台任务
from unittest.mock import MagicMock

@pytest.mark.asyncio
async def test_background_task(client):
    app.notify_admins = MagicMock()  # 替换为 Mock 函数
    response = await client.post("/report")
    app.notify_admins.assert_called_once_with("admin@example.com")

3.3 目标覆盖率报告

----------- coverage: platform linux -----------
Name                  Stmts   Miss  Cover
-----------------------------------------
app/__init__.py          0      0   100%
app/main.py             15      0   100%
app/routes.py           20      1    95%   # 缺失分支
-----------------------------------------
TOTAL                   35      1    97%

 graph TD
A[启动测试] --> B[pytest 执行用例]
B --> C{覆盖率采集}
C -->|SQLAlchemy| D[数据库操作]
C -->|Pydantic| E[数据验证]
C -->|BackgroundTasks| F[异步任务]
D --> G[生成报告]
E --> G
 F --> G