简介:围绕接口自动化测试中登录鉴权与 token 传递这一常见环节,这份 docx 文档面向已具备 Python 基础、希望从手工测试转向接口自动化的测试人员与开发初学者,梳理了从登录接口获取 token 并复用到业务接口的完整思路。压缩包共 1 个 docx 文件,约 1.29MB,以图文笔记形式组织,便于按步骤阅读和本地实践。内容以 requests 库为工具,覆盖安装与导入验证、POST 登录请求的参数与请求头设置、响应 JSON 的反序列化、按字典层级提取 token,以及将 token 作为函数返回值传给 infoGet 接口的写法,同时对 token 不一致导致请求失败的排错场景作了提示。文档还穿插了函数返回值与参数传递的说明,帮助读者理解接口间的数据依赖。目前已有 6665 人学习,适合需要补齐鉴权处理、会话参数复用与基础接口串联能力的读者参考。
1. 从“登录了 N 次还是 502”说起:接口自动化测试里 token 的正确获取方式
写接口自动化测试的人,迟早会撞上一次“token 明明是登录拿的,为什么下一个接口还是 502”。这个项目的起点很朴素:用 Python 的 requests 打一个 login 接口,从返回的 JSON 里把 token 抠出来,再把它当作参数喂给 infoGet、isSoonExpire 这些需要鉴权的业务接口,最后用 unittest 把整条链路整理成可重复执行的用例。它没有花哨的框架,走的是一条最真实的演进路线:单函数调通、多接口复用、登录只做一次、断言补齐、session 兜底,最后才谈得上进 CI。适合刚接触接口自动化、手里正好有一批需要鉴权接口要覆盖的测试同学,也适合想把散落脚本收成一套可维护资产的人。
2. 工程骨架:requests + unittest 的最小可跑结构
动手之前先把“能跑起来”这件事确定下来。这套东西的依赖极少,核心就一个 requests 负责发请求,unittest 负责组织和执行用例,其余全是标准库。骨架搭对了,后面加接口只是复制粘贴再改改参数;骨架搭错了,等到用例涨到三五十个,改一个域名要动十几个文件,那种血泪经验谁都躲不过。
2.1 依赖安装与 import 自检
先装库。原始项目是在 Python 2 环境下写的,代码里还是print getToken()这种写法,现在统一按 Python 3 来跑,语法上要注意把 print 改成函数调用,其余逻辑基本不用动。
# 安装/升级 requests,-U 保证拿到较新版本 pip install -U requests # 装完立刻自检,能打印出版本号才算真的装进了当前解释器 python -c "import requests; print(requests.__version__)"这两条命令看着简单,翻车点却不少。最常见的是机器上装了多个 Python,pip和python指向的不是同一个环境,装完 import 报 ModuleNotFoundError。遇到这种情况不要急着重装,先用python -c "import sys; print(sys.executable)"确认解释器路径,再用python -m pip install -U requests让 pip 跟着这个解释器走,问题基本就解决了。后面所有命令都建议统一用python -m的形式,能少掉一大半环境错乱的坑。
2.2 目录结构与各文件职责
原始代码把登录、取 token、业务请求全塞在一个 weke.py 里,跑通没问题,但一旦接口数量上去,函数之间互相调用会迅速变成一团乱麻。我一般会按职责拆成四块,文件不多,边界清楚:
| 文件 | 职责 | 改动频率 |
|---|---|---|
config.py | 域名、公共请求头、超时、账号密码 | 换环境时改 |
api_client.py | 封装 post/get,统一处理超时和 JSON 解析 | 很少改 |
token_store.py | token 落盘与读取,保证登录只做一次 | 很少改 |
test_interface.py | unittest 用例,只关心“调哪个接口、断言什么” | 加接口时改 |
这么拆的好处是加一个新接口时,测试同学只需要在test_interface.py里写一个test_xxx方法,请求怎么发、token 从哪来全都不用操心。反之如果全堆在一个文件里,新人接手第一件事就是通读八百行,读完还是不知道 token 到底缓存在哪。
2.3 公共参数收拢:base_url、headers、timeout
把域名和请求头写死在每个函数里,是这类脚本最常见的坏味道。原始代码里url='https://.../v4/login'出现了一次,infoGet里又拼了一次,isSoonExpire里干脆写了个完整 URL,三处不一致的时候排查起来非常难受。
# config.py # 公共参数集中管理:换测试环境只改这一处 BASE_URL = "https://api.park-demo.test/v4/" # 结尾带斜杠,后面直接拼接口名 TIMEOUT = 5 # 秒,接口慢就往上调 HEADERS = { "Content-Type": "application/json;charset=UTF-8", "DemoPark-Client-Source": "DemoParkAPIClientWeb", # 网关要求的来源标识,漏了会 400 } # 登录用的固定账号,真实项目里应改成从环境变量读取 LOGIN_PAYLOAD = { "username": "auto_api", "password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", "role": 2, }BASE_URL结尾带斜杠、接口名不带前导斜杠,是为了拼接时不用再判断;TIMEOUT单独抽出来是因为它经常要按环境调整,压测环境响应慢,5 秒经常不够;HEADERS里的来源标识字段是网关层做客户端校验用的,少一个就会返回 400 而不是 401,这个错很容易被误判成“token 有问题”。密码那串是 MD5 摘要值不是明文,真实项目里建议用环境变量注入,别提交到仓库。
3. 核心链路拆解:从 login 响应里把 token 抠出来
这一段是整个项目的骨架动作。登录接口返回的是一坨 JSON,token 藏在data.token下面,能不能稳稳地取出来、能不能正确地传给下一个接口,决定了后面所有用例的成败。很多人的做法是先手动调一次接口,把 token 复制出来贴进代码里当常量,短期能跑,过几个小时就 502 了。
3.1 先打印原始 JSON,别急着取值
第一步永远是把完整响应打出来看,别上来就写['data']['token']。因为你根本不知道接口返回的结构长什么样,字段名可能叫token,也可能叫access_token,还可能套在两层data里面。
# -*- coding:utf-8 -*- import requests def get_token_raw(): """登录并返回完整 JSON,用于第一次探查响应结构""" resp = requests.post( url='https://api.park-demo.test/v4/login', json={"username": "auto_api", "password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", "role": 2}, headers={'Content-Type': 'application/json;charset=UTF-8', 'DemoPark-Client-Source': 'DemoParkAPIClientWeb'}, timeout=5) return resp.json() if __name__ == '__main__': print(get_token_raw())requests.post的json=参数会自动把字典序列化成 JSON 字符串并顺手设上正确的 Content-Type,比手动data=json.dumps(...)省事且不容易漏。timeout=5是必须的,不设的话接口挂住时脚本会一直卡着,CI 上就表现为“任务无响应”。返回值经过resp.json()反序列化后是 Python 的 dict,形如{'status': 0, 'msg': '', 'data': {'token': 'xxx', 'id': 600192, 'name': '自动化测试'}},结构看清楚之后再往下改。
3.2 字典取值与 getToken() 的返回值设计
看清楚了结构,取值就一行的事。但这里有个值得想清楚的设计问题:getToken()这个函数到底该返回什么?返回整个 JSON 让人自己去取,还是直接返回 token 字符串?答案取决于调用方需要什么——如果所有调用方都只需要 token 那一个字符串,就让它直接返回字符串。
# -*- coding:utf-8 -*- import requests def get_token(): """登录成功后直接返回 token 字符串,供其他接口调用""" resp = requests.post( url='https://api.park-demo.test/v4/login', json={"username": "auto_api", "password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", "role": 2}, headers={'Content-Type': 'application/json;charset=UTF-8', 'DemoPark-Client-Source': 'DemoParkAPIClientWeb'}, timeout=5) return resp.json()['data']['token'] # 逐层取到 data 下的 token if __name__ == '__main__': print(get_token())返回值在这套代码里承担的角色,就是“把上一个函数的产出交给下一个函数当参数”。把 token 收窄成字符串返回,调用方写成json={"token": get_token()}就完事了,不会出现有人从resp.json()['data']里顺手拿走别的字段、导致耦合变重的问题。代价是字段缺失时会直接抛 KeyError,所以在这类脚本里我一般会加一层保护:data = resp.json().get('data') or {},取不到时给出明确报错,而不是抛一个看不出上下文的 KeyError。
3.3 infoGet 带上 token 跑通闭环
拿到 token 只是半步,真正的验证是下一个接口认不认。infoGet 接口要求的参数就是这个 token,而且必须和登录返回的一模一样,否则服务端直接返回 502 并提示 token 无效。
# -*- coding:utf-8 -*- import requests def get_token(): resp = requests.post( url='https://api.park-demo.test/v4/login', json={"username": "auto_api", "password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", "role": 2}, headers={'Content-Type': 'application/json;charset=UTF-8', 'DemoPark-Client-Source': 'DemoParkAPIClientWeb'}, timeout=5) return resp.json()['data']['token'] def info_get(): """把 token 作为请求体参数传给业务接口""" resp = requests.post( url='https://api.park-demo.test/v4/infoGet', json={"token": get_token()}, # 关键:token 从 login 的返回值直接流入 headers={'Content-Type': 'application/json;charset=UTF-8', 'DemoPark-Client-Source': 'DemoParkAPIClientWeb'}, timeout=5) print(resp.json()) if __name__ == '__main__': info_get()跑通之后能看到返回体里有username、name、park_code这些字段,说明 token 被正确识别了。这一步的意义在于确认了 token 的传递方式是放在请求体里,而不是 Header 里的 Authorization——这两种风格在真实项目里都很常见,一定要以实际接口文档或抓包结果为准,猜错的话排查方向会完全跑偏。
3.4 加第三个接口,重复登录的问题暴露出来
按同样的套路再加一个 isSoonExpire 接口,代码几乎照抄,只换 URL 和参数。加到第三个的时候,问题就很明显了:每个函数里都调了一次get_token(),意味着每跑一个用例就登录一次。
def is_soon_expire(): resp = requests.post( url='https://api.park-demo.test/v4/isSoonExpire', json={"token": get_token()}, # 又一次登录 headers={'Content-Type': 'application/json;charset=UTF-8', 'DemoPark-Client-Source': 'DemoParkAPIClientWeb'}, timeout=5) print(resp.json())两三个用例时看不出代价,等到二十个接口都要 token,就是二十次登录。服务端通常会做登录频控,短时间内多次登录要么触发风控、要么把前一个 token 顶掉,表现就是“前面几个用例过了,后面突然开始 502”。这不是玄学,是登录次数太多导致旧 token 失效。下一步的重构就是要解决它。
4. 从脚本到用例:unittest 重构、断言与登录只做一次
散装函数跑到七八个之后,就必须换成测试框架组织了,否则没法批量执行、没法统计结果、也没法在 CI 里给出退出码。这一章做的事:把请求参数收进 setUp,把判定逻辑换成断言,把登录收敛成一次。
4.1 setUp/tearDown 收拢公共资源
unittest 的 setUp 在每个用例执行前跑一次,tearDown 在用例执行后跑一次,天然适合放域名、请求头、超时这类每次都要用的东西。
# -*- coding:utf-8 -*- import unittest import requests class InterfaceTest(unittest.TestCase): def setUp(self): """每个用例执行前都会走一遍,放公共参数""" self.url = 'https://api.park-demo.test/v4/' self.headers = {'Content-Type': 'application/json;charset=UTF-8', 'DemoPark-Client-Source': 'DemoParkAPIClientWeb'} self.timeout = 5 def tearDown(self): """每个用例执行后走一遍,这里留空,需要清理数据时再填""" pass def get_token(self): resp = requests.post( url=self.url + 'login', json={"username": "auto_api", "password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", "role": 2}, headers=self.headers, timeout=self.timeout) return resp.json()['data']['token'] def test_info_get(self): """验证 infoGet 接口返回的用户信息正确""" resp = requests.post(url=self.url + 'infoGet', json={"token": self.get_token()}, headers=self.headers, timeout=self.timeout) print(resp.json()) if __name__ == '__main__': unittest.main(verbosity=2)这里有个细节值得说:self.url + 'infoGet'之所以能拼上,是因为 setUp 里 url 结尾带了斜杠。如果哪天有人把斜杠删了,所有用例会一起 404,错误信息还很难看出原因。我一般会在 setUp 里做一次self.url = self.url.rstrip('/') + '/'的兜底,写完就再不用惦记。unittest.main(verbosity=2)会把每个用例的名字和结果打出来,比默认的点点点友好得多。
4.2 断言该断什么:状态码之外还要断业务字段
没有断言的自动化用例是无效的,这句话值得贴在显示器上。上面那段代码只 print 了响应,就算接口返回业务失败,用例依然显示通过,这种“绿色”比红色更危险。断言不能只断 HTTP 200,因为绝大多数业务接口在参数错误、权限不足时返回的也是 200,真正表示成败的是响应体里的 status 字段。
def test_info_get(self): """验证 infoGet 接口:状态正常且用户身份匹配""" resp = requests.post(url=self.url + 'infoGet', json={"token": self.get_token()}, headers=self.headers, timeout=self.timeout) body = resp.json() self.assertEqual(body['status'], 0) # 业务状态码为 0 代表成功 self.assertEqual(body['data']['username'], 'auto_api') # 返回的是当前登录用户 def test_is_soon_expire(self): """验证 isSoonExpire 接口:能正常返回过期标记""" resp = requests.post(url=self.url + 'isSoonExpire', json={"token": self.get_token()}, headers=self.headers, timeout=self.timeout) body = resp.json() self.assertEqual(body['status'], 0) self.assertEqual(body['data']['expire'], False)断什么由业务决定:状态类字段断固定值,身份类字段断当前登录账号,列表类接口断长度和关键元素,时间类字段断格式或范围。像expire这种布尔字段,硬编码 False 有风险——万一测试账号确实快过期了,用例就会误报,稳妥写法是assertIn(body['data']['expire'], [True, False])只验类型。断言写多写少是个度,原则是“接口坏了必须红、接口正常必须绿”,宁少勿滥。
4.3 登录只做一次:token 落盘与读取
回到 3.4 暴露的问题:两个用例各登录一次,N 个用例登录 N 次。解决办法是把登录产出的 token 存到一个地方,其他用例只读不登。
# -*- coding:utf-8 -*- import os import unittest import requests def base_dir(): """以当前文件所在目录为基准,避免受执行目录影响""" return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'token.md') def get_headers(): return {'Content-Type': 'application/json;charset=UTF-8', 'DemoPark-Client-Source': 'DemoParkAPIClientWeb'} def login(): """只在真正需要刷新时调用,把 token 写入文件""" resp = requests.post( url='https://api.park-demo.test/v4/login', json={"username": "auto_api", "password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", "role": 2}, headers=get_headers(), timeout=5) with open(base_dir(), 'w') as f: f.write(resp.json()['data']['token']) def get_token(): """从文件读取 token,文件不存在时先登录一次""" if not os.path.exists(base_dir()): login() with open(base_dir(), 'r') as f: return f.read() class InterfaceTest(unittest.TestCase): def setUp(self): self.url = 'https://api.park-demo.test/v4/' def test_info_get(self): resp = requests.post(url=self.url + 'infoGet', json={"token": get_token()}, headers=get_headers(), timeout=5) self.assertEqual(resp.json()['status'], 0) def test_is_soon_expire(self): resp = requests.post(url=self.url + 'isSoonExpire', json={"token": get_token()}, headers=get_headers(), timeout=5) self.assertEqual(resp.json()['status'], 0)base_dir()用os.path.dirname(os.path.abspath(__file__))而不是os.getcwd(),是因为前者以文件位置为准,不管你从哪个目录执行都能找到;后者依赖工作目录,在 IDE 里跑正常、到 CI 上就找不到文件,这是很典型的一类环境差异导致的翻车。token.md这个扩展名无所谓,本质是个纯文本缓存文件,记得加进.gitignore,别把 token 提交上去。
4.4 session 场景:登录不返回 token 怎么办
不是所有系统都用 token。有些接口登录成功后响应体里只有一个code: 200,凭证藏在 Set-Cookie 里,后面的请求靠 Cookie 维持会话。这时候取 token 的整套逻辑都用不上,要换成另一种思路。
# -*- coding:utf-8 -*- import requests session = requests.Session() # 同一个实例内部自动维护 cookie def login_by_session(): resp = session.post( url='https://api.park-demo.test/v4/login', json={"username": "auto_api", "password": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"}, headers={'Content-Type': 'application/json;charset=UTF-8'}, timeout=5) print(resp.json()) # 通常只有 code: 200 print(session.cookies.get_dict()) # 关键:拿到服务端下发的 cookie def access_datas(): """复用同一个 session,cookie 自动带上""" resp = session.post( url='https://api.park-demo.test/v4/accessDatas', json={}, headers={'Content-Type': 'application/json;charset=UTF-8'}, timeout=5) print(resp.json())requests.Session()是这一节的核心,它会把登录响应里的 Set-Cookie 自动存下来,后续用同一个 session 发的请求会自动带上 Cookie 头,不需要手工拼。session.cookies.get_dict()用来把 cookie 取成字典,需要跨进程或跨脚本传递时(比如另开一个进程跑业务用例),可以把它序列化后写入文件,再在另一个 session 上用session.cookies.update(saved_dict)灌回去。如果业务接口忘了走 session 而是直接用requests.post,服务端拿不到 cookie 就会把你重定向到登录页,表现为返回 HTML 或者 code 非 200,这个现象非常有迷惑性。
5. 避坑与排查:token 失效、502 与重复登录
这一节把踩过的坑集中列一下,都是现象很唬人、原因很朴素的问题。遇到 502 先别怀疑网络,按下面的顺序过一遍,大部分能当场定位。
5.1 用例跑一半开始 502,日志里登录了好几次
现象:单跑每个用例都能过,批量执行时前几个绿、后面开始红,报 token 无效或 502。原因:每个 test 方法内部都调了一次登录函数,多次登录把前面的 token 顶掉了,服务端只认最后一次。解决:把登录收敛到setUpClass或文件缓存,全局只登录一次;如果业务上确实要求每次重新登录,就把 token 的获取做成带过期时间的缓存,读写时判断有效期。
5.2 手动粘贴的 token 昨天还能用今天就不行
现象:代码没改,昨天跑得好好的,今天准时 502。原因:token 被写死成常量了,而服务端 token 有有效期(有的就几小时)。解决:永远不要手工复制 token 进代码,改成运行时获取;如果为了调试方便必须缓存,就在缓存文件里一并存时间戳,超过阈值先重新登录再读。
5.3 CI 上找不到 token.md,本地一切正常
现象:本地执行全绿,推到流水线第一步就报 FileNotFoundError。原因:用了相对路径或者os.getcwd()定位文件,而 CI 的工作目录和本地不一样。解决:统一用os.path.dirname(os.path.abspath(__file__))做基准目录拼接;顺便确认 CI 的任务有写权限,容器里挂载只读目录时写文件会失败。
5.4 session 用例返回的是登录页 HTML
现象:resp.json()直接抛 JSONDecodeError,打印出来一看是一段登录页源码。原因:业务请求没有复用登录时的那个 Session 实例,cookie 没带上,服务端判定未登录后做了重定向。解决:把session提升为类属性或模块级单例,所有请求都从它发;如果确实需要新建实例,就手动session.cookies.update(saved_dict)把 cookie 灌进去。
5.5 断言全绿但接口其实是坏的
现象:接口返回{"status": 1001, "msg": "参数错误"},用例依然通过。原因:只断言了 HTTP 状态码,没有断言响应体里的业务状态。解决:至少加一条assertEqual(body['status'], 0);更进一步可以断言 msg 为空字符串,因为正常返回时它通常是空的。断言写少了比写错更麻烦,因为它不会报错,只会让你误以为一切正常。
6. 二次开发:把单文件脚本改造成可维护的配置化测试工程
这套东西跑通之后,下一步的改造方向很明确:把“能跑”变成“好维护”。第一个动刀点是配置外置,域名、账号、超时全部从config.py挪到环境变量或 yaml,一套用例同时打测试环境和预发环境,靠切换环境变量实现,而不是改代码。第二个动刀点是数据驱动,把每个接口的 URL、请求体、期望断言写进一份 JSON 或 yaml,用例只留一个循环体,加接口变成加一行配置,而不是复制一个方法。
第三个动刀点是 token 缓存加效期。文件缓存虽然解决了重复登录,但缓存没有过期概念,服务端那边 token 到期了,本地文件还躺着旧值,跑起来就是一片 502。改造方式是存一个两行的文件,第一行时间戳、第二行 token,读的时候先判断时间差,超过阈值就走登录刷新。
# token_store.py 的改造版:带时间戳的缓存 import os, time TTL = 600 # token 缓存 10 分钟,按实际有效期调 def save(token): with open(_path(), 'w') as f: f.write(f"{int(time.time())}\n{token}\n") def load(): """返回有效 token,过期或不存在则返回 None""" try: with open(_path(), 'r') as f: ts, token = f.read().splitlines()[:2] return token if time.time() - int(ts) < TTL else None except (FileNotFoundError, ValueError): return None第四个动刀点是并发。用例上到几百个之后,串行跑太慢,会想到用 pytest-xdist 多进程跑。这时候多个进程同时读写同一个 token 文件就会打架,轻则读到半截内容,重则互相覆盖。我的做法是并发执行时禁用文件缓存,改用setUpClass里的内存变量,每个进程自己登录一次,用登录次数换稳定性,代价可以接受。
最后一个动刀点是 CI 落地。unittest 的退出码非零就代表有失败用例,流水线据此判定即可,不需要额外解析日志;再加一个--xml报告输出(unittest 有xmlrunner,pytest 有--junitxml),失败详情能直接在流水线界面上看到。我踩过最贵的一次坑是流水线只看了控制台最后一行是不是 OK,结果中途某个用例因为超时没打印任何东西,整条流水线显示成功,实际上什么都没测。从那以后我每次接入新工程,都会先故意改坏一个断言,确认流水线真的会变红,再往下写用例。希望帮到你。
本文还有配套的精品资源,点击获取