ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FastAPI登录测试三大坑:密码哈希、异步事件循环与OAuth2认证详解

FastAPI登录测试三大坑:密码哈希、异步事件循环与OAuth2认证详解 上个月补一个 FastAPI 登录模块的测试原计划半天搞定结果两个晚上都搭进去了。接口用 curl 调没任何问题一换到 Pytest 里就三连崩密码哈希一跑就报 AttributeError异步测试要么不执行要么 loop 打架好不容易跑到认证环节又整个 401 把人看懵。上网搜每个坑单独都有帖子但很少有人把这三个放在一起讲。这篇就把我最终落地的方案完整贴出来包含可复现代码和每一条报错的排查思路。对正在补 FastAPI 测试的新人尤其是用过 pytest 写同步接口但还没写过异步接口的人应该能省下不少弯路。先说结论这三个坑表面上是密码哈希、异步测试、认证失败三个独立问题本质上全都是测试环境和真实运行环境不一致造成的。FastAPI 的认证依赖 OAuth2 协议细节密码哈希依赖底层加密库的版本兼容性异步测试依赖事件循环的管理方式任何一个环节没对齐测试就会给出看似莫名其妙的报错。下面我会先把原理拆开再给完整代码最后附一张常见报错速查表方便你以后直接对照。1. 这三个坑为什么会扎堆出现1.1 密码哈希并不只是“换个加密库”的问题密码哈希本身不复杂但测试时很容易踩到两个层级的问题。第一层是实现层你选择了 passlib 封装 bcrypt但 passlib 1.7.4 其实已经停更多年它通过bcrypt.__about__.__version__去读取版本号而 bcrypt 4.1 以后把这个属性删了。于是代码在导入时不报错一执行hash()或verify()就直接 AttributeError。这种“初始化正常、调用才炸”的库兼容问题在测试阶段最容易遇到因为业务代码可能没那么频繁触发注册接口。第二层是断言层很多人测试注册接口时喜欢直接比较返回的hashed_password和原始密码这当然会失败。正确的做法是测试“密码验证链路”——注册后能用明文密码通过登录接口错误密码被拒绝这才叫功能测试。哈希值是随机盐生成的每次结果不同完全是实现细节不该出现在断言里。1.2 异步报错的根源pytest 是同步的FastAPI 是异步的pytest 默认在同步世界里执行测试函数而 FastAPI 基于 asyncio 事件循环。要让async def的测试跑起来必须引入 pytest-asyncio这不算坑真正的坑在三个容易被忽略的细节上。第一个细节是测试函数没被标记或没开启 auto 模式。此时 pytest 把 async 函数当成普通函数执行测试不会跑内部代码只返回一个 coroutine 对象。很多“测试明明通过但什么都没测”的诡异现象就是这么来的更糟的是你加一条错误断言后测试仍然通过等于形同虚设。第二个细节是事件循环的生命周期不一致。比如测试文件里混用同步的 TestClient 和异步的 AsyncClient或者被测代码里持有数据库连接池、httpx 异步客户端就会抛出Future attached to a different loop这类 RuntimeError。原因是两套 client 各自创建了事件循环连接池里的连接绑定在旧循环上新循环里无法使用。第三个细节是 fixture 的写法。如果你只在测试函数上标记了pytest.mark.asyncio但 fixture 是同步的里面又创建了异步客户端那么这个客户端对象在跨循环使用时同样会翻车。后面我会给一个标准的 async fixture 写法。1.3 认证失败不是算法错了而是请求协议细节错了认证接口真实的 OAuth2 密码模式走的是表单请求不是 JSON。FastAPI 的OAuth2PasswordRequestForm依赖从request.form()里读取 username 和 password所以测试客户端调用/token时必须用data{...}而不是json{...}。一旦用 JSON 请求FastAPI 会返回 422 Unprocessable Entity而不是 401。这还不是最隐蔽的。更常见的是token 拿到了下一步带着Authorization: Bearer xxx头访问受保护接口结果仍然 401。问题往往出在 Header 格式上比如Bearer后面少了空格、单词拼成BearerToken、或者在 token 字符串里意外带了换行符。另外还有一种情况是测试代码里手动替换了get_current_user依赖但忘了清理dependency_overrides导致后续用例被污染明明没登录也能访问受保护接口或者反过来永远 401。测试隔离没做好认证逻辑就会变成“薛定谔的通过”。2. 环境准备让测试可复现2.1 最小项目结构我建议在一开始就把项目结构压到最小排除无关干扰。我最终用的目录长这样fastapi_test_demo/ ├── main.py ├── test_main.py ├── pytest.ini └── requirements.txtmain.py 里放一个精简版登录注册服务test_main.py 放全部测试pytest.ini 做异步配置requirements.txt 锁版本。没有用复杂的 src 布局或工厂模式是因为要验证的是测试方案本身而不是工程架构。如果你有自己的真实项目只需要把 main.py 替换成你的 app 对象其他思路完全一样。2.2 依赖清单与 pytest 配置requirements.txt 我最终是这么锁的fastapi0.110.0 uvicorn0.27.1 passlib1.7.4 bcrypt4.0.1 PyJWT2.8.0 httpx0.27.0 pytest8.1.0 pytest-asyncio0.23.5这里最关键的是 bcrypt 必须锁 4.0.1。如果你直接pip install bcrypt大概率装上 4.3 甚至更高版本然后 passlib 就会在第一次调用时报AttributeError: module bcrypt has no attribute __about__。要么锁 bcrypt 4.0.1要么别用 passlib 改用它自己的封装。我更推荐锁版本改动最小。pytest.ini 是最容易被忽视的文件[pytest] asyncio_mode autoasyncio_mode auto让 pytest-asyncio 自动识别所有async def测试函数不需要每个函数都手动加pytest.mark.asyncio。这个配置对新手最友好也能避免“忘记标记导致测试没执行”的经典问题。不过为了更明确我下面的代码仍然会显式加上标记双保险。2.3 TestClient vs AsyncClient为什么我选后者FastAPI 官方文档里最常出现的是TestClient它基于 requests 和 Starlette 的 TestClient用起来是同步的非常顺手。但到了异步场景它反而成了麻烦制造者。对比项TestClientAsyncClient ASGITransport调用方式同步请求异步请求事件循环自己创建独立 loop复用当前测试循环与异步数据库兼容容易冲突天然兼容与真实 HTTP 层接近度中等更高单元测试隔离一般好我最终选择了AsyncClient因为它走的是 ASGI 协议层直接把 FastAPI app 实例传给ASGITransport测试路径和真实 uvicorn 启动后的请求路径几乎一致而且不会出现 TestClient 在 async 测试函数里创建独立事件循环的问题。如果你用异步数据库或异步 Redis这条路是最顺的。同步项目用 TestClient 没问题但只要涉及异步特性别犹豫直接换 AsyncClient。3. 三大坑逐一拆解与完整代码3.1 坑一密码哈希的版本炸弹与验证测试先看报错现场。代码结构很简单创建CryptContext后调用一次 hashfrom passlib.context import CryptContext pwd_context CryptContext(schemes[bcrypt], deprecatedauto) print(pwd_context.hash(s3cret))如果你安装的是最新 bcrypt运行结果就是AttributeError: module bcrypt has no attribute __about__这个报错信息极具迷惑性因为你的源码里根本就没碰过bcrypt.__about__。我去翻了一下 passlib 的源码发现它在_bcrypt.py里通过bcrypt.__about__.__version__获取版本号用于版本判断而 bcrypt 从 4.1 开始移除了这个属性。这个兼容性问题至今没修因为 passlib 已经进入维护停滞状态。解决方式有两种。第一种是锁定依赖bcrypt4.0.1简单粗暴项目能直接跑起来。第二种是抛弃 passlib用 bcrypt 库自带接口封装或者换用新一代的pwdlib。如果你的项目是全新开始我建议直接走第二条路如果是老项目先锁版本是最稳妥的。真正要写进测试的断言是验证哈希链路而不是比对哈希字符串。注册用户后用正确密码和错误密码各登录一次pytest.mark.asyncio async def test_password_hash_verification(): async with AsyncClient(transportASGITransport(appapp), base_urlhttp://test) as client: # 注册一个新用户 resp await client.post( /register, json{username: alice, password: s3cret}, ) assert resp.status_code 200 # 数据库中存的是哈希值不是明文 stored fake_users_db[alice][hashed_password] assert stored ! s3cret # 正确密码能成功换到 token ok await client.post( /token, data{username: alice, password: s3cret}, ) assert ok.status_code 200 # 错误密码必须 401 bad await client.post( /token, data{username: alice, password: wrong}, ) assert bad.status_code 401这段代码同时覆盖了“哈希确实被使用了”和“登录校验正常”两层含义。断言stored ! s3cret的目的不是检查加密强度而是防止将来有人偷懒把明文直接塞进数据库后测试还稀里糊涂通过。3.2 坑二异步测试的事件循环冲突先展示一个错误的写法很多新手都这么栽过# 错误示例同步 TestClient 嵌套在 async 测试里 pytest.mark.asyncio async def test_wrong_loop(): with TestClient(app) as client: # 它会开一个自己的 loop resp client.get(/) assert resp.status_code 200这个简单场景可能碰巧能过但只要被测代码里有数据库连接池、异步 HTTP 客户端或其他绑定 loop 的资源就会出现RuntimeError: Task Task pending nameTask-1 corotest_wrong_loop() got Future attached to a different loop原因很简单TestClient 内部创建了独立事件循环而你的测试函数跑在 pytest-asyncio 提供的事件循环里两边的资源不能互相传递。这种情况尤其容易出现在“先用 TestClient 测登录再用 AsyncClient 测业务接口”的混用场景中。正确的思路是全部统一成 AsyncClient并且把客户端作为异步上下文管理器使用。我写了这样一个 fixturepytest.fixture async def client(): transport ASGITransport(appapp) async with AsyncClient(transporttransport, base_urlhttp://test) as c: yield c配合asyncio_mode auto这个 fixture 会在当前测试的事件循环中创建客户端请求结束再关闭不会再产生跨循环的边界问题。所有测试函数都从 fixture 拿 client不要在函数内部各建各的。还有一个小细节ASGITransport在 httpx 0.27 版本里可以直接from httpx import ASGITransport导入。老版本里它的位置在httpx._transports.asgi属于私有路径所以我特意在 requirements.txt 里锁了 0.27避免将来 import 路径变动影响你的复制粘贴。3.3 坑三OAuth2 认证失败的隐藏细节认证失败我总结出三个高频原因每一个都让人抓狂。第一个是表单格式。OAuth2 密码模式要求application/x-www-form-urlencodedFastAPI 的OAuth2PasswordRequestForm也只会从表单解析字段。如果你写成# 错误用 json 传表单字段返回 422 await client.post(/token, json{username: alice, password: s3cret})FastAPI 会告诉你请求体格式不对直接 422。正确做法是# 正确表单数据用 data 参数 await client.post(/token, data{username: alice, password: s3cret})就这么一个参数名差异能让调试时间多出一个小时。第二个是 Authorization Header 的格式。拿到 token 后访问受保护接口要这样写headers {Authorization: fBearer {token}} await client.get(/users/me, headersheaders)注意Bearer和 token 之间必须有一个空格大小写也要对。我见过有人写成Token xxx、bearer xxx还有人在 f-string 里带了换行符结果都是 401。Header 名本身大小写不敏感但 Bearer 方案的单词写法是约定最好保持标准。第三个是依赖覆盖的污染。测试中想模拟“已登录用户”时通常会覆盖get_current_userdef override_get_current_user(): return User( usernametest-user, emailtestexample.com, hashed_passwordnot-a-real-hash, ) app.dependency_overrides[get_current_user] override_get_current_user这个 API 用起来很爽但坑在遗忘清理。如果只在一个测试里设置了 override其他测试不会自动复原。后续用例可能继续使用这个伪造用户导致你没测到真实的认证失败路径或者出现完全没登录却返回 200 的假阳性结果。我的习惯是把清理放在 fixture 的 teardown 里pytest.fixture(autouseTrue) def clean_environment(): fake_users_db.clear() app.dependency_overrides.clear() yield fake_users_db.clear() app.dependency_overrides.clear()每个测试前后都清一遍数据库和依赖覆盖测试之间就彻底隔离了。3.4 完整代码main.py 与 test_main.py先看后端入口 main.py这是一个最小可运行的 FastAPI 登录注册服务from datetime import datetime, timedelta, timezone from typing import Optional import jwt from fastapi import Depends, FastAPI, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from passlib.context import CryptContext from pydantic import BaseModel app FastAPI() SECRET_KEY test-secret-key ALGORITHM HS256 ACCESS_TOKEN_EXPIRE_MINUTES 30 pwd_context CryptContext(schemes[bcrypt], deprecatedauto) fake_users_db: dict[str, dict] {} class User(BaseModel): username: str email: Optional[str] None hashed_password: str disabled: bool False class RegisterRequest(BaseModel): username: str email: Optional[str] None password: str def verify_password(plain_password: str, hashed_password: str) - bool: return pwd_context.verify(plain_password, hashed_password) def get_password_hash(password: str) - str: return pwd_context.hash(password) def create_access_token(data: dict, expires_delta: timedelta | None None) - str: to_encode data.copy() expire datetime.now(timezone.utc) ( expires_delta or timedelta(minutesACCESS_TOKEN_EXPIRE_MINUTES) ) to_encode.update({exp: expire}) return jwt.encode(to_encode, SECRET_KEY, algorithmALGORITHM) oauth2_scheme OAuth2PasswordBearer(tokenUrl/token) def get_current_user(token: str Depends(oauth2_scheme)) - User: credentials_exception HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detail无法验证凭据, headers{WWW-Authenticate: Bearer}, ) try: payload jwt.decode(token, SECRET_KEY, algorithms[ALGORITHM]) username payload.get(sub) if not username: raise credentials_exception except jwt.PyJWTError: raise credentials_exception user fake_users_db.get(username) if not user: raise credentials_exception return User(**user) app.post(/register, response_modelUser) def register(req: RegisterRequest): if req.username in fake_users_db: raise HTTPException(status_code400, detail用户名已存在) hashed_password get_password_hash(req.password) fake_users_db[req.username] { username: req.username, email: req.email, hashed_password: hashed_password, disabled: False, } return fake_users_db[req.username] app.post(/token) def login(form_data: OAuth2PasswordRequestForm Depends()): user fake_users_db.get(form_data.username) if not user or not verify_password(form_data.password, user[hashed_password]): raise HTTPException( status_codestatus.HTTP_401_UNAUTHORIZED, detail用户名或密码错误, ) token create_access_token({sub: form_data.username}) return {access_token: token, token_type: bearer} app.get(/users/me, response_modelUser) def read_users_me(current_user: User Depends(get_current_user)): return current_user再来看测试文件 test_main.py我已经把之前提到的所有坑都绕开了import pytest from httpx import ASGITransport, AsyncClient from main import User, app, fake_users_db, get_current_user pytest.fixture(autouseTrue) def clean_environment(): fake_users_db.clear() app.dependency_overrides.clear() yield fake_users_db.clear() app.dependency_overrides.clear() pytest.fixture async def client(): transport ASGITransport(appapp) async with AsyncClient(transporttransport, base_urlhttp://test) as c: yield c pytest.mark.asyncio async def test_full_auth_flow(client): # 1. 注册新用户 await client.post( /register, json{username: alice, email: aliceexample.com, password: s3cret}, ) # 2. 错误密码登录 - 401 bad await client.post( /token, data{username: alice, password: wrong}, ) assert bad.status_code 401 # 3. 正确密码登录 - 获取 token ok await client.post( /token, data{username: alice, password: s3cret}, ) assert ok.status_code 200 token ok.json()[access_token] # 4. 带 token 访问受保护接口 me await client.get(/users/me, headers{Authorization: fBearer {token}}) assert me.status_code 200 assert me.json()[username] alice pytest.mark.asyncio async def test_register_duplicate_user(client): await client.post( /register, json{username: bob, password: pw123456}, ) duplicate await client.post( /register, json{username: bob, password: pw123456}, ) assert duplicate.status_code 400 pytest.mark.asyncio async def test_read_users_me_with_mock_auth(client): def override_get_current_user(): return User( usernametest-user, emailtestexample.com, hashed_passwordnot-a-real-hash, disabledFalse, ) app.dependency_overrides[get_current_user] override_get_current_user resp await client.get(/users/me) assert resp.status_code 200 assert resp.json()[email] testexample.com记得 clean_environment fixture 会在每个测试后清除dependency_overrides所以最后一个测试设置的 mock 用户不会泄漏到其他用例。4. 常见问题速查表与避坑技巧4.1 报错信息与对应处理对照我把这次实战中遇到的典型报错整理成了表方便以后直接检索。报错或现象根本原因处理办法AttributeError: module bcrypt has no attribute aboutpasslib 与新版 bcrypt 不兼容锁定 bcrypt4.0.1或改用 pwdlib/bcrypt 自封装pytest 提示测试通过但内部断言没生效async 测试函数没有被 pytest-asyncio 执行设置 asyncio_modeauto或加 pytest.mark.asyncioRuntimeError: Future attached to a different loop同步 TestClient 与异步资源混用全部改成 AsyncClient ASGITransport请求 /token 返回 422表单字段用 json 传参改成 data{username: ..., password: ...}带 Authorization 头仍然 401Bearer 格式错误或 token 过期检查 fBearer {token} 的空格、前后缀第一个测试通过后面的认证测试集体失败dependency_overrides 污染在 autouse fixture 中调用 app.dependency_overrides.clear()注册接口重复调用返回 400内存数据库残留测试数据每个测试前后 fake_users_db.clear()这张表覆盖了我踩过的所有高频场景。如果你遇到“局部过、全局挂”的奇怪情况优先怀疑测试污染而不是业务代码。4.2 我习惯保留的三个小技巧第一个是在 fixture 里统一准备用户和 token。只要测试涉及认证我就写一个返回(client, token)的 fixture避免每个用例重复注册登录。别在多个测试里裸调/register和/token数据清理和状态管理会越来越难。第二个是调试时把响应体打印出来。很多认证问题只看 status_code 根本定位不了我会先用curl验证接口本身是好的再用response.text对比测试环境返回找出 Header 或表单格式差异。httpx的响应对象有.request属性可以看到实际发出的请求细节这一步对排查“为什么没带对 Header”特别有效。第三个是不要过度依赖dependency_overrides。它能模拟登录用户但你很容易忘记它是一个实验室呼吸机——只能证明“在依赖被替换时接口逻辑正确”无法证明真实认证链路没问题。所以我在测试套件里保留了真实的登录流程测试再使用 override 测试业务分支两者互补。只写 override 的测试一旦到了生产环境换了认证方式会第一个失灵。最后再分享一点实在体会这三个坑连在一起踩其实给了我一个很深的印象FastAPI 的测试难点不在接口本身而在把 FastAPI 的异步特性和 OAuth2 协议细节搬运到 pytest 环境时中间那层转换出了问题。只要你坚持用 AsyncClient 走 ASGI 层、用表单格式调用 token 接口、每个测试前清理全局状态绝大多数怪问题都能被挡在门外。我自己后来再看这类项目已经不再慌乱了。遇到报错第一反应不是改业务代码而是先问自己四个问题这是不是版本兼容问题这是不是事件循环问题这是不是请求协议格式问题这是不是测试间污染问题把这四个问题按顺序排查完基本都能落地一个稳定的测试套件。希望这篇折腾出来的经验能让你少熬两个晚上。
返回列表