做接口测试的人,迟早会撞上验证码这道墙。Jmeter脚本写得好好的,一到登录接口就卡住,验证码拿不到,登录进不去,后面所有业务流程全部瘫痪。这不是脚本问题,而是验证码本身就是一个反自动化设计。我当年第一次做登录压测,在这个环节整整卡了一天,后来把整套处理思路理顺之后,验证码反而成了最不让人担心的部分。这篇文章就把验证码登录接口从功能验证、链路联调到并发压测的完整Jmeter处理方法写清楚,覆盖短信验证码和图形验证码两种最常见形态,顺便带上登录成功之后Token和Cookie的关联用法,给还没趟过这摊水的朋友一份可以直接照抄的作业。
1. 验证码登录接口先想清楚三件事:测的到底是什么
1.1 验证码天生反自动化,但你要测的偏偏是自动化
验证码的设计初衷就是区分人类和机器:图形验证码靠扭曲字体、干扰线和人脑识别,短信验证码靠真实手机号和时效性来确权。这些机制在防御方面很有效,但对自动化测试来说全是障碍。Jmeter本质上就是机器,跑自动化脚本就是高频率模拟请求,恰恰是验证码最想拦下来的那类流量。
理解了这层矛盾,你就能明白为什么教程里第一步不是写脚本,而是先商量怎么绕过。跟开发团队确认验证码的接入方式、校验逻辑、存储位置和使用限制,比拿到接口文档更重要。我遇到过不止一次,接口文档写得明明白白,但验证码接口在网关层还有额外限流,导致测试过程中被误判成攻击流量,整个压测数据全部作废。
验证码关联的常见限制包括:同一个手机号一分钟内只能发一次短信,图形验证码加载一次就失效,验证码五分钟过期,连续错误五次锁定账号。这些限制对真实用户来说影响不大,但对Jmeter这种高频工具就是致命的。做方案前先把这些限制摸清楚,你才知道是要写参数化手机号,还是要找开发开白名单。
1.2 登录接口的产物是认证状态,不是那个四位数
很多人测验证码登录接口时容易陷进去一个误区,总觉得测的重点是验证码本身。实际上验证码只是登录流程的一道闸门,登录接口真正要测试的对象是认证结果,也就是登录成功之后返回的那一串Token、Session、用户信息、权限标识。
所以功能验证阶段,你需要确认的核心用例是这几类:正确验证码能不能登录成功,错误验证码能不能被拦截,过期验证码会不会放行,验证码不传会不会有参数校验,验证码重用会不会被识别。这些用例跑完之后,真正的链路测试才开始,带着登录拿到的令牌去访问需要身份才能看的接口,验证权限边界和会话有效性。
我建议你在Jmeter里把登录接口的返回结果和后续业务接口的执行结果关联起来看,而不是只看登录本身。很多项目登录接口单测是通的,但是Token签发后时效性设置有问题,或者返回头里的Cookie与响应体里的Token不是同一条会话链,这些坑只有做完整链路才会暴露。
1.3 功能验证、链路联调、并发压测:三个场景三种打法
验证码登录接口的处理方式,取决于你当前在做哪一类测试。三种场景的目标和方法是完全不一样的。
| 测试场景 | 核心目标 | 验证码处理建议 | 主要关注点 |
|---|---|---|---|
| 单接口功能验证 | 验证登录接口的各类用例 | 关闭验证码或使用OCR识别 | 参数校验、错误码、Token生成 |
| 链路冒烟联调 | 登录贯穿后续业务流 | 万能验证码或固定测试码 | 登录态传递、接口间依赖 |
| 并发性能压测 | 验证系统在高并发下的表现 | 关闭验证码或万能验证码 | 响应时间、错误率、资源占用 |
性能压测最怕的就是大并发去触发验证码的防刷限流,导致错误率虚高。你分不清报错到底是被限流拦截了,还是应用服务器真的扛不住压力。所以压测登录接口时,我强烈建议默认走“拆码”通道,让验证码这个环节不参与性能计算,你测出来的数据才反映真正的后端处理能力。至于OCR识别验证码的方式,仅建议在功能联调阶段尝试验证用,别让它拖垮压测进度。
2. 环境允许时优先“拆码”:测试环境的三种验证码退路
2.1 最省事的方式:直接在服务端关闭验证码开关
很多大型系统的验证码功能是由独立的验证服务提供的,开发框架里通常都留了配置开关。你可以在测试环境的配置中心把验证码功能关闭,或者把验证码校验逻辑做成一个可插拔组件,测试环境里直接拔掉。
操作上你要注意一点:关闭验证码开关,不能影响登录接口本身的其他校验逻辑。我见过有人图省事把整个认证服务降级了,结果登录接口的密码校验也跟着失效,测试环境直接裸奔。正确做法是只关掉验证码那一层,保留账号密码、设备指纹、参数校验等所有安全逻辑,这样测出来的登录接口行为才接近真实生产表现。
另外,关闭验证码开关后,一定要在测试环境里留一条能开回来的通道。因为偶尔需要现场验证验证码相关缺陷,这时候你再去改配置重启服务就慢了。比较好的做法是做成开关型配置,改完配置自动热加载,不需要重启服务。
2.2 万能验证码:压测时的最佳搭档
万能验证码是目前压测场景下用得最多的方案,思路很简单:服务端在验证码校验逻辑里预留一个固定值,当输入的验证码等于这个值的时候直接放行。比如代码里写死,如果验证码等于“888888”,跳过验证直接通过。
这个方案的优点在于,验证码环节仍然在逻辑上被调用了,只是被放行了,你可以完整走通“传参—校验—放行—签发Token”整个链路,不会因为没有验证码导致流程和真实场景不一致。我在性能压测里基本都推荐这种方案,因为压测数据比较干净,不会因为验证码识别失败引入额外的错误率。
但万能验证码必须只允许在测试环境生效。有些开发把万能验证码挂在生产环境的开关上没有下线,这太危险了,等于给所有攻击者留了一扇后门。作为测试人员,你应当在用例中设计一条验证生产环境不存在万能码的检查项。
2.3 白名单参数:把验证码从认证链条里摘出去
还有一类系统设计得更灵活,会预留一个调试参数,比如请求头里带X-Debug-Validation: bypass,后端在解析到该参数时直接跳过验证码校验。这种方案在联调和性能压测阶段都很好用,因为它不需要业务侧改配置,只需要在HTTP请求中增加一个管理器,不影响其他接口。
用白名单参数要留意的是路由层防护:这个debug通道必须被网关拦截,不能穿透到生产。你可以在测试计划里区分环境变量,生产环境压根就不允许传入这个参数,甚至可以专门写一个脚本去探测生产环境的LTS入口,确保白名单通道失效。
这几种“拆码”方式的本质都是调整校验链路,没有优劣之分,只有适不适合当前项目状态。如果项目里已经有万能验证码,就用万能验证码;如果没有,跟开发沟通不改代码就用白名单参数;都不行就只能走数据库取码或者OCR识别的路线,也就是下面两章要展开的内容。
3. 短信验证码链路:发送接口、数据库取码、登录参数引用
3.1 第一步先把发送验证码的HTTP请求搭起来
短信验证码的完整链路是:先调发送验证码接口,系统往目标手机号发一条短消息,然后你拿到短信里的验证码,再调登录接口提交。Jmeter跑这个链路,最怕的就是你手动等短信、手动敲验证码,一旦上了循环和并发就完全没法玩。所以我们要把整个链路脚本化。
先搭最基础的线程组和HTTP请求。线程组里加一个HTTP请求,指向发送验证码接口,按常见项目推测大概长这样:
POST /api/sms/send Content-Type: application/json { "mobile": "13800138000", "scene": "login", "type": 1 }实际项目中,发送验证码往往需要携带图形验证码的校验结果,或者有行为校验参数,比如设备标识、时间戳、随机串。你需要先跟开发确认,这一步最容易遗漏的是请求头里的Token、签名参数。我见过有人调发送接口一直报“风控拦截”,最后发现是少传了一个设备的指纹头。
发送接口的响应通常只返回成功标记和流水号,验证码本身不在响应体里,而是落到短信通道和数据库里。如果你们的验证码系统在测试环境有专门的短信记录服务,直接从数据库里捞是最稳的。
3.2 JDBC直连数据库:验证码的真正来源
既然验证码不返回在接口响应里,我们就去数据库里找。Jmeter提供了JDBC组件,包括JDBC Connection Configuration和JDBC Request,用起来和数据库客户端一样。
在测试计划里添加JDBC Connection Configuration,关键配置如下:
- JDBC Driver class:com.mysql.cj.jdbc.Driver(MySQL环境)
- Database URL:jdbc:mysql://测试库地址:3306/业务库名?useSSL=false&serverTimezone=UTC
- Username:测试库账号
- Password:测试库密码
- Variable Name:dbConfig
这个Variable Name非常重要,后面的JDBC Request要靠这个名字来引用连接池。如果Jmeter执行时报“无法加载JDBC驱动”,说明lib目录下没有对应的数据库驱动包,去下载对应版本的驱动jar放到jmeter的lib目录,重启Jmeter即可。
接着添加JDBC Request组件:
- Variable Name:dbConfig
- SQL Query:SELECT code FROM t_sms_record WHERE mobile='${mobile}' ORDER BY send_time DESC LIMIT 1
- Variable Names:sms_code
- Result variable name:sms_result
JDBC Request执行完毕之后,查询到的验证码就存到了变量sms_code里,后面登录接口直接引用它。查询时要注意两点:一是表名和字段名以实际项目为准,我写的是最常见命名,你要去项目的数据库里确认;二是排序一定要按时间倒序,LIMIT 1取最新的一条,因为短信验证码通常有多个历史记录,取错一条就会登录失败。
3.3 登录请求引用验证码,用调试断言快速定位
拿到sms_code之后,登录接口长这样:
POST /api/login Content-Type: application/json { "mobile": "${mobile}", "code": "${sms_code}", "clientType": "WEB" }跑通之后,建议在JDBC Request后面加一个JSR223断言或者查看结果树,先确认sms_code到底有没有取到值。我习惯在JSR223断言里写一行:log.info("当前验证码=" + vars.get("sms_code")),然后在日志里直接看结果。用BeanShell断言也能做这件事,但现在更推荐JSR223+Groovy,因为BeanShell跑得慢,压测时容易拖慢整体性能。
短信链路里有一个隐蔽的坑:验证码可能有有效期,默认一般是5分钟或10分钟,但并发压测时如果线程排队等待,部分线程拿到的验证码已经过期了。所以如果你在做压测,短信验证码链路其实不太适合作为压测入口,这也是我前面强调压测用万能验证码的原因。
链路全部跑通之后,别忘了验证码的重置逻辑:登录成功后验证码是否立即失效,登录失败后验证码还能不能用。这些用例看起来小,但是和用户实际体验直接相关。
4. 图形验证码链路:取图、OCR识别、一次登录测试跑通
4.1 获取图形验证码图片和captchaId
图形验证码的接口交互和短信验证码不太一样。它通常有两个关键信息:一个是captchaId,用来标识本次验证码的会话;一个是图片内容,可能是Base64字符串,也可能是一个图片URL。
Jmeter里访问验证码接口的方式很简单,用HTTP请求即可:
GET /api/captcha?timestamp=1693250000000加timestamp参数主要是为了绕过缓存,否则可能每次请求都返回同一个验证码。有些项目在网关层做了缓存,如果你不附加随机参数,图片内容会一直是同一张,那后面对比识别结果和登录结果就没意义了。
接口返回的JSON结构常见形态是这样:
{ "code": 0, "data": { "captchaId": "a8c1f9d3-...", "img": "data:image/png;base64,iVBORw0KGgo..." } }在Jmeter里处理Base64图片,不能直接把字符串塞给OCR服务,需要先解码成图片文件。可以在HTTP请求之后加一个JSR223后置处理器,用Groovy脚本把图片和captchaId提取出来。
4.2 OCR识别:用JSR223采样器调用本地识别服务
拿到图片数据之后,下一步是识别出验证码明文。我常用的方案是把图片保存到本地,然后调用一个本地的OCR识别服务,把识别结果返回给Jmeter。
在“获取验证码”的HTTP请求下,添加一个JSR223后置处理器,写解码逻辑:
import java.util.Base64 import groovy.json.JsonSlurper def resp = prev.getResponseDataAsString() def data = new JsonSlurper().parseText(resp) vars.put("captchaId", data.data.captchaId) def imgStr = data.data.img if (imgStr.startsWith("data:image")) { imgStr = imgStr.substring(imgStr.indexOf(",") + 1) } byte[] imgBytes = Base64.getDecoder().decode(imgStr) def imgFile = new File("captcha_" + vars.get("captchaId") + ".png") imgFile.bytes = imgBytes props.put("captchaImgPath", imgFile.getAbsolutePath())然后再添加一个JSR223采样器,读取本地图片并调用OCR HTTP服务:
import java.io.File def f = new File(props.get("captchaImgPath")) def url = new URL("http://127.0.0.1:8000/ocr") def conn = url.openConnection() conn.requestMethod = "POST" conn.doOutput = true conn.setRequestProperty("Content-Type", "application/octet-stream") conn.outputStream.write(f.bytes) conn.outputStream.flush() conn.outputStream.close() def result = conn.inputStream.text def json = new groovy.json.JsonSlurper().parseText(result) vars.put("captchaCode", json.code)执行完这个采样器,识别出的验证码就存在变量captchaCode里。如果你的OCR服务返回的是纯字符串,那就更简单,直接vars.put("captchaCode", result.trim())就行。
4.3 图形验证码链路的几个真实踩坑点
第一,验证码通常是一次一用。线程A获取了验证码但登录失败,这个captchaId和code就废了,线程B再用必然会失败。排查问题时别把这种失败归因到并发能力上,先把验证码关联关系理清。
第二,OCR识别率不可能100%。最简单的纯数字四位验证码,识别率90%以上是可以的;一旦加了扭曲、噪点、干扰线,识别率可能掉到50%以下。识别失败就登录失败,进而拖高错误率。所以OCR方案只适合做单接口功能联调,不适合做性能压测数据入口。
第三,本地保存图片的路径要和脚本运行环境匹配。如果Jmeter脚本要提交到持续集成平台上运行,图片要保存到相对路径或者临时目录,不要用硬编码的绝对路径,否则换台机器就崩。
第四,图形验证码接口有时会走HTTPS,Jmeter访问HTTPS接口需要先导入安全证书。录制脚本时如果遇到HTTPS请求报证书错误,把证书导入到Jmeter的lib/ext目录里的cacert,或者配置成忽略校验。这个问题网上资料很多,我就不展开了,但大家遇到类似报错时先往证书方向排查。
5. 登录成功后别停手:Token提取、Cookie会话和结果落盘
5.1 从登录响应里提取Token:JSON提取器和正则二选一
登录接口跑通只是第一步,后续带着登录态去访问业务接口才是重点。绝大多数现代项目登录成功后都会返回Token,Jmeter里提取Token最常用的工具是JSON提取器。
在登录HTTP请求下添加后置处理器里的JSON提取器,配置如下:
- Apply to:Main sample and sub-samples
- JSON Path expression:$.data.token
- Variable names:login_token
- Default value:NOT_FOUND
如果你的登录响应结构不是标准的data字段下挂token,比如返回层级比较深,或者Token藏在响应头的Authorization字段里,就需要换成正则表达式提取器。正则表达式长这样:"token"\s*:\s*"([^"]+)",模板是$1$,匹配数字填1,变量名同样叫login_token。
两种提取器各有利弊。JSON提取器依赖响应是合法JSON,一旦响应被包装过(比如返回了text/html的异常页),提取器会失手;正则表达式则对字符串匹配更直接,适合从混合内容里抓数据。我的习惯是能解析JSON就用JSON提取器,读起来清楚,维护起来不容易出错。
5.2 Cookie会话保持:别让登录态在请求之间断裂
很多老系统和服务端渲染的项目,登录态的载体不是Token而是Cookie。Jmeter处理Cookie可以用HTTP Cookie管理器,把它加在线程组下面,勾选“自动保存Cookie”就行。
加了Cookie管理器之后,登录接口返回的Set-Cookie会被自动保存,后续的HTTP请求会自动携带对应的Cookie值。这个组件解决的最大问题是会话漂移,也就是每次请求创建新会话,导致登录态丢失。
有些系统比较特殊,登录响应里同时返回Cookie和Token,而且两个都校验。这时候你就需要把Token放到后续请求的请求头里,一般在线程组下添加一个HTTP Header管理器,加一行Authorization: Bearer ${login_token}。注意,Header管理器添加在哪个作用域,决定了带这个请求头的请求范围。
我之前做过一个单点登录项目,登录返回的Token要放到URL参数里而不是请求头里,否则网关不认账。所以拿到登录响应后,先别急着写死方案,去看一下后续接口实际需要哪种鉴权方式,多看几个接口再定。
5.3 把提取结果写成文件:为数据驱动和后续准备
Jmeter提取出来的变量默认只在当前线程内有效,一旦测试结束就没了。如果你想把这些Token保存下来,后续做数据准备或交给其他工具使用,需要主动把变量写进文件。
在登录请求下加一个JSR223后置处理器,用Groovy把Token追加到本地文件:
def f = new File("tokens.txt") def w = new FileWriter(f, true) w.write(vars.get("login_token") + System.lineSeparator()) w.flush() w.close()这段脚本是追加写入,不会覆盖之前的记录。对于并发压测场景,每个线程都会执行一次写入,最终文件里的Token数量应该和线程数一致。如果数量对不上,就说明部分线程的登录请求没有成功取到Token,需要回头排查登录失败原因。这个方法也叫提取JSON结果生成文件,在数据准备环节非常常用,比如你要生成一批真实的登录态给线上巡检用。
6. 压测验证码登录接口:线程数设计、错误率排查与报告解读
6.1 压测登录接口的线程组怎么设计
压测登录接口时,线程组的设置要结合登录接口的特征来定。登录接口通常是无状态短链接、请求量大的类型,主要消耗的是应用服务器的认证处理能力。
我的建议是压测登录接口使用阶梯式线程模型:线程组里先设50个线程、Ramp-up 10秒,循环次数根据测试时长动态调整。不要一上来就拉500并发,登录接口如果因为验证码限流或者密码错误锁定等机制产生错误,你无法判断数据里的错误率到底是压出来的还是业务规则拦的。
线程组的Loop Count如果设置成循环多次,每个线程会重复执行登录,这意味着每次循环都会重新触发验证码逻辑。如果验证码没有得到妥善处理,比如走OCR,识别一次要几百毫秒,大量线程同时OCR很容易把识别服务打爆。所以压测时仍然建议打开万能验证码通道,或者干脆关闭验证码校验,把登录接口还原成一个纯粹的认证接口去压。
6.2 用断言和日志挖出真实的失败原因
登录压测里最让人头疼的错误信息是“验证码错误”。你以为全部问题都出在验证码上,实际上这只是表象,背后的真实原因可能五花八门。
最常见的假性失败来自参数变量没取到值。比如手机号参数没有正确参数化,所有线程都用了同一个号码,服务端会先拦截重复验证码请求,然后登录全部失败。你在查看结果树里会看到大量“验证码已失效”之类的提示,其实根源是手机号没做参数化,而不是验证码本身的问题。
排查技巧是给登录请求加JSR223断言,在断言里把关键变量打印出来:
log.info("手机号=" + vars.get("mobile")) log.info("验证码=" + vars.get("sms_code")) log.info("Token=" + vars.get("login_token"))加上之后,跑一轮小并发,去Jmeter日志里翻每一个线程的实际取值,基本能定位是变量问题还是接口问题。断言写得好,能把错误码、响应体、请求参数全部留痕,压测结束后整理缺陷报告也有据可依。
6.3 结果报告看什么:响应时间分布比平均响应时间更有用
Jmeter自带的聚合报告能看平均响应时间、错误率、吞吐量这几个核心指标。但在登录接口场景,我建议你更关注响应时间的分布,也就是P90、P95和P99。平均响应时间很容易被个别长尾请求拉高,而P95能更真实地反映大多数用户的体验。
登录接口还有一个需要关注的指标是错误率的构成。你可以搭配JSR223断言和结果树,把失败请求单独筛选出来,按错误码统计归类。比如业务错误码为10001的是验证码错误,10003的是密码错误,10007的是限流拦截。这种归类能直接告诉你系统瓶颈到底在哪一层。
我个人的压测经验是:跑登录压测的时候,先小规模验证脚本正确性,再上量级。宁可多跑三轮小并发,也不要一次跑个大的然后数据全是废的。每次跑完,先去查看结果树里成功和失败的请求长什么样,确认判断逻辑无误,再进下一轮。
最后再分享一个我项目里用过的做法:压测登录接口时,顺手把应用服务器的Tomcat、数据库连接池、Redis连接数都抓一份曲线。登录接口是后端认证链路的总入口,它一旦出问题,往往不是接口本身的锅,而是底下依赖的数据库连接池或缓存节点先扛不住了。多看一眼这些资源指标,排查问题时候能少走不少弯路。