我接手过不少FastAPI项目每次从写接口切到写测试总能在同一批地方翻车密码哈希、异步测试、认证依赖。这几个坑不是写不对代码那么简单它背后是测试环境、依赖注入、运行机制三个层面的错位单看报错信息根本摸不着头脑。这半个月我又在现网项目里踩了一轮把思路和完整代码整理出来希望能帮你绕过这些弯路。1. 密码哈希注册能过登录却验不过问题出在校验不是加密先说最典型的表象用passlib哈希密码注册接口返回200到了登录接口拿着刚设置的密码去校验verify返回False。很多人第一反应是算法配错了或者盐失效了其实大多数情况是你在注册时根本没有哈希或者哈希后的字段被覆盖了。1.1 你以为存的是密文其实存了裸密码我之前见过一位同事的代码# user.py class UserCreate(BaseModel): username: str password: str app.post(/register) def register(payload: UserCreate, db: Session Depends(get_db)): user User( usernamepayload.username, passwordpayload.password, # 直接存明文 ) db.add(user) db.commit() return {status: ok}结果项目一开始没做校验登录时把用户输入的密码直接数据库里的密码生产环境居然跑了一阵子。直到某天加了hash逻辑登录全挂。所以第一条铁律注册和校验必须走同一个哈希源且始终只保存hash()的结果。正确做法是先定义一个pwd_context单例然后在创建用户时统一调用from passlib.context import CryptContext pwd_context CryptContext(schemes[bcrypt], deprecatedauto) def hash_password(password: str) - str: return pwd_context.hash(password) def verify_password(plain_password: str, hashed_password: str) - bool: return pwd_context.verify(plain_password, hashed_password)这样在/register里写入的是hash_password(payload.password)在/login里调用verify_password(plain_password, db_user.hashed_password)。1.2 CryptContext 使用的坑Python 3.12 与 bcrypt 版本还有一个时代性问题bcrypt到 4.x 之后对 72 字节密码截断处理以及某些 passlib 版本在 Python 3.12 环境会出现AttributeError: module bcrypt has no attribute __about__的告警甚至直接抛错。这块的处理我建议直接升级并锁定版本组合实测稳定的是passlib1.7.4 bcrypt4.0.1如果你用的是bcrypt4.1可能得给 passlib 打补丁。与其折腾补丁不如在测试里做一次显式的单测锁基线def test_hash_verify_roundtrip(): plain MyS3cret! hashed hash_password(plain) assert hashed ! plain assert verify_password(plain, hashed) is True这条用例如果挂了后面所有接口测试都别跑了。我每次写项目都是把它放在测试文件的最上面用pytest -k hash单独跑一遍作为冒烟测试。1.3 bcrypt 盐值随机不要断言哈希值相等继续踩坑有人写测试时断言 同一个密码哈希之后结果应该一样这是错误预期。bcrypt 每次都会生成随机盐所以hash_password(abc)两次的结果必然不同。正确断言只验证能校验通过就好。密码哈希相关测试还有一个隐蔽点数据库里的hashed_password字段长度。bcrypt 哈希长度是60个字符如果你的 ORM 模型字段定义成String(50)写入时会被截断登录时怎么verify都是False。这类字段长度问题在 SQLAlchemy 里不报错只静默截断我吃过一次亏后现在所有密码字段一律String(128)起步。2. 异步报错TestClient 测不出异步全貌异步测试又频繁报事件循环冲突FastAPI 最大的特点就是异步支持但测试环境的异步和接口内部的异步经常打架。常遇到的报错有两类一类是在事件循环中运行同步代码导致阻塞另一类是 pytest 里pytest.mark.asyncio装饰了却不生效。2.1 pytest 不认 pytest.mark.asyncio测试悄悄跳过先排除一个最基础的坑pytest 不是天然支持异步函数它需要一个插件。很多人pip install pytest就开写然后发现加了pytest.mark.asyncio的测试函数执行时直接被跳过还看不到任何提示。原因就是没装pytest-asyncio。安装后还要在pyproject.toml或pytest.ini里声明配置[pytest] asyncio_mode autoauto模式的意思是不用给每个测试函数手动加装饰器凡是async def test_xxx就自动按异步测试处理。这个配置省了很多事也避免有人忘加装饰器导致测试假通过。2.2 用 httpx.AsyncClient 而不是 TestClient能少踩一半坑不少人用fastapi.testclient.TestClient测试异步接口但它底层是httpx的同步封装直白说它是另起一个线程跑 ASGI 应用。如果你的接口内部有async数据库驱动比如asyncpg、aiomysqlTestClient 经常出现Connection closed或Event loop is closed。我的经验是纯同步项目用 TestClient 问题不大一旦项目里掺了异步数据库或异步缓存直接用httpx.AsyncClient配合asgi-lifespan来测。核心代码如下import asyncio import pytest import httpx from asgi_lifespan import LifespanManager pytest.mark.asyncio async def test_login_success(): async with LifespanManager(app): async with httpx.AsyncClient( transporthttpx.ASGITransport(appapp), base_urlhttp://test, ) as client: resp await client.post( /login, json{username: admin, password: admin123}, ) assert resp.status_code 200LifespanManager的作用是触发 FastAPI 的startup/shutdown事件。很多人测试异步接口一报错就怀疑代码其实可能只是懒加载的启动事件没执行。我用asgi-lifespan之前常碰到ASGI callable returned without sending start message加了这个才让我想起来项目里有个FastAPI(app, on_startup[...])要跑。2.3 异步 fixtures 里频繁 open/close 连接最终事件循环被关另一个容易踩的是event_loop关闭策略冲突。pytest-asyncio 在不同版本里默认作用域是function也就是说每个测试函数都会创建新的事件循环如果你在模块级别的 fixture 里开过一次连接池就会在测试之间产生Event loop is closed。最佳实践是让数据库连接池和事件循环绑定同一个作用域。在 conftest 里这样写pytest_asyncio.fixture(scopesession, autouseTrue) async def db_engine(): engine create_async_engine(postgresqlasyncpg://...) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield engine await engine.dispose()注意这里的scopesession。如果你为了隔离数据测试里用session级别的引擎 函数级别的事务回滚就需要一个更精细的设计在事务外层包savepoint每个测试回滚到SAVEPOINT。我一个小项目曾经直接在测试里 truncate 表后来发现多个异步测试并发跑彼此把对方的数据删了最后不得不用 SQLAlchemy 的join_transaction_modecreate_savepoint。from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession from sqlalchemy.orm import sessionmaker pytest_asyncio.fixture async def db_session(db_engine): connection await db_engine.connect() trans await connection.begin() session AsyncSession( bindconnection, join_transaction_modecreate_savepoint, ) yield session await session.close() await trans.rollback() await connection.close()这套配置写明白之后异步报错的大头就解决了。剩下的都是细节比如async with忘写、await漏加这些只能靠代码审查和 type checker 兜底。3. 认证失败依赖注入被覆盖了测试却还在用真实依赖认证相关接口的测试写起来其实不难难在哪里难在测试环境里你不想真的要一个 token但代码里到处调用get_current_user这样的依赖。FastAPI 给了app.dependency_overrides这个口子但很多人的用法只覆盖了一个接口里的依赖其他接口还是走的旧逻辑导致认证老是失败。3.1 覆盖依赖后scope 要对应上假设你的接口依赖长这样async def get_current_user( token: str Depends(oauth2_scheme), db: AsyncSession Depends(get_db), ): # 解析 token查用户 ...测试里如果你只覆盖了get_current_user但其他依赖还没动认证依旧失败。正确姿势是把get_db和get_current_user一起打进 overrides 字典async def override_get_db(): async with TestSessionLocal() as session: yield session async def override_get_current_user(): return User( id1, usernametest_user, hashed_passwordfakehash..., is_activeTrue, ) app.dependency_overrides[get_db] override_get_db app.dependency_overrides[get_current_user] override_get_current_user我见过一种奇怪的现象一个接口测试过了另一个接口测试却 401。原因就是第二个接口里的依赖不是同一个函数对象。比如你写了两遍# router_a.py def get_me(user Depends(get_current_user)): ... # router_b.py def get_me_other(user Depends(get_current_user)): ...看起来一样但 FastAPI 按函数签名和对象做匹配。如果某处你顺手from xxx import get_current_user as get_me只要函数对象是同一个覆盖就不会失效。问题往往出现在你重新包装了一层def fake_auth(): return override_get_current_user()这种间接引用会导致 overrides 匹配不上最后还得回头改成直接覆盖原函数。所以我的建议是在 conftest 里统一定义所有需要覆盖的依赖函数并在 fixtures 里显式声明 overrides所有测试共享。3.2 401 和 403 的语义安全配置直接决定了测试断言认证接口测试里有个细节状态码断言究竟是401未认证还是403无权限。很多人断言resp.status_code 401但项目里配置了HTTPBearer(auto_errorFalse)后缺失 token 时 FastAPI 返回的可能不是预期状态。看一下HTTPBearer的行为auto_errorTrue默认无 token 直接返回 403auto_errorFalse无 token 时返回None进入你的业务逻辑如果你自定义了get_current_user里面写的是找不到 token 就 raise 401那接口返回 401如果用的是默认HTTPBearer抛出来的是 403。这没有对错只是测试断言要跟着项目设计走。我的习惯是项目一开始就统一封装get_current_user里面显式 raiseHTTPException(status_code401, detailNot authenticated)然后所有接口依赖这个封装函数。测试里对未认证请求断言 401对已认证但角色不够的断言 403语义清清楚楚。3.3 测试之间 token 缓存导致的串号还有一个容易忽略的你在 conftest 里实现了一个auth_headersfixture第一次测试获取 token 后缓存到一个模块级变量里想着省事又高效。结果第二个测试如果复用了同样用户名的测试账号token 还是旧的那份。而 JWT 里通常带过期时间和签发时间一旦第一个测试里改了用户角色第二个测试还在用旧 token接口返回 401你查半天代码发现认证根本没坏是缓存串了。我现在的做法是每个测试都现拿 token或者用factory_boy快速造一个固定密码的测试用户每次请求前走一遍登录接口拿新 token。关键测试之间要清理内存里的用户缓存尤其是项目用了 Redis 做 session 的时候pytest.fixture(autouseTrue) def clean_user_cache(): cache.clear() yield cache.clear()3.4 认证测试的完整模板实际项目里我封装的认证测试模板大概长这样你可以直接放进 conftestpytest_asyncio.fixture async def auth_client(db_session, anyio_backend): async with LifespanManager(app): async with httpx.AsyncClient( transporthttpx.ASGITransport(appapp), base_urlhttp://test, ) as client: yield client app.dependency_overrides.clear()用法async def test_protected_route(auth_client): resp await auth_client.get(/api/v1/me) assert resp.status_code 401已登录测试async def test_protected_route_with_token(auth_client): login_resp await auth_client.post( /login, json{username: test_user, password: test_pass} ) token login_resp.json()[access_token] headers {Authorization: fBearer {token}} resp await auth_client.get(/api/v1/me, headersheaders) assert resp.status_code 2004. 一个能直接跑的完整测试项目目录结构、conftest、核心用例上面把三个坑拆完了这里给一个最小可复现的完整项目代码照着建目录就能在本地跑通我是不太信摘抄一段代码就完事的必须给能直接pytest -v通过的项目。4.1 项目目录结构fastapi_pytest_demo/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── models.py │ ├── schemas.py │ ├── security.py │ └── database.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_auth.py │ ├── test_async.py │ └── test_password.py ├── pytest.ini └── requirements.txt4.2 关键文件内容先看database.py用 SQLAlchemy 异步引擎# app/database.py from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker from sqlalchemy.orm import DeclarativeBase DATABASE_URL sqliteaiosqlite:///./test.db engine create_async_engine(DATABASE_URL, echoTrue) AsyncSessionLocal async_sessionmaker(engine, expire_on_commitFalse) class Base(DeclarativeBase): pass async def get_db(): async with AsyncSessionLocal() as session: yield session选sqliteaiosqlite有两个好处不需要额外起数据库服务CI 跑很快能真实触发异步路径不会像纯同步sqlite3那样掩盖问题。团队后来的测试一直用这个组合没出过环境类故障。security.py里放哈希和 token 相关逻辑# app/security.py from datetime import datetime, timedelta, timezone from passlib.context import CryptContext import jwt SECRET_KEY test-secret-key ALGORITHM HS256 ACCESS_TOKEN_EXPIRE_MINUTES 30 pwd_context CryptContext(schemes[bcrypt], deprecatedauto) def hash_password(password: str) - str: return pwd_context.hash(password) def verify_password(plain_password: str, hashed_password: str) - bool: return pwd_context.verify(plain_password, hashed_password) def create_access_token(data: dict) - str: to_encode data.copy() expire datetime.now(timezone.utc) timedelta(minutesACCESS_TOKEN_EXPIRE_MINUTES) to_encode.update({exp: expire}) return jwt.encode(to_encode, SECRET_KEY, algorithmALGORITHM) def decode_token(token: str) - dict: return jwt.decode(token, SECRET_KEY, algorithms[ALGORITHM])models.py定义User# app/models.py from sqlalchemy import String, Boolean from sqlalchemy.orm import Mapped, mapped_column from app.database import Base class User(Base): __tablename__ users id: Mapped[int] mapped_column(primary_keyTrue, indexTrue) username: Mapped[str] mapped_column(String(50), uniqueTrue, indexTrue) hashed_password: Mapped[str] mapped_column(String(128)) is_active: Mapped[bool] mapped_column(Boolean, defaultTrue)注意hashed_password长度我写了128如果你沿用旧项目的String(60)很可能踩坑。main.py是接口层# app/main.py from fastapi import FastAPI, Depends, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db, Base, engine from app.models import User from app.schemas import UserCreate, LoginRequest, TokenResponse from app.security import hash_password, verify_password, create_access_token app FastAPI(titleFastAPI Pytest Demo) security HTTPBearer(auto_errorFalse) async def get_current_user( credentials: HTTPAuthorizationCredentials | None Depends(security), db: AsyncSession Depends(get_db), ): if credentials is None: raise HTTPException(status_code401, detailNot authenticated) token credentials.credentials payload decode_token(token) username payload.get(sub) result await db.execute(select(User).where(User.username username)) user result.scalar_one_or_none() if user is None: raise HTTPException(status_code401, detailUser not found) return user app.on_event(startup) async def init_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) app.post(/register, status_code201) async def register(payload: UserCreate, db: AsyncSession Depends(get_db)): result await db.execute(select(User).where(User.username payload.username)) if result.scalar_one_or_none(): raise HTTPException(status_code400, detailUsername already registered) user User(usernamepayload.username, hashed_passwordhash_password(payload.password)) db.add(user) await db.commit() return {id: user.id, username: user.username} app.post(/login, response_modelTokenResponse) async def login(payload: LoginRequest, db: AsyncSession Depends(get_db)): result await db.execute(select(User).where(User.username payload.username)) user result.scalar_one_or_none() if not user or not verify_password(payload.password, user.hashed_password): raise HTTPException(status_code401, detailIncorrect username or password) token create_access_token({sub: user.username}) return {access_token: token, token_type: bearer} app.get(/me) async def me(current_user: User Depends(get_current_user)): return {id: current_user.id, username: current_user.username}4.3 conftest 的完整配置这是整篇最核心的部分测试环境能不能跑得稳全靠它# tests/conftest.py import asyncio import pytest import pytest_asyncio import httpx from asgi_lifespan import LifespanManager from app.main import app from app.database import get_db from app.security import hash_password from app.models import User from app.database import Base, engine # 覆盖 get_db使用测试专用事务封装 from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker TEST_DATABASE_URL sqliteaiosqlite:///./test.db pytest_asyncio.fixture(scopesession) async def db_engine(): 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) pytest_asyncio.fixture async def db_session(db_engine): connection await db_engine.connect() trans await connection.begin() session AsyncSession(bindconnection, join_transaction_modecreate_savepoint) yield session await session.close() await trans.rollback() await connection.close() async def override_get_db(): connection await engine.connect() trans await connection.begin() session AsyncSession(bindconnection, join_transaction_modecreate_savepoint) yield session await session.close() await trans.rollback() await connection.close() pytest_asyncio.fixture async def client(): app.dependency_overrides[get_db] override_get_db async with LifespanManager(app): async with httpx.AsyncClient( transporthttpx.ASGITransport(appapp), base_urlhttp://test, ) as c: yield c app.dependency_overrides.clear() pytest_asyncio.fixture async def created_user(db_session): user User( usernameexisting_user, hashed_passwordhash_password(correct-password), is_activeTrue, ) db_session.add(user) await db_session.commit() return user一个小提醒override_get_db和db_sessionfixture 里的事务嵌套逻辑要保持一致否则测试里的数据准备接口和测试接口会出现看不到对方 commit 的数据这类灵异问题。4.4 三个核心测试文件test_password.py验证哈希往返和登录失败场景# tests/test_password.py from app.security import hash_password, verify_password def test_hash_verify_roundtrip(): hashed hash_password(my-pass) assert hashed ! my-pass assert verify_password(my-pass, hashed) is True assert verify_password(wrong-pass, hashed) is False def test_hash_is_random(): h1 hash_password(same-pass) h2 hash_password(same-pass) assert h1 ! h2test_async.py验证异步接口和生命周期# tests/test_async.py import pytest pytest.mark.asyncio async def test_register_login_me_flow(client): reg await client.post(/register, json{ username: alice, password: alice123, }) assert reg.status_code 201 login await client.post(/login, json{ username: alice, password: alice123, }) assert login.status_code 200 token login.json()[access_token] me await client.get(/me, headers{Authorization: fBearer {token}}) assert me.status_code 200 assert me.json()[username] alicetest_auth.py验证缺少 token、错误密码、以及无效 token# tests/test_auth.py import pytest pytest.mark.asyncio async def test_me_without_token(client): resp await client.get(/me) assert resp.status_code 401 pytest.mark.asyncio async def test_me_with_invalid_token(client): resp await client.get(/me, headers{Authorization: Bearer invalid-token}) assert resp.status_code 401 pytest.mark.asyncio async def test_login_wrong_password(client, created_user): resp await client.post(/login, json{ username: existing_user, password: wrong-password, }) assert resp.status_code 4014.5 跑测试时记得设置 asyncio_modepytest.ini文件如下[pytest] asyncio_mode auto testpaths tests我用asyncio_mode auto而不是手动给每个函数加装饰器理由前面讲过少一个忘加装饰器导致跳过的隐患。如果是老版本 pytest-asyncio可能还需要filterwarnings ignore::DeprecationWarning新版本基本不需要了。5. 聊点测试之外的工程经验顺序、隔离、缓存清理三个大坑讲完了还想分享几点我在实际项目中总结的测试工程经验它可能不会直接体现在某条用例外面但决定了你后面维护测试的体验。5.1 测试执行顺序不靠谱别依赖先注册再登录的顺序pytest 默认按文件顺序执行但千万别写那种先跑注册用例再跑登录用例的隐式顺序。哪怕同一条用例test_auth.py 跑完注册了新用户test_password.py 里可能因为共享数据库导致用户已存在。我的建议是每个测试的数据独立用完即走测试内部如果依赖用户存在就通过 fixture 造出来不要通过调用接口去顺带生产数据。5.2 认证测试要主动清理 HTTP 客户端连接异步测试里httpx.AsyncClient如果不关闭会留下未关闭的连接告警有时还会拖慢整个测试套件。上面 conftest 里async with保证了关闭但如果有人习惯直接client httpx.AsyncClient(...)就会踩到 Unclosed connection 的坑。我一般会在pytest_sessionfinish钩子里主动做一次资源回收pytest.fixture(autouseTrue) def close_async_client(): yield for task in asyncio.all_tasks(): task.cancel()这个属于防御性编程有了心里踏实很多。5.3 环境变量和真实密钥的隔离FastAPI 测试里认证失败还有一种隐藏原因SECRET_KEY在测试环境和生产环境不一致。如果你在 conftest 里 import 真实应用配置测试时用的还是生产密钥签名出来的 token 和测试环境decode时用的密钥不同必然 401。我建议测试的 conftest 里主动设置os.environ[SECRET_KEY] test-secret-key并在测试启动前完成保证测试环境完全可控。5.4 别忽略 pytest.ini 的 testpaths最后这个建议听起来无关紧要但它能避免你在 CI 上跑测试时误把node_modules或migrations目录里的文件当成测试用例执行。testpaths tests可以少很多莫名其妙的问题。6. 把三个坑串起来一整轮测试的思维链最后我总结一下遇到FastAPI 测试三大坑时完整的排查思维链。这比任何单一修复都管用第一先明确是数据层假象还是依赖注入错位。密码相关、有用户数据的失败优先检查哈希字段是否完整、长度是否够、verify 参数顺序对不对。先跑一次test_hash_verify_roundtrip过了就说明哈希层没问题往数据库或业务逻辑找。第二检测异步链路是否真实顺畅。把 fastapi 里所有Depends(get_db)替换成测试 fixture 后如果出现连接被关闭或Event loop is closed检查 lifespans 和连接池作用域必要时用sqliteaiosqlite简化环境。第三认证问题先检查 overrides 是否生效。如果测试里头带了正确的 bearer token 还 401去 conftest 查看是否把app.dependency_overrides清空了或者覆盖的依赖函数和接口实际引用的不是同一个对象。这三层检查一过基本能把 FastAPI 测试三大坑全部扫平。代码都在上面直接跑一遍再对照你项目里的报错信息很快就能找到症结。