ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

FastAPI测试实战:用pytest和TestClient轻松搞定接口单元测试

FastAPI测试实战:用pytest和TestClient轻松搞定接口单元测试 讲真的我在项目里见过太多“上线前拍胸脯上线后被喷”的场面了。FastAPI上手快路由写起来爽交互式文档一开接口看上去像那么回事但你要是没把单元测试当回事生产环境里被业务方追着问“这个接口为什么挂了”的时候后悔都来不及。我自己在多个FastAPI项目实战里踩过坑之后才彻底把TestClient用明白。很多朋友对FastAPI测试的印象还停留在“用Postman手动点一点”或者干脆不写这真不行。后来我用pytest加TestClient这套方案把所有核心接口都测了一遍发现它比我想象中简单太多用对了是真的香。1. TestClient到底在测什么先搞懂它和真实请求差在哪1.1 同样是发请求TestClient和真实HTTP请求差在哪很多人第一次听说TestClient容易把它理解成“在代码里发一个真正的HTTP请求”。其实不是。TestClient是FastAPI官方基于httpx封装的测试客户端它不发真实的网络请求而是直接把请求交给ASGI应用去处理。什么意思呢就是你不需要先uvicorn main:app把服务跑起来再开一个端口去访问TestClient会直接在进程内部把请求“喂”给你的FastAPI应用。这种设计有一个非常实在的好处快。真实HTTP请求要走TCP握手、DNS解析、代理等一系列网络流程TestClient全跳过了所以单测跑起来嗖嗖的。另外一个好处是没有端口冲突本地同时跑十个测试项目也不用担心谁占了8000端口。当然有得必有失TestClient测不到真实网络栈里的东西比如负载均衡、反向代理、真实域名解析这些只能靠集成测试或上线前的手工冒烟去覆盖。我列了一个对比表方便你直观感受维度真实HTTP请求TestClient网络栈走完整TCP/IP、DNS、代理不走网络直接调ASGI速度较慢受网络和启动影响很快毫秒级验证范围能覆盖反代、网关等部署层只覆盖应用自身逻辑使用场景联调、冒烟、线上巡检单元测试、回归测试所以TestClient不是用来替代联调的它是让你在开发阶段就能低成本、高频次地验证接口逻辑是不是对的。1.2 有了TestClient你才能真正做到“每个接口都敢碰”在没有测试的情况下一个接口改完你只能手动点一下Swagger看看返回结果对不对。但接口的依赖注入有没有生效、路由能否正确解析、响应结构是否符合预期这些光靠肉眼盯很难全查清楚。TestClient让你能在毫秒级内把这些全部验证一遍而且是自动化、可重复的。拿一个最基础的例子来说假设应用里有个返回JSON的路由from fastapi import FastAPI app FastAPI() app.get(/ping) def ping(): return {message: pong}对应的测试长这样from fastapi.testclient import TestClient from main import app client TestClient(app) def test_ping(): response client.get(/ping) assert response.status_code 200 assert response.json() {message: pong}就这么简单。没有启动服务没有网络请求断言状态码、断言返回体接口的基本行为就被锁住了。以后任何人改了这个接口只要跑一遍测试就能知道有没有改坏。没有TestClient的时候这事儿要么靠运气要么靠人工去点出问题的概率完全取决于团队有多细心。2. 环境准备把pytest TestClient这套组合一步装到位2.1 依赖和目录结构先把地基打牢很多FastAPI教程只讲怎么把路由写出来不讲怎么测所以新人上手测试时容易卡在环境上。其实依赖很简单直接装这些pip install fastapi[test] pytest pytest-cov pytest-asyncio httpx注意fastapi[test]这个写法它会自动把httpx装好而TestClient就是基于httpx实现的。pytest-asyncio是给异步测试用的后面讲AsyncClient时会用到。pytest-cov用来统计覆盖率。建议的项目目录结构大概是这样的myproject/ ├── app/ │ ├── main.py │ ├── routers/ │ │ └── items.py │ ├── db.py │ └── models.py └── tests/ ├── conftest.py ├── test_items.py └── test_users.py测试文件命名有个规矩pytest默认会去找test_*.py或*_test.py结尾的文件所以务必按这个格式来否则测试不会被自动发现。我见过有人把测试文件命名为test_api_001.py、test_api_002.py这种也能跑但后面维护起来会特别乱还是按照“一个业务模块对应一个测试文件”来组织最舒服。2.2 conftest.py与fixture设计把自己的测试搭成可复用的架子新手最容易犯的错是在每个测试文件里都创建一个TestClient实例。我今天建一个明天另一个文件又建一个最后改动app配置时所有文件都要跟着改极其痛苦。正确做法是在tests/conftest.py里统一写好fixture让所有测试文件共享。fixture可以理解成“给测试提前准备好材料和环境”的钩子。最简单的fixture是这样import pytest from fastapi.testclient import TestClient from app.main import app pytest.fixture() def client(): return TestClient(app)然后测试文件里直接把这个client当参数传进去def test_ping(client): response client.get(/ping) assert response.status_code 200这比在每个文件里手动创建要清爽很多。如果需要每个测试都跑在独立环境里可以用yield实现“用例前准备、用例后清理”pytest.fixture() def client(): with TestClient(app) as c: yield c这段代码里的with TestClient(app) as c:是一个大坑也是很多FastAPI教程不会细讲的地方。后面我专门开一节说这里你先记住一个结论如果你在接口里通过lifespan初始化了资源就必须用with写法否则那些启动逻辑不会执行。3. 核心实战带数据库、带登录态的接口到底怎么测3.1 依赖覆盖dependency_overrides是FastAPI测试的灵魂接口只要连了数据库测试的复杂度就上来了。你总不能在单测里连生产库吧。FastAPI官方其实留了一个非常优雅的机制叫app.dependency_overrides它的作用是把接口里用到的依赖替换成测试专用的依赖。举个例子你的接口通过依赖拿数据库会话from fastapi import Depends from sqlalchemy.orm import Session from app.db import get_db app.get(/items/{item_id}) def get_item(item_id: int, db: Session Depends(get_db)): return db.query(Item).filter(Item.id item_id).first()在测试里你完全可以换一个假的get_dbfrom app.main import app def override_get_db(): # 这里返回测试专用的数据库会话 ... app.dependency_overrides[get_db] override_get_db注意这个覆盖是全局的所以最好配合fixture用每次测试结束之后清理掉否则会污染其他用例pytest.fixture() def client(): app.dependency_overrides[get_db] override_get_db with TestClient(app) as c: yield c app.dependency_overrides.clear()我见过一些人觉得这个机制不hack其实完全不是FastAPI官方文档里就明确推荐这种用法它是依赖注入框架自带的特性属于官方支持的能力。用好它你的测试可以完全不碰真实外部依赖。3.2 数据库测试内存SQLite也没你想的那么省事很多项目为了省事会在测试里用SQLite内存库。听起来很美好速度快、不需要安装数据库服务。但实际落地时你会踩到一个非常隐蔽的坑。SQLite默认的内存库在不同数据库连接之间是相互隔离的。SQLAlchemy连接池如果开了多个连接第一个连接里创建的表第二个连接根本看不到。你很可能遇到这种情况测试初始化的时候建表成功了跑起来的时候却报“no such table”。解决办法是让SQLAlchemy连接池始终复用同一个连接用StaticPoolfrom sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool engine create_engine( sqlite://, connect_args{check_same_thread: False}, poolclassStaticPool, ) TestingSessionLocal sessionmaker(bindengine, autoflushFalse, autocommitFalse)然后每次测试前重建表测试结束后删掉from app.models import Base pytest.fixture() def db_session(): Base.metadata.create_all(bindengine) session TestingSessionLocal() try: yield session finally: session.close() Base.metadata.drop_all(bindengine)把db_session和前面的override_get_db组合起来你就能在测试里安心地操作数据库了。还有一种更高级的方案是测试用例里开启事务、结束时回滚好处是快缺点是FastAPI的请求和测试的session往往不在同一个事务里实现起来容易绕晕。我建议新人先老老实实用create_all加drop_all稳定可靠。3.3 登录态与身份信息造一个“已登录用户”再测接口真实项目里很多接口都要求用户先登录。测试这种接口有两条路一条是真实走一遍登录流程拿到token再把token带进Header里另一条是直接把“获取当前用户”的依赖给覆盖掉。先看真实流程方案。假设登录接口返回JWTdef test_get_me_with_real_login(client): login_resp client.post(/login, json{username: tester, password: 123456}) assert login_resp.status_code 200 token login_resp.json()[access_token] me_resp client.get(/me, headers{Authorization: fBearer {token}}) assert me_resp.status_code 200这个方案最大优点是非常接近线上真实情况能顺带验证登录接口有没有问题。缺点是慢而且每次测试都要先发一次登录请求如果后面几十个接口都要带token代码会非常啰嗦。所以我更推荐在实际项目中以覆盖依赖为主from app.dependencies import get_current_user def fake_get_current_user(): return {id: 1, username: tester, role: admin} pytest.fixture() def client_with_user(client): app.dependency_overrides[get_current_user] fake_get_current_user yield client app.dependency_overrides.clear()这样测试代码里不需要关心token怎么生成只要专注测接口逻辑就行。至于登录接口本身单独写几个用例验证token生成是否正确就够了。3.4 上下文与全局状态app.state里藏着的东西别忘初始化FastAPI里有一种不太起眼但很常见的需求在应用启动时初始化一些全局资源比如数据库连接池、Redis客户端、配置对象然后存到app.state上。接口里通过request.app.state来访问。这种设计思路本身没毛病但测试时会突然发现接口一跑就报错说app.state里没有某个属性。原因很简单TestClient没有触发lifespan启动事件你的初始化代码压根没执行。比如你的main.py长这样from contextlib import asynccontextmanager from fastapi import FastAPI, Request asynccontextmanager async def lifespan(app: FastAPI): app.state.settings {debug: True, db_url: sqlite:///test.db} yield app FastAPI(lifespanlifespan) app.get(/debug) def debug(request: Request): return request.app.state.settings如果只写client TestClient(app)然后直接client.get(/debug)大概率会报AttributeError。你需要改成def test_debug(client): response client.get(/debug) assert response.status_code 200但前面的conftest里clientfixture必须用with写法这样TestClient会正确触发lifespan的启动和关闭pytest.fixture() def client(): with TestClient(app) as c: yield c这一段算是我自己最早忽略的地方第一次跑挂的时候百思不得其解后来查了文档才发现是生命周期没触发。你现在看到了就直接绕开这个坑。4. 只测happy path远远不够边界条件和异常场景也要覆盖4.1 参数校验与422接口不是只有200才叫正常新手写测试有个通病只知道测接口正常返回200然后断言一下里面的字段。但接口线上出问题往往不是正常路径出问题而是非法输入没被拦住或者业务规则没生效。FastAPI自带参数校验缺参、类型错误、枚举值不匹配时默认会返回422。这种场景特别适合写成测试用例因为它是接口安全非常重要的一环而且自动化之后几乎零成本def test_create_item_missing_name(client): response client.post(/items, json{price: 10}) assert response.status_code 422 def test_create_item_wrong_type(client): response client.post(/items, json{name: book, price: not-a-number}) assert response.status_code 422我还建议把业务规则也测进来。比如库存不能为负数、用户名不能重复、订单不能二次支付这些逻辑通常写在接口或service里一旦改坏用户马上就能感觉到。比如def test_order_cannot_be_paid_twice(client, db_session): # 先创建一个已支付订单 order create_order(db_session, statuspaid) # 再尝试支付 response client.post(f/orders/{order.id}/pay) assert response.status_code 400 assert response.json()[detail] order already paid这类用例写起来不复杂但价值极高它们代表的是业务规则的底线。4.2 第三方API调用测试时把真实网络请求切掉很多后端接口会调用第三方服务比如支付回调、天气接口、地图API。单元测试里如果真去请求这些外部服务会有三个问题慢、不稳定、可能出现你无法控制的结果。网络一抖测试就挂了但挂的原因不是你的代码有问题。正确的思路是mock也就是伪造外部服务的返回值。pytest自带的monkeypatch就够用了。比如你的service里定义了一个pay_servicefrom app.services import pay_service app.post(/pay) def pay(): result pay_service.charge() return {status: result}测试时直接把charge换掉def test_pay_success(monkeypatch, client): def fake_charge(*args, **kwargs): return {status: success, order_id: 2024001} monkeypatch.setattr(pay_service, charge, fake_charge) response client.post(/pay) assert response.status_code 200 assert response.json()[status] success如果你用的是httpx.AsyncClient直接发请求也可以用respx这类专门的库拦截HTTP调用但绝大多数情况下monkeypatch已经足够。核心原则就一句话单元测试里不要出现真实网络请求所有外部依赖都按预期给假数据。4.3 超时与并发有些问题只在压力下现形最后一个容易忽略的点是接口在单请求下没问题不代表并发时也没问题。数据库连接池不够用、共享变量被多个请求同时读写、死锁这些只有在并发场景下才会暴露。简单的并发冒烟测试可以用Python自带的ThreadPoolExecutorfrom concurrent.futures import ThreadPoolExecutor def test_concurrent_reads(): def call(): with TestClient(app) as c: return c.get(/items/1).status_code with ThreadPoolExecutor(max_workers8) as executor: results list(executor.map(lambda _: call(), range(64))) assert all(status 200 for status in results)注意每个线程最好各自创建TestClient共用同一个client在并发下容易出一些奇怪的连接状态问题。这个测试不是正经的压力测试但能帮你发现那些“一上线就被并发打爆”的低级问题。超时保护也很重要。有些接口完成得很慢测试可能会一直卡着不退出。可以给pytest加超时来兜底pip install pytest-timeout然后跑测试时带上参数pytest --timeout10超过10秒还没跑完的用例会被强制掐断并报失败这样至少不会让CI挂上一个小时没有反应。5. 实战避坑那些测试里反复踩的坑我一次说完5.1 同步还是异步TestClient与AsyncClient别用混这是被问烂但也最容易踩的问题。FastAPI的路由函数可以写def也可以写async def底层处理方式不一样。TestClient是同步接口不管你是同步路由还是异步路由它都能直接.get()、.post()不需要额外处理。但有时候你想在测试代码里写异步逻辑比如并发地发起多个请求这时候TestClient就不太顺手因为它内部是阻塞的。这时可以用httpx.AsyncClient搭配ASGITransportimport pytest import httpx from httpx import ASGITransport pytest.mark.asyncio async def test_async_client(app): transport ASGITransport(appapp) async with httpx.AsyncClient(transporttransport, base_urlhttp://test) as ac: response await ac.get(/ping) assert response.status_code 200注意pytest.mark.asyncio需要前面装的pytest-asyncio支持。如果你懒得在每个用例前加装饰器可以在pytest.ini里配置[pytest] asyncio_mode auto这样所有async def测试函数都会被自动识别。这个问题很容易忽视等你在异步测试里跑出“事件循环已关闭”之类的报错时再回头看这节就明白了。5.2 fixture污染、用例顺序依赖这些“幽灵问题”最磨人测试最忌讳的一点是“用例之间互相影响”。我见过不少项目单个测试跑都能过一整个测试套件跑就随机挂几个。这类问题排查起来特别费劲因为它们不是代码逻辑错误而是环境没隔离干净。最常见的污染源有三个数据库表数据没清干净A用例插入了一条nametest的数据B用例查询时被干扰dependency_overrides没清空上一个用例覆盖了get_db下一个用例还在用全局变量被改动比如某个模块里的缓存字典被测试写入了数据。针对这三个问题我的习惯是每个用例尽量用独立的测试数据不依赖已有数据在fixture的yield之后统一调用app.dependency_overrides.clear()对全局变量、缓存、环境变量测试结束时要恢复原值可以用monkeypatch提供的setattr、setenv等能力它自带自动恢复。如果你能做到“每个用例都可以单独运行且不会影响其他用例”那你的测试套件离稳定就不远了。5.3 覆盖率怎么提上去又不变成自嗨覆盖率是个双刃剑。不关注代码可能有一大片没被验证过度关注团队会为了凑数字写一堆无效断言比如只调接口不检查任何返回内容。用pytest-cov可以很方便地统计pytest --covapp --cov-reportterm-missing tests/参数里的term-missing会列出哪些行没有被执行到方便你定位遗漏。但请记住覆盖率百分比只是参考不是目标。核心模块比如支付、登录、订单覆盖率尽量做到90%以上辅助模块可以适当放宽真正有意义的是“核心业务逻辑有没有被验证到”。另外我强烈建议在CI里跑测试时加上-q和--disable-warnings不然输出会很长反而淹没真正有用的报错信息。等代码稳定后再加上--cov-fail-under80这类门槛强制约束团队不要跌破底线。6. 日常工作流把“写测试”变成顺手的事6.1 写完接口顺手写测试的“三步法”很多人不写测试不是懒是不知道从哪儿下手。我自己摸索出一套很省力的三步法写每个接口时按这个顺序来第一步测正常路径。发一个合法请求断言状态码200核心字段是不是预期值。这一条确保接口能用。第二步测异常路径。比如找不到资源、权限不够、参数非法分别断言404、403、422。这一条确保接口不会在异常情况下“裸奔”。第三步测业务规则。如果接口里有任何“不能xx”“必须xx”的逻辑专门写用例验证。这一条是价值的核心。三步走完一个接口才算真正测完。如果你是先写接口再写测试时间久了会忘记规则所以强烈建议在写完接口、趁思路还清晰的时候立刻补测试不要拖到明天。6.2 本地、CI、覆盖率一条命令跑完整套验证本地开发时我会在提交代码前跑这组命令pytest -q --disable-warnings pytest --covapp --cov-reportterm-missing tests/如果项目里有pre-commit钩子可以把pytest加进去让每个commit之前都自动跑一遍基础用例。CI里也一样核心就两步装依赖、跑测试。如果你用GitHub Actions一个最小的流水线大概长这样name: Python Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv6 with: python-version: 3.11 - run: pip install -r requirements-dev.txt - run: pytest -q --disable-warnings我不建议一上来就在CI里堆很多复杂的缓存、并行、多版本矩阵配置先把最基础的“每次提交都跑一遍测试”跑稳再逐步优化速度。测试这套东西最重要的是稳定和可持续不是跑得多花哨。6.3 最后分享一个我自己的测试习惯我自己的习惯是写测试时永远要问一个问题“如果这个测试现在失败我能一眼看出是哪段逻辑出问题了吗”如果答案是否定的说明断言写得太粗或者测试数据的构造太绕。所以我在测试里会尽量让断言贴近业务语义不检查无关字段不为覆盖率硬凑用例。遇到某个测试频繁随机失败我不会选择把它禁用或跳过而是会停下来查清楚原因因为这种随机失败通常意味着代码里有隐藏的并发问题或状态污染无视它迟早会在线上吃到苦头。说实话我自己也是被线上事故教育过之后才认真对待测试的。FastAPI的TestClient给了我们一个极低门槛的入口你不用搭复杂框架不用维护独立的测试服务写好fixture、覆盖依赖、多写几条异常用例就能把大部分线上事故提前拦在发布之前。今天就先找个最简单的接口试一试把TestClient跑起来你会发现写测试这件事真的没那么难。
返回列表