Skip to content

第 20 章 · 测试 FastAPI 应用

本章目标:用 TestClient + pytest 为 FastAPI 应用编写自动化测试,掌握依赖覆盖、生命周期测试与 WebSocket/文件上传测试。

20.1 TestClient 基础

FastAPI 基于 Starlette 的 TestClient(底层是 HTTPX),不需要真正启动服务器、不占端口,直接在进程内调用 ASGI 应用:

bash
uv add httpx pytest   # TestClient 需要 httpx
python
# main.py
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def read_main():
    return {"msg": "Hello World"}
python
# test_main.py
from fastapi.testclient import TestClient
from .main import app  # 与 main.py 同包时的相对导入

client = TestClient(app)  # 把 app 传给 TestClient


def test_read_main():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"msg": "Hello World"}

运行 pytest 即可。三个关键约定:

  • 测试函数用普通 def不是 async def;调用客户端也不用 await——这样可以直接配合 pytest,无需额外插件;
  • TestClient 用法与 httpx 完全一致:路径/查询参数拼进 URL,JSON 体传给 json 参数,表单用 data,请求头用 headers,Cookie 用 cookies
  • 发送的数据是"能转 JSON 的对象",不是 Pydantic 模型;如有模型先用 jsonable_encoder 转换。

TIP

也可以 from starlette.testclient import TestClientfastapi.testclient 只是同一对象的便捷出口。

20.2 用 pytest fixture 组织测试

真实项目的 app 可能需要 lifespan 初始化资源。把 client 放进 fixture,并用 with 语句让 lifespan 在测试中触发

python
import pytest
from fastapi.testclient import TestClient
from .main import app


@pytest.fixture
def client():
    # with 语句进入时执行 startup(lifespan 前半段)
    # 退出时执行 shutdown(lifespan 后半段),如关闭连接池
    with TestClient(app) as c:
        yield c


def test_health(client):
    assert client.get("/health").status_code == 200

不用 with 时 TestClient 不会跑 lifespan——这是"为什么测试里数据库没初始化"的最常见原因。

20.3 dependency_overrides:替换依赖

测试外部服务(付费认证接口、数据库)时不应该真的去调。FastAPI 提供了 app.dependency_overrides 字典:key 是原依赖函数,value 是替代函数,原依赖的子依赖也不会再执行:

python
# main.py
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()


class User(BaseModel):
    username: str


async def verify_token_from_provider(token: str):  # 模拟昂贵的第三方认证
    raise HTTPException(status_code=401)


async def get_current_user(user=Depends(verify_token_from_provider)):
    return user


@app.get("/me")
async def me(user: User = Depends(get_current_user)):
    return user
python
# test_main.py
from fastapi.testclient import TestClient
from fastapi import Depends
from .main import app, get_current_user

client = TestClient(app)


def fake_user():
    return {"username": "test_user"}  # 永远成功的 mock 用户


def test_me_with_override():
    app.dependency_overrides[get_current_user] = fake_user
    resp = client.get("/me")
    assert resp.status_code == 200
    assert resp.json() == {"username": "test_user"}
    app.dependency_overrides.clear()  # 清空恢复原始依赖

要点:

  • 无论原依赖用在路径函数参数、装饰器 dependencies=[...] 还是 include_router 里,都能被覆盖;
  • 只想在个别测试中替换时,就在该测试开头设置 override、结尾 clear() 或置为 {}
  • 这同样是替换测试数据库的标准手法:override 掉 get_db,返回内存 SQLite 会话。

20.4 测试 WebSocket 与文件上传

WebSocket 测试

同样使用 TestClient,通过 websocket_connect 建立会话上下文

python
# main.py 中已有
@app.websocket("/ws")
async def ws(websocket):
    await websocket.accept()
    await websocket.send_text("hello")
    msg = await websocket.receive_text()
    await websocket.send_text(f"echo: {msg}")
    await websocket.close()
python
# test_ws.py
from fastapi.testclient import TestClient
from .main import app


def test_websocket_echo():
    client = TestClient(app)
    with client.websocket_connect("/ws") as ws:
        assert ws.receive_text() == "hello"
        ws.send_text("world")
        assert ws.receive_text() == "echo: world"

with 块内收发消息必须交替匹配服务端行为:先 receive_text() 收到 "hello",再发送、再接收回显。

上传文件与表单测试

httpx 风格:文件用 files 参数,表单字段用 data

python
from fastapi import FastAPI, UploadFile, File, Form

app = FastAPI()


@app.post("/upload")
async def upload(file: UploadFile, note: str = Form(...)):
    content = await file.read()
    return {"filename": file.filename, "size": len(content), "note": note}
python
def test_upload():
    client = TestClient(app)
    resp = client.post(
        "/upload",
        files={"file": ("report.txt", b"hello data", "text/plain")},
        data={"note": "季度报表"},
    )
    assert resp.status_code == 200
    assert resp.json()["size"] == 10
    assert resp.json()["filename"] == "report.txt"

20.5 本章小结

  • TestClient(app) 进程内直连 ASGI 应用,测试函数用同步 def,无需启动服务器;
  • 传参规则同 httpx:json/data/headers/cookies/files;Pydantic 模型需先经 jsonable_encoder
  • with TestClient(app) 才会触发 lifespan 的 startup/shutdown,WebSocket 测试用 with client.websocket_connect(...)
  • app.dependency_overrides[原依赖] = 替代函数 可在任意位置替换依赖(含子依赖不再执行),结束记得 clear()
  • 替换数据库、跳过第三方认证都靠依赖覆盖实现。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 使用 TestClient 编写测试时,官方推荐的做法是?

2. 为什么测试中数据库连接池没有初始化?最可能的原因是?

3. 关于 app.dependency_overrides,说法正确的是?

4. 用 TestClient 测试 POST /upload 上传文件,正确写法是?

🛠️ 动手实践

  1. 为第 19 章拆分后的 todos 应用编写完整测试:fixture 创建 client + 内存 SQLite override,覆盖创建、查询、404 三条路径。
  2. 给应用加一个需要 X-API-Key 头的 admin 接口,写两个测试:带正确 key 返回 200、错误 key 返回 403。
  3. 为第 17 章的聊天 WebSocket 写一个测试:连接后收到欢迎语,发送消息并断言广播回显内容。

应用能被可靠测试后,最后一章解决配置管理与上线部署:第 21 章 · 配置管理与应用生命周期