
简介这份文档面向刚接触接口自动化测试的测试人员与 Python 学习者聚焦登录后 token 的获取、提取与跨接口传递这一常见环节。内容以 requests 库为基础演示依赖安装、POST 登录请求构造、请求头与超时设置、返回 JSON 字符串解析并通过字典取值拿到 token再将其带入后续接口完成身份校验同时穿插函数返回值为何存在、如何把 token 作为参数传给另一个函数等基础说明并给出 token 无效时的排查思路。文档中的代码示例覆盖从登录接口到业务接口的完整调用顺序读者可据此理解 token 在自动化用例中的参数化方式并迁移到自己的项目。压缩包共 1 个 docx 文件约 1.29MB内容集中便于对照示例逐步复现登录鉴权流程。已有 6665 人学习适合需要补齐接口鉴权入门链路、理解 token 与 session 处理方式的读者参考。1. token 拿不到后面全是白跑这套流程到底解决什么接口自动化测试里最先翻车的地方往往不是断言写错而是 token 没拿到、或者拿到了用不上。这套流程解决的就是这件事用 Python 的 requests 打登录接口从响应 JSON 里把 token 抠出来作为参数喂给后面的业务接口再收进 unittest 的用例结构里最后把 token 与 session 两种鉴权形态都走一遍。它适合刚开始做接口自动化的同学照着抄也适合已经被「502 token 无效」和「每跑一个用例就登录一次」折腾过的熟手对照边界。整套东西不依赖任何测试平台一个 py 文件加 requests 就能跑通后面平移到 pytest、接进流水线都是同一套逻辑。真正需要想清楚的是三件事token 从响应的哪一层取、取到之后放在请求的哪个位置、以及多个用例之间怎么只登录一次。2. requests 打穿登录接口一次 POST 里藏着三个参数坑登录接口是整个链路的入口它的返回结构决定了后面所有接口怎么写。很多人卡在这里不是因为不会发请求而是没看清响应体的层级、没搞明白json和data的区别、也没处理响应非 JSON 的情况。这一章把登录请求拆到能复现的程度每一步都对应一个具体的参数决定。2.1 依赖与自检装完先跑一行导入第三方库只有 requests 一个安装命令和绝大多数 Python 包一样。装完之后不要急着写业务代码先做一次导入自检确认装到了当前解释器里——虚拟环境多的时候pip install装到 A 环境、IDE 用的是 B 解释器是最常见的「明明装了却 ModuleNotFoundError」。# 安装或升级到最新版-U 表示已安装则升级 pip install -U requests# 自检确认当前解释器能导入并打印版本 import requests print(requests.__version__)这两行跑通再往下走。版本号不用刻意记但要知道 requests 从 2.27 开始把 JSON 解析失败的异常独立成了requests.exceptions.JSONDecodeError老版本里抛的是ValueError。写异常捕获的时候如果只写except ValueError在新版本上可能漏接这一点在 2.2 节会再提。提示如果项目里同时有 Python 2 和 Python 3 的代码pip命令要显式绑定解释器比如python3 -m pip install -U requests否则装错解释器是常事。原始案例的代码是 Python 2 写法print getToken()下面统一改成 Python 3 的可执行版本差异主要在 print 和文件编码两处。2.2 响应体到底是 json 还是 dict反序列化的边界登录请求本身很简单POST 一个 JSON body 过去。真正容易出问题的是三个参数json、headers和timeout。import requests BASE_URL https://api.example-test.com/v4/ HEADERS { # 声明请求体是 JSON服务端解析器靠这个头决定怎么读 body Content-Type: application/json;charsetUTF-8, # 自定义来源头很多网关会校验缺失直接 403 X-Client-Source: DemoAPIClientWeb, } def login_raw(): resp requests.post( urlBASE_URL login, # json 会自动序列化 dict 并补 Content-Type这里显式头会覆盖它 json{username: demo_user, password: 按被测系统的密码串, role: 2}, headersHEADERS, timeout5, ) return resp if __name__ __main__: r login_raw() print(r.status_code) # HTTP 状态码 print(r.text) # 原始文本排错时最先看这个参数逐个说清楚。json传 dictrequests 内部会做json.dumps并把 Content-Type 设成application/json如果被测系统要的是表单就得换成data两者混用会出现服务端读到空 body 的情况返回的往往是业务错误码而不是 HTTP 错误很容易误判成「账号密码错」。timeout5是连接超时和读取超时都设成 5 秒不传这个参数的话服务端挂起时脚本会一直卡着在 CI 上表现为任务超时而不是用例失败排查成本很高。自定义头X-Client-Source属于接口契约的一部分抓包看一眼真实请求里带了什么照着抄别自己猜。响应拿到之后r.json()才是把字符串变成 dict 的那一步。这一步有两个边界一是响应体不是 JSON网关返回 HTML 错误页、502 页面、登录被重定向到 HTML 登录页r.json()直接抛异常二是 HTTP 200 但业务失败JSON 结构里status不为 0。所以稳妥的写法是先判状态码和内容类型再解析。def parse_login(resp): # 先看 HTTP 层非 200 多半是网关或路由问题不是业务问题 if resp.status_code ! 200: raise RuntimeError(fHTTP {resp.status_code}: {resp.text[:200]}) try: body resp.json() except requests.exceptions.JSONDecodeError: # 返回的不是 JSON把前 200 字符打出来通常是 HTML 错误页 raise RuntimeError(f响应非 JSON: {resp.text[:200]}) return body执行后能看到类似这样的结构{status: 0, msg: , data: {token: xxxx, id: 600192, name: ...}}。token 在data下面一层不是顶层。这个层级关系必须从真实响应里确认不同系统有的放在data.token有的放在result.access_token写死之前先打印一次完整结构别照搬别人的路径。2.3 用返回值把 token 交出去函数调用链的最小模型拿到 dict 之后取 token 就是一行下标操作但这一行背后是 Python 函数返回值的作用返回值把对象交给调用方调用方拿到的是同一个对象的引用可以直接当参数传给下一个请求。理解这一点后面「为什么登录函数要被反复调用」的问题才有解。def get_token(): resp requests.post( urlBASE_URL login, json{username: demo_user, password: 按被测系统的密码串, role: 2}, headersHEADERS, timeout5, ) body resp.json() # 先校验业务状态避免 KeyError 掩盖真实错误 if body.get(status) ! 0: raise RuntimeError(f登录失败: {body.get(msg)}) return body[data][token] if __name__ __main__: token get_token() print(token)这里做了一个容易被忽略的处理先判status再取data.token。如果不判登录失败时抛的是KeyError: data看报错完全猜不到是密码错了还是账号被锁。这种「报错信息指向错误位置」的情况在接口自动化里非常常见后面避坑章节还会遇到同类问题。返回值的类型是字符串长度、字符集按被测系统而定别对 token 做任何格式假设——有的系统 token 里带时间戳后缀有的带下划线拿正则去校验纯属自找麻烦。要做校验就校验「非空且长度大于某个阈值」够用了。到这一步一个能取到 token 的脚本就完整了。接下来要验证的是这个 token 到底能不能用。3. 拿 token 去换业务数据infoGet 与 isSoonExpire 的依赖串联取到 token 只是半程真正的验证在下一个接口。业务接口对 token 的校验通常很严格——同一个账号在其他地方登录一次旧 token 立刻失效请求返回 502 或者被重定向到登录页。这一章用两个典型接口把依赖关系跑通并说清楚失败时该看什么。3.1 token 放 body 还是 header先看接口契约再动手同一个 token放在 JSON body 里和放在请求头里对被测系统来说是两套解析逻辑。原始案例里 token 是塞进 JSON body 的json{token: get_token()}。这是最省事的方式但只适用于接口明确这么约定的场景。def info_get(): resp requests.post( urlBASE_URL infoGet, # token 作为 body 字段传递字段名必须和接口文档一致 json{token: get_token()}, headersHEADERS, timeout5, ) return resp.json()如果接口要的是请求头写法变成headers{**HEADERS, Authorization: fBearer {token}}。判断依据只有一个抓一次真实的前端请求或者翻接口文档看 token 出现在哪个位置。凭经验猜的话REST 风格系统多用 Authorization 头早期业务系统多塞 body猜错的代价是一个 502 加半小时排查。第二个接口isSoonExpire换个语义用来判断账号是否即将到期参数同样是 token。两个接口写在一起就能看出重复代码的问题每个函数里都调了一次get_token()也就是每个接口都登录了一次。def is_soon_expire(): resp requests.post( urlBASE_URL isSoonExpire, json{token: get_token()}, headersHEADERS, timeout5, ) return resp.json() if __name__ __main__: print(info_get()) print(is_soon_expire())跑通之后输出的是两个 dictstatus为 0 表示业务成功data里分别是用户信息和到期标记。能跑通不代表写法对这里的隐患是登录被调用了两次。3.2 token 无效时长什么样502 与跳登录页的两种失败面失败面必须提前摸清楚否则用例报错时根本判断不了是脚本问题还是环境问题。常见的两种第一种是业务网关直接返回错误码。原始案例里描述的是「502 错误 token 无效」——注意这里的 502 可能出现在两个层级HTTP 状态码真的是 502或者 HTTP 200 但 body 里status是 502。这两种处理方式完全不同前者要在resp.status_code上判断后者要在resp.json()[status]上判断。写断言的时候两个都看一眼别只判一个。第二种是被重定向到登录页。session 场景下最典型不带 cookie 请求业务接口服务端 302 到登录页requests 默认会跟随重定向最后拿到的是登录页的 HTML。这时候r.json()会抛 JSON 解析异常报错信息看起来跟 token 毫无关系实际原因是鉴权失败。def probe_without_token(): # 故意不带 token观察服务端的失败形态 resp requests.post( urlBASE_URL infoGet, json{}, headersHEADERS, timeout5, allow_redirectsFalse, # 关掉跟随重定向才能看到 302 ) print(resp.status_code, resp.headers.get(Location)) print(resp.text[:200])allow_redirectsFalse这个参数在排查鉴权问题时特别好用。默认 True 会把 302 跟到底你看到的是最终页面看不到跳转过程关掉之后status_code直接暴露 302Location头告诉你去哪了一眼就能判断是「没带凭证」还是「凭证过期」。注意生产环境的登录页地址往往和 API 不同域跟随重定向还可能把 POST 变成 GETbody 直接丢失。排查阶段建议一律关掉重定向。3.3 把三个接口串成一条链从脚本到可重复执行脚本能跑一次和能重复跑是两回事。可重复执行要求每次跑之前状态干净、失败时能看到足够上下文、成功时有明确判断。最粗糙的版本可以在__main__里顺序调用三个函数但只要接口数量上去这种方式就撑不住了——函数之间靠全局变量传递 token一个函数改了返回结构整条链全崩。一个折中的做法是把公共部分提出来base url、headers、超时、token 获取四个东西抽成模块级常量或函数。这样每个接口函数只看自己的业务参数公共参数改一处全生效。import requests BASE_URL https://api.example-test.com/v4/ TIMEOUT 5 HEADERS { Content-Type: application/json;charsetUTF-8, X-Client-Source: DemoAPIClientWeb, } def get_token(): resp requests.post( urlBASE_URL login, json{username: demo_user, password: 按被测系统的密码串, role: 2}, headersHEADERS, timeoutTIMEOUT, ) return resp.json()[data][token] def post_json(path, payload): # 统一出口所有业务接口都走这里超时和 headers 不用重复写 resp requests.post(urlBASE_URL path, jsonpayload, headersHEADERS, timeoutTIMEOUT) return resp.json() if __name__ __main__: tk get_token() print(post_json(infoGet, {token: tk})) print(post_json(isSoonExpire, {token: tk}))改动看着小效果明显token 只取一次两个接口共用超时和 headers 只有一个来源新增接口只需要一行post_json(xxx, {token: tk})。这个结构就是后面 unittest 重构的基础——tk这个变量最终会被搬到setUp里post_json会被拆成带断言的方法。跑通这条链之后下一个问题是怎么把它变成一组能自动判定成败的用例而不是靠人眼看输出。4. unittest 重构把 URL、headers、超时收进 setUp脚本阶段的输出靠print看用例阶段必须靠断言判。unittest 提供的最小骨架是setUp准备、test_*执行、tearDown清理三者的职责边界一旦搞混后面会写出一堆互相依赖的用例。这一章把重构过程拆开重点放在断言写什么、token 怎么只取一次。4.1 setUp/tearDown 里该放什么、不该放什么setUp在每个测试方法执行前跑一次适合放「每个用例都要用、且创建成本低」的东西base url、headers、超时。这些就是几个字符串和字典重建一次的成本可以忽略。import unittest import requests import time class InterfaceTest(unittest.TestCase): def setUp(self): self.url https://api.example-test.com/v4/ self.headers { Content-Type: application/json;charsetUTF-8, X-Client-Source: DemoAPIClientWeb, } self.timeout 5 def tearDown(self): # 每个用例之间留 1 秒避免触发服务端限流 time.sleep(1) def get_token(self): resp requests.post( urlself.url login, json{username: demo_user, password: 按被测系统的密码串, role: 2}, headersself.headers, timeoutself.timeout, ) return resp.json()[data][token] def test_info_get(self): 验证 infoGet 接口返回的用户名正确 resp requests.post( urlself.url infoGet, json{token: self.get_token()}, headersself.headers, timeoutself.timeout, ) body resp.json() self.assertEqual(body[status], 0) self.assertEqual(body[data][username], demo_user) def test_is_soon_expire(self): 验证 isSoonExpire 接口返回的到期标记 resp requests.post( urlself.url isSoonExpire, json{token: self.get_token()}, headersself.headers, timeoutself.timeout, ) body resp.json() self.assertEqual(body[status], 0) self.assertEqual(body[data][expire], False) if __name__ __main__: unittest.main(verbosity2)tearDown里的time.sleep(1)值得单独说。它的作用是降低请求频率避免被测系统按 IP 或账号做限流。代价很直接100 个用例就是 100 秒纯等待。我的习惯是只对写接口和登录接口加间隔读接口不加或者干脆把 sleep 换成失败重试机制只在遇到 429 或特定错误码时退避。无脑全局 sleep 是新手最容易背上的性能债。setUp里不要放的东西也得说清楚不要在里面登录。一旦登录写进setUp每个测试方法执行前都会登录一次这就是 4.3 节要解决的问题。也不要在setUp里创建数据库连接、启动服务这类重资源用例数量一多光准备阶段就能跑几分钟。4.2 断言没有断言的用例等于没跑只print不assert的「测试」本质上是个手动脚本跑完还得人去看输出对不对。这类代码放进 CI 里是最危险的——永远绿灯出了问题也发现不了。断言写什么取决于接口的业务语义但有几条通用原则第一HTTP 层和业务层都要判。self.assertEqual(resp.status_code, 200)保证链路通self.assertEqual(body[status], 0)保证业务成功。只判前者会漏掉 HTTP 200 但业务失败的情况。第二断言要落在「这个用例独有」的字段上。infoGet断言用户名等于登录账号isSoonExpire断言到期标记为 False这两个断言才有区分度。如果所有用例都只断言status 0那接口返回的是谁的数据根本验证不了。第三避免断言整个响应体。assertEqual(body, {...})这种写法看着严谨实际上是自找麻烦服务端加一个字段、改一个时间戳用例就红维护成本极高。断言关键字段即可。# 反例整个 body 比对字段一变就失败 # self.assertEqual(body, {status: 0, msg: , data: {...}}) # 正例只断言业务关键字段 self.assertEqual(body[status], 0) self.assertEqual(body[data][username], demo_user) self.assertIn(token, body[data])assertIn这种断言在判断「字段存在但值不确定」的场景很好用。比如 token 每次登录都不一样就没法断言具体值只能断言存在且非空。4.3 登录只做一次token 落盘与读取上面那份代码有个明显问题test_info_get和test_is_soon_expire各调了一次get_token()也就是登录了两次。两个用例还好二十个用例就是二十次登录。很多系统对同一账号的 token 是互踢的——后一次登录让前一次失效于是并发或快速连续执行时先登录的用例拿到的是已失效 token报 502。解决思路很朴素登录只做一次把 token 存在某个地方其他用例读。存文件是最简单的实现跨进程也能用。import os def token_file(): # 和当前脚本同目录避免依赖执行时的工作目录 return os.path.join(os.path.dirname(os.path.abspath(__file__)), token.txt) def save_token(token): with open(token_file(), w, encodingutf-8) as f: f.write(token) def read_token(): with open(token_file(), r, encodingutf-8) as f: # strip 很关键编辑器自动加的换行会让 token 带 \n请求直接失效 return f.read().strip() def ensure_token(): # 有缓存就用缓存没有才登录 path token_file() if os.path.exists(path) and read_token(): return read_token() tk get_token() save_token(tk) return tk三个细节容易被忽略。os.path.dirname(os.path.abspath(__file__))而不是os.path.dirname(__file__)是因为后者在相对路径执行时可能返回空串拼出来的路径就变成了当前工作目录换个目录跑就找不到文件。encodingutf-8显式指定避免 Windows 上默认 GBK 写出来的文件在 Linux CI 上读成乱码。strip()前面说过是防止文件尾部的换行混进 token。提示token 文件要加进.gitignore。带着真实 token 提交上去等于把账号凭据公开了这个坑比代码 bug 严重得多。到这一步用例结构、断言、token 缓存都有了。剩下的是把实际跑起来会撞上的问题提前排掉。5. 避坑与排查token 链路上的五条血泪记录前面几章的代码是「理想路径」真实项目里跑起来会遇到各种意外。这一章按「现象 → 原因 → 解决」记五条实际踩过的都是能复现的不是理论推演。5.1 登录被调用 N 次引发的 token 互踢现象单跑每个用例都过一起跑就有随机一两个用例报 502 token 无效重跑又不一定复现。原因每个测试方法里都调了get_token()unittest 默认按方法名字母序执行登录发生多次。被测系统如果采用「单 token 有效」策略后一次登录会让前一次的 token 立刻失效。用例执行快的时候A 用例刚拿到 tokenB 用例的登录就把它顶掉了A 的请求发出去就是无效 token。这种问题在单用例调试时永远看不到一上 CI 就暴露很玄学。解决登录收敛到一处。要么用 4.3 的文件缓存要么在setUpClass里登录一次存成类属性所有用例共用。setUpClass的写法更干净但只适合同一进程内执行文件缓存跨进程可用CI 分片跑也能共享。class InterfaceTest(unittest.TestCase): classmethod def setUpClass(cls): # 整个类只执行一次token 存类属性 cls.token cls._login() classmethod def _login(cls): resp requests.post( urlhttps://api.example-test.com/v4/login, json{username: demo_user, password: 密码串, role: 2}, headers{Content-Type: application/json;charsetUTF-8}, timeout5, ) return resp.json()[data][token]注意setUpClass要用classmethod且第一个参数是cls写成self会直接报错。这是从setUp迁移过来时最常见的低级失误。5.2 落盘代码那几个必崩的地方现象一把登录逻辑抽成模块级函数后运行报NameError: name self is not defined。原因函数从类方法里搬出来时函数体里还留着self.url、self.headers的引用。模块级函数没有self解释器找不到这个名字。解决把self.url换成模块级常量BASE_URLself.headers换成get_headers()函数或常量。重构时不要只改函数签名函数体里的self引用要一起清掉全局搜一遍self.最保险。现象二token 文件写进去了读出来请求还是 502。原因open(..., w)写文件时如果没写换行读出来确实干净但只要有人用编辑器打开保存过末尾就会多一个\n。这个换行跟着 token 一起进了 JSON body服务端比对失败。解决读取时统一.strip()。另外open记得带encodingutf-8否则中文 Windows 上写出来的编码和 CI 上读的编码不一致token 里的特殊字符可能被破坏。现象三CI 上跑报FileNotFoundError: token.txt本地却正常。原因路径用了相对路径或os.path.dirname(__file__)CI 执行时工作目录和本地不同拼出来的路径指向了别处。解决一律用os.path.abspath(__file__)拿绝对路径再dirname。要更稳一点把缓存目录做成可配置的环境变量本地和 CI 各指各的。5.3 凭据与文件管理的两个坏习惯现象代码提交后安全扫描报出了硬编码密码。原因登录密码直接写在源码里还跟着 git 历史一起提交了。原始案例里密码是一串 MD5 值看着像密文实际上它就是登录凭据本身——抓包重放一样能用。解决账号密码从环境变量或配置文件读配置文件进.gitignore仓库里只留一份config.example.json说明字段。已经提交过的改密码比删历史更实际。import os USERNAME os.environ.get(API_USER, demo_user) PASSWORD os.environ.get(API_PWD, ) if not PASSWORD: raise RuntimeError(缺少环境变量 API_PWD请先配置登录凭据)这段在模块导入时就检查缺配置直接失败比起跑到一半报「密码错误」要好排查得多。现象token 缓存文件写进了仓库别人拉下来直接能用你的账号。原因缓存文件和源码放同一目录.gitignore忘了加。解决.gitignore里加上token.txt、*.token、.env这类模式。更彻底的做法是把缓存目录指到系统临时目录tempfile.gettempdir()一行搞定从根上避免误提交。这五条覆盖了 token 链路上一大半的翻车场景。剩下的 session 部分坑的性质不太一样单独拎出来说。6. 进阶session 场景下 cookie 的取用与验证不是所有系统都返回 token。有的登录接口只返回一个code: 200凭证放在响应头里Set-Cookie中后续请求靠 cookie 识别身份。这类系统的自动化思路要从「取字段」切换到「维持会话」。手动取 cookie 的方式是resp.cookies.get_dict()拿到一个 dict再塞进下一个请求的cookies参数里。能跑通但每个请求都要手动带漏一个就跳登录页。import requests s requests.Session() # 会话对象自动管理 cookie # 登录响应里的 Set-Cookie 会被 Session 自动收下 r1 s.post(https://api.example-test.com/v4/login, json{username: demo_user, password: 密码串}, headers{Content-Type: application/json;charsetUTF-8}, timeout5) print(s.cookies.get_dict()) # 看一眼拿到了哪些 cookie # 业务请求不需要手动带 cookieSession 自己带上 r2 s.post(https://api.example-test.com/v4/accessDatas, json{page: 1}, timeout5) print(r2.status_code, r2.text[:200])requests.Session()的核心价值就是把 cookie 的存取自动化了。手动模式的典型翻车是某天服务端多加了一个 cookie 字段手动代码只带了原来那一个接口开始间歇性跳登录排查半天以为是账号问题。Session 不会漏因为它按域名全量维护。两种鉴权形态落地的差异列个表对照更清楚维度token 方案session 方案凭证位置响应 body 的data.token响应头Set-Cookie传递方式放进 JSON body 或 Authorization 头请求自动携带 cookie实现载体字符串变量 / 文件缓存requests.Session()对象跨进程复用容易写文件即可较麻烦需序列化 cookie典型失效表现业务码 502、token 无效302 跳登录页、返回 HTML断言语义断言 token 非空、业务 status 为 0断言 cookie 存在、业务数据非空验证 session 是否真的生效有个简单办法登录后先打印s.cookies.get_dict()确认关键 cookie 存在再发业务请求如果返回的还是登录页 HTML说明 cookie 没带上或者已失效。这时候把allow_redirectsFalse打开看 302 的Location指向哪里比盯着报错猜快得多。跨进程共享 session 是个坑。cookie 本质是个 dict能序列化成 JSON 存文件但带过期时间和服务端会话状态手工恢复很容易出问题。我的做法是session 类系统不做跨进程缓存每个测试进程独立登录一次用进程数控制登录次数比折腾序列化可靠。最后说个我自己的习惯。早期写这类脚本我总是先写接口再补断言结果好几次跑了一周才发现用例从来没真正校验过东西——全绿的背后是断言写在了错误字段上。从那以后我每加一个接口用例都强制先打印一次完整响应确认字段路径之后才写断言写完立刻把断言改错一个值跑一遍看它是不是真的会红。这个「故意让它失败一次」的动作救过我很多回。希望帮到你。本文还有配套的精品资源点击获取