登录接口自动化测试:会话、断言、数据隔离与超时
接口自动化刚开始写时,很容易把用例简化成“发一个请求,再断言状态码等于200”。这种写法能证明接口在当前输入下返回了成功响应,却回答不了更多问题:Token能不能真的访问受保护资源,错误密码有没有稳定的业务错误码,退出后旧Token是否立即失效,服务迟迟不响应时客户端会不会一直等待。
本文启动一个本地HTTP接口服务,用Pytest和Requests完成注册、登录、鉴权、退出和超时验证。测试通过真实TCP连接访问接口,不依赖外部公共服务,也不使用框架内部的测试客户端,重点放在请求组织、断言层次和数据隔离上。
一、先明确登录接口要验证什么
登录成功并不等于整条鉴权链路正确。一个较完整的验证过程至少包含五步:创建账号、登录获取Token、携带Token访问资料接口、退出登录、再次使用原Token访问。
这五步分别验证不同状态:
注册成功返回
201;登录成功返回
200和Bearer Token;有效Token可以访问资料接口;
退出成功返回
204,响应体为空;原Token再次访问资料接口时返回
401。
如果只检查登录接口本身的200,即使服务端生成的Token无法使用,或者退出接口没有真正撤销Token,用例依然会显示通过。因此,登录测试的核心不是一个请求,而是Token从创建、生效到失效的状态变化。
二、环境与项目结构
本次运行环境如下:
| 项目 | 版本 |
|---|---|
| 操作系统 | Windows 10 |
| Python | 3.12.13 |
| pytest | 9.1.1 |
| Requests | 2.34.2 |
| FastAPI | 0.141.1 |
| Uvicorn | 0.52.3 |
第三方依赖安装在本篇独立的.venv中。项目结构如下:
showcase/ ├─ src/ │ ├─ auth_api/ │ │ ├─ app.py │ │ └─ store.py │ └─ api_client.py ├─ tests/test_auth_api.py ├─ examples/test_wrong_status_expectation.py ├─ tools/capture_responses.py ├─ outputs/response_samples.json ├─ conftest.py ├─ pyproject.toml └─ requirements.txt
auth_api提供本地登录接口,api_client.py封装Requests会话和默认超时,conftest.py负责启动服务、创建账号和清理数据。这样测试文件只需要描述请求行为和预期结果。
本地服务使用内存保存账号和Token,密码没有加密。它用于验证HTTP测试组织方式,不是生产鉴权实现。真实系统还需要密码哈希、Token签名与过期、持久化、限流、审计和密钥管理。
三、为什么要封装Requests Session
多个请求访问同一服务时,可以使用requests.Session保存公共请求头并复用底层连接。本文将基础地址、Session和超时放进一个客户端对象:
class AuthApiClient: def __init__( self, base_url: str, *, timeout: tuple[float, float] = (1.0, 1.0), ) -> None: self.base_url = base_url.rstrip("/") self.timeout = timeout self.session = requests.Session() def login(self, username: str, password: str) -> requests.Response: return self._request( "POST", "/api/login", json={"username": username, "password": password}, ) def use_token(self, token: str) -> None: self.session.headers["Authorization"] = f"Bearer {token}"封装的目的不是隐藏所有Requests细节,而是统一容易遗漏的公共配置。例如所有请求都通过_request()补上超时,避免某条新用例忘记设置。登录成功后,use_token()把Authorization头写入当前Session,后续资料、退出和删号请求会自动携带相同Token。
Requests默认不会主动超时。如果接口没有返回数据,未设置timeout的请求可能等待很久。接口测试通常不应该把“无限等待”当作默认行为,因此这里从一开始就给出连接超时和读取超时。
四、用fixture启动服务并隔离账号数据
测试会话开始时,api_base_url选择一个本机空闲端口,在后台线程中启动Uvicorn,并轮询/health确认服务已经可用。整个测试集共用这个服务,账号数据则按测试隔离:
@pytest.fixture def registered_user(api_client: AuthApiClient) -> Iterator[ApiUser]: user = ApiUser( username=f"api_user_{uuid4().hex[:8]}", password="safe-pass-2026", ) response = api_client.register(user.username, user.password) assert response.status_code == 201 yield user login_response = api_client.login(user.username, user.password) if login_response.status_code == 200: api_client.use_token(login_response.json()["access_token"]) api_client.delete_current_user()每条需要账号的测试都会生成不同用户名,结束后重新登录并删除账号。这样做比所有用例共用test_user更稳定:并发执行时不容易产生用户名冲突,某条用例修改会话状态也不会污染其他用例。
清理逻辑放在yield之后,即使断言失败,已经建立的fixture仍会进入teardown。与此同时,删号接口会撤销该账号关联的全部Token,避免只删除用户记录却留下可用会话。
五、成功用例要证明Token可用
登录成功测试不只检查状态码,还检查Token格式、类型、有效时间,并立即访问受保护接口:
def test_login_returns_a_usable_bearer_token( api_client: AuthApiClient, registered_user: ApiUser, ) -> None: login_response = api_client.login( registered_user.username, registered_user.password, ) assert login_response.status_code == 200 body = login_response.json() assert body["token_type"] == "bearer" assert body["expires_in"] == 3600 assert TOKEN_PATTERN.fullmatch(body["access_token"]) api_client.use_token(body["access_token"]) profile_response = api_client.profile() assert profile_response.status_code == 200 assert profile_response.json() == { "username": registered_user.username, "status": "active", }这里没有把随机Token硬编码成某个固定字符串,只检查它满足当前约定的格式。随后用这个Token获取用户资料,能够进一步证明Token不是“看起来像Token的无效字段”。
本次采集到的响应已经隐藏真实Token:
接口断言可以分成几个层次:状态码判断请求结果类别,响应体确认字段和业务错误码,响应头检查鉴权约定,后续请求验证状态变化,超时断言处理无响应情况。
六、负向用例不能只换一组密码
错误密码和密码大小写变化都应返回401,同时携带WWW-Authenticate: Bearer响应头,并返回稳定的业务错误码:
@pytest.mark.parametrize( ("password", "expected_code"), [ pytest.param("wrong-pass", "INVALID_CREDENTIALS", id="wrong-password"), pytest.param("SAFE-PASS-2026", "INVALID_CREDENTIALS", id="case-sensitive"), ], ) def test_login_rejects_invalid_passwords( api_client, registered_user, password, expected_code, ) -> None: response = api_client.login(registered_user.username, password) assert response.status_code == 401 assert response.headers["WWW-Authenticate"] == "Bearer" assert response.json()["detail"]["code"] == expected_code除此之外,本文还验证了三类不同问题:请求体缺少密码返回422,重复注册返回409,未携带Token访问资料接口返回401。这些状态码不能混为一谈:字段校验失败、资源冲突和鉴权失败发生在不同阶段,也应该有可区分的响应。
负向测试中不宜一开始就调用response.raise_for_status()。它会把4xx响应转换为HTTPError,如果用例只断言“抛出了HTTPError”,就无法确认接口究竟返回了401、409还是422,也会漏掉具体错误体。先检查约定的响应,再决定是否需要把意外状态转换为异常,定位信息会更完整。
七、一次真实的错误预期:401还是422
最初很容易把“缺少密码”理解为登录失败,并把预期状态码写成401:
response = api_client.session.post( f"{api_client.base_url}/api/login", json={"username": "api_user"}, timeout=api_client.timeout, ) assert response.status_code == 401单独运行后,Pytest给出的结果是:
E assert 422 == 401 E + where 422 = <Response [422]>.status_code FAILED examples/test_wrong_status_expectation.py 1 failed in 0.73s
原因是请求体连password字段都没有,FastAPI先执行请求模型校验,在进入账号认证逻辑之前就返回422。只有请求结构完整、用户名或密码内容不正确时,才进入登录逻辑并返回401。
因此修复方式不是把接口强行改成401,而是先确认接口契约:如果约定由框架统一处理字段缺失,用例就应该断言422,并继续检查错误位置是["body", "password"]、错误类型是missing。红色结果只说明实际结果和测试预期不一致,最终修改哪一边要依据接口约定判断。
八、退出登录后要继续使用原Token
退出接口返回204只能证明请求被接受,不能证明Token真的失效。本文在同一个Session中退出,再用原请求头访问资料接口:
def test_logout_revokes_the_current_token( authenticated_client: AuthApiClient, ) -> None: logout_response = authenticated_client.logout() profile_response = authenticated_client.profile() assert logout_response.status_code == 204 assert logout_response.content == b"" assert profile_response.status_code == 401 assert profile_response.json()["detail"]["code"] == "TOKEN_INVALID"
这里刻意没有删除Session中的Authorization头,因为验证目标就是确认服务端已撤销Token。若客户端先清空请求头,再访问得到401,只能证明“没有Token不能访问”,无法证明原Token已经失效。
九、用慢响应验证客户端超时
本地服务提供一个延迟200毫秒返回的接口,测试把连接超时设为100毫秒、读取超时设为50毫秒:
with pytest.raises(requests.Timeout): api_client.get( "/api/slow", params={"delay_ms": 200}, timeout=(0.1, 0.05), )二元组中的第一个值控制建立连接,第二个值控制等待响应数据。本次服务运行在本机,连接很快建立,随后因为读取阶段超过50毫秒而抛出requests.Timeout。
需要注意,Requests的读取超时不是整个响应下载的绝对总时长,而是底层连接在指定时间内没有收到数据时触发。生产项目中的超时值应根据服务目标、网络环境和重试策略确定,本文使用较短时间只是为了稳定复现超时路径。
十、几个常见问题
1. 所有接口都只断言状态码
同样返回200,响应可能缺字段、字段类型错误或Token不可用。成功接口至少检查关键响应字段,并在可能时继续执行一次依赖该结果的请求。
2. 多条用例共用固定账号
固定账号在并发、重复运行和失败重试时容易发生状态冲突。可以给测试数据增加唯一后缀,并在fixture中建立与清理。如果连接真实数据库,还要准备定期回收机制处理进程异常退出留下的数据。
3. 把Token写进代码或配置文件
本文Token由接口动态生成,输出样例也已经脱敏。真实Token、Cookie和账号凭据不能提交到Git仓库,也不应该直接出现在截图和日志中。
4. 负向用例只判断抛出了异常
raise_for_status()适合业务代码快速阻止错误响应继续传播,但接口契约测试应该明确检查状态码、响应头和错误体,否则不同错误可能被压缩成同一种HTTPError。
5. 为了让用例通过而放宽超时
超时偶发不一定意味着阈值太小,也可能是服务变慢、连接未释放或运行环境异常。调整数值前应先区分连接超时和读取超时,再结合接口耗时分布判断。
十一、小结与思考
Pytest和Requests组合起来并不复杂,真正影响接口测试质量的是用例是否覆盖了完整状态链路。本文从注册开始,验证登录生成Token、Token访问资料、退出撤销Token以及慢响应触发超时;正常测试集共收集8条,全部通过。
接口断言也需要分层:状态码说明结果类别,响应体承载字段和业务错误码,响应头体现协议约定,后续请求确认状态变化,超时处理负责不可用路径。把测试数据创建与清理放进fixture,并让每条测试拥有独立账号,回归次数增加后仍能保持可重复运行。
本文没有覆盖Token真实签名、过期刷新、权限角色、并发登录、限流和数据库事务。这些属于更完整鉴权系统的验证范围,可以在当前请求客户端和fixture结构上继续扩展,但不能从当前8条用例推导整个登录系统已经得到完整覆盖。
参考资料
Requests Quickstart
Requests Advanced Usage
FastAPI Security First Steps
FastAPI Security Tools
本文代码
GitHub:005-api-testing-with-pytest
