接口脚本最容易骗人的地方,就是它明明挂了,却给你一片绿色的 200。之前帮一个团队看他们的天气接口自动化脚本,聚合报告里错误率 0%,结果我一翻察看结果树,返回体里全是{"code":1002,"msg":"invalid city code"}——状态码 200,业务全错。后来复盘,问题出在三个地方:城市编码是硬编码的、上一接口返回的城市 ID 没有传给下一个接口、断言只校验了"响应里包含 200 这个字符"。这三件事对应的,恰好就是 JMeter 做接口自动化绕不开的四个考点:参数化、关联、断言、正则。
这篇就把这四个考点全部落在一个具体场景上——天气查询接口。它天然包含一条两段式链路:先按城市名搜出城市 ID,再拿 ID 查实时天气,中间必须做关联;城市列表天然适合做参数化;返回体是 JSON,字段多、有取值范围,正好练断言和正则。不管你是刚装完 JMeter 想找个能跑通的练手项目,还是已经写了半年脚本但一直靠肉眼点结果树,这篇里的步骤和踩坑记录都能直接用上。
1. 天气接口这条链路,为什么恰好卡在四个考点上
1.1 一条查询请求里藏着的依赖关系
先看这条链路长什么样。天气服务一般不会让你直接用城市名查天气,因为重名城市太多,所以标准设计是两段式:第一段/api/city/search?name=杭州,返回一个列表,里面有cityId、cityName、province;第二段/api/weather/now?cityId=101210101&key=xxx,返回温度、湿度、风力、更新时间。第三段还可能有一个/api/weather/forecast?cityId=...查未来几天。
这个设计本身没问题,但放到自动化脚本里就出现了硬依赖:第二个请求的参数值,来自第一个请求的响应体。如果我把101210101写死在脚本里,那这个脚本就只能测杭州,换个城市就得改脚本——这就引出了参数化。而要把响应体里的cityId抠出来塞进第二个请求——这就是关联;抠出来这个动作靠什么工具完成,最常用的就是正则表达式提取器。
再看断言。天气接口的返回体里,code字段是业务状态码,data.temp是温度值,data.updateTime是时间字符串,data.city是城市信息。HTTP 200 只说明你的请求到达了服务端,服务端也回了话,至于回的是"成功"还是"你 key 过期了",只有看code才知道。我见过太多团队的脚本,断言只有一句"响应代码等于 200",这种脚本的价值约等于零。
所以天气接口不是一个"随便挑的练手项目",它的结构刚好把参数化、关联、断言、正则四件事全部串在一条主线上,而且链路短,出错了容易定位。换成电商下单链路,十几个接口串起来,新手还没跑到断言那一步就已经放弃了。
1.2 先把测试计划的分层想清楚,再动手拖元件
JMeter 最大的坑不是元件不会用,是元件放错位置。同一个"响应断言",放在线程组下面和放在某个 HTTP 请求下面,作用范围完全不同——前者对所有取样器生效,后者只对当前请求生效。所以动手之前,先在纸上把分层画出来,比在 GUI 里拖半小时有用。
我在实际项目里固定用这套分层逻辑:
- 测试计划级:放用户定义的变量、HTTP 请求默认值、HTTP 信息头管理器。这三样是所有请求共用的底座,改了以后全局生效,不用一个个请求去改。
- 线程组级:定义并发数、循环次数、调度器。参数化配置文件(CSV Data Set Config)我也习惯放在线程组下面,因为它的共享模式是跟线程组绑定的。
- 取样器级:HTTP 请求本身,加上只属于它的后置处理器(正则提取器)、断言、定时器。
- 监听器:调试阶段挂察看结果树,正式跑的时候全部关掉,只留聚合报告或者干脆用命令行生成 HTML 报告。
关于 JMeter 版本,我目前用的是 5.6.x,配套 JDK 17(5.6 要求 Java 8 以上,官方更推荐 17)。别用 JDK 8 去跑 5.6,某些 JSON 相关的元件会出奇怪的兼容问题。至于环境变量,把%JMETER_HOME%\bin加到 PATH 里,后面命令行模式会方便很多。
1.3 中文乱码这件事,开工前就该按死
乱码是新手最容易被绊住的地方,而且它的表现很迷惑:请求发出去了,响应也有内容,但中文全是问号或者方块。三个地方要一起改,缺一个都不行。
第一,jmeter.properties里找到sampleresult.default.encoding,取消注释并改成UTF-8。这一行决定 JMeter 用什么编码去解析响应流,不改的话默认是 ISO-8859-1。
第二,CSV 数据文件本身的编码要和 CSV Data Set Config 里填的编码一致。Windows 上用记事本另存的 CSV,很容易变成带 BOM 的 UTF-8,这时候第一个变量名的前面会多出一个不可见字符,你会看到变量名是cityCode但引用${cityCode}取不到值——这种问题能查一下午。
第三,如果是从命令行启动,加上编码参数:jmeter -n -t test.jmx -Dfile.encoding=UTF-8 -l result.jtl。GUI 模式下一般不用管,但如果你的机器默认区域设置是非中文环境,加上更保险。
提示:CSV 文件优先用 VS Code 或 Notepad++ 另存为"UTF-8 无 BOM",不要用 Excel 直接另存,Excel 默认会带上 BOM 和平台相关的换行符。
还有一点,GUI 模式只用来调试和录制,真正的压测一定要走命令行。GUI 本身要渲染结果树、要刷新界面,一台普通办公机跑 100 并发,光是 GUI 渲染就能吃掉大半 CPU,压出来的数据根本不能看。
2. 参数化:把城市列表喂给线程组的几种姿势
2.1 CSV Data Set Config 的八个字段,每个都值得停一秒
CSV Data Set Config 是参数化里用得最多的元件,但它那八个输入框,我见过至少一半的测试人员从来没把"共享模式"那一项点开看过。逐个拆一遍:
| 字段 | 含义 | 实际踩过的坑 |
|---|---|---|
| Filename | 数据文件路径 | 相对路径以 JVM 启动目录为基准。GUI 下双击 jmeter.bat 启动,基准是 bin 目录;命令行下从项目目录启动,基准就变了。建议直接写绝对路径 |
| File encoding | 文件编码 | 留空等于用系统默认编码,跨平台迁移必炸。明确写 UTF-8 |
| Variable Names | 变量名列表 | 留空则用文件首行当变量名,此时必须勾选 Ignore first line;填了变量名,首行就可以是纯数据 |
| Delimiter | 分隔符 | 默认英文逗号。要填制表符就写\t(反斜杠加 t,不是真的按 Tab 键) |
| Allow quoted data | 是否允许引号包裹 | 数据里本身含逗号时必须打开,比如"杭州,西湖区" |
| Recycle on EOF | 读完后是否回头重读 | 默认 True。线程循环次数乘以线程数大于总行数时,关掉它线程会提前结束,报告里线程数对不上 |
| Stop thread on EOF | 读完后是否停止线程 | 和上一项配合使用,一般保持 False |
| Sharing mode | 共享模式 | 决定文件指针怎么分配,最容易理解错的一项 |
共享模式三选一的实际效果,说白了就是"文件指针有几个"。
选All threads(默认):整个测试计划里只有一个文件指针,所有线程按顺序抢着读下一行。线程 1 读第 1 行,线程 2 读第 2 行,互不重复。这是最常用的模式,数据不会被重复消费。
选Current thread group:每个线程组各自持有一个文件指针。如果你有两个线程组共用同一个文件,它们会各自从第一行开始读,数据就被重复用了。
选Current thread:每个线程各自持有一个独立的文件指针,都从第一行开始读。结果就是线程 1 读第 1 行、线程 2 也读第 1 行。这个模式适合"每个线程都要完整遍历一遍数据集"的场景,比如每个虚拟用户都要把所有城市查一遍。
2.2 线程数、循环次数、数据行数三者之间的关系
假设你有 20 个城市,50 个线程,每个线程循环 3 次,Recycle on EOF 保持默认的 True,共享模式选 All threads。那么实际执行下来,每一轮循环会把 20 行数据跑完,然后回头重来,总共 150 次请求里,每个城市会被请求 7 到 8 次,具体哪个城市多一次取决于时序。
如果你把 Recycle on EOF 设成 False,那第 21 次请求开始就取不到值了,变量会变成${cityCode}这个字面量,或者变成默认值。很多人在这里翻车:看到请求还在发,就以为是正常的,实际上后面 130 次请求发的都是垃圾数据。
所以算数据量的时候要记住这个公式:需要的行数 ≥ 线程数 × 循环次数(All threads 模式下),否则一定要打开 Recycle。而 Current thread 模式下,每个线程都要独立跑完,需要的行数是线程数 × 循环次数,但取值会大量重复。
2.3 想按线程"分块取值",CSV 本身做不到
这是被问得最多的一个问题:同一个 CSV 文件,怎么让线程 1 只取第 1 到 5 行,线程 2 只取第 6 到 10 行,各自管各自的一块,互不干扰?
答案很直接:CSV Data Set Config 做不到。它只能按顺序发号,或者每个线程从头读,没有"按块切分"这个概念。要实现分块,得自己算行号。
我的做法是在线程组下挂一个 JSR223 PreProcessor,语言选 Groovy(比 BeanShell 快,也是官方现在推荐的),把整个文件读进内存,用线程编号和迭代次数算出目标行:
// JSR223 PreProcessor,Language 选 groovy def lines = new File("D:/testdata/cities.csv").readLines() // 如果首行是表头,去掉它 if (lines.size() > 0 && lines[0].startsWith("cityCode")) { lines = lines.tail() } int blockSize = 5 int threadNo = ctx.getThreadNum() // 线程编号,从 1 开始 int iterNo = vars.getIteration() // 当前线程的第几次循环,从 1 开始 int row = (threadNo - 1) * blockSize + (iterNo - 1) if (row >= lines.size()) { row = row % lines.size() // 越界回绕,防止取到空值 } def cols = lines[row].split(",") vars.put("cityCode", cols[0]) vars.put("cityName", cols[1]) log.info("线程 {} 第 {} 次循环取到第 {} 行:{}", threadNo, iterNo, row, cols[0])这段代码里有几个细节值得说。ctx是 JMeterContext 实例,vars是当前线程的变量表,这两个在 JSR223 元件里是内置的,不用自己 new。vars.getIteration()返回的是 int,从 1 开始计数,这个计数是"当前线程的第几次循环",不是全局计数,所以在分块算法里正好合适。
row % lines.size()这一句是兜底。如果不做回绕,一旦行号越界,lines[row]会抛 IndexOutOfBoundsException,整个线程直接报错,报告里会出现一堆莫名其妙的失败。回绕之后,数据会从头再来一遍,虽然有点重复,但至少不会把脚本跑挂。
这个方案的代价是数据全量加载到内存里。城市列表这种几百行的文件完全无所谓,如果是百万级的号码池,就不该用这个方法,应该走数据库或者专业的测试数据服务。
2.4 JDBC Request 参数化:什么情况下值得上数据库
当测试数据超过几千行、或者需要多个脚本共用同一份数据、或者数据本身要经常由业务系统维护的时候,把数据放进数据库比放文件合理得多。
JMeter 里的做法是加一个 JDBC Connection Configuration 配置元件(放在线程组下),填好 JDBC URL、驱动类名、用户名密码,然后加 JDBC Request 取样器。关键在三个地方:Query Type 要选Select Statement;Result variable name 随便起个名,比如cityResult;如果想按行取值,可以在 SQL 里用占位符配合参数化:
SELECT city_code, city_name FROM dim_city WHERE province = ? ORDER BY city_code LIMIT ?然后在 JDBC Request 的 Parameter values 里填${province},${pageSize},Parameter types 里填对应的类型(字符串用 VARCHAR,数字用 INTEGER)。如果不想用占位符,也可以直接在 SQL 里写${province},但那样就失去了预编译的意义,而且字符串要自己加引号,容易出错。
取出来的结果集怎么用?如果是单行单列,直接用${cityResult_1}。多行的话是${cityResult_1}、${cityResult_2}……同时有一个${cityResult_#}告诉你一共返回了几行。想随机取一行,可以用${__Random(1,${cityResult_#},)}生成下标,再拼成${cityResult_${__Random(1,${cityResult_#},)},}。这个嵌套看起来有点绕,但是能跑。
注意:JDBC 驱动 jar 要放到
lib/目录下重启才生效。MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,不是老的com.mysql.jdbc.Driver,写错了会报 ClassNotFound。另外连接池的 Max Number of Connections 要大于等于线程数,否则高并发下会出现线程排队等连接,压出来的响应时间全是假的。
2.5 随机值、时间戳、UUID:有些参数化不该用文件
不是所有参数都适合从文件里读。比如请求里的时间戳、随机数、防重放的 UUID,这些每次请求都该变,写文件里反而累赘。JMeter 内置函数就能搞定:
${__time(yyyy-MM-dd HH:mm:ss,)}生成格式化时间,天气接口的预报查询经常需要传日期。${__timeShift(yyyy-MM-dd,P1D,,)}在指定日期基础上偏移,P1D是加一天,查明天天气正好用。${__Random(1,100000,)}生成随机整数,最后那个参数是变量名,留空就不存变量。${__RandomString(16,abcdef0123456789,)}生成随机字符串,适合做流水号。${__UUID()}生成通用唯一标识。
还有一个坑要单独提:很多天气服务要求把参数按字典序拼接后加密钥做摘要(MD5 或 SHA256),这个摘要没法用普通参数化搞定。我的做法是在线程组下加一个 JSR223 PreProcessor,把所有参数取出来排序拼接,算完哈希再存回变量:
import java.security.MessageDigest def params = ["cityId": vars.get("cityId"), "key" : vars.get("apiKey"), "ts" : vars.get("ts"), "nonce" : vars.get("nonce")] def raw = params.sort().collect { k, v -> "${k}=${v}" }.join("&") def digest = MessageDigest.getInstance("SHA-256") .digest(raw.getBytes("UTF-8")) .encodeHex().toString() vars.put("sign", digest)这样第二个请求里直接写${sign}就行。注意vars.get()取到的都是字符串,如果服务端要求数字参与签名,别自己加引号去拼。
3. 关联:把上一个响应里的钥匙递给下一个请求
3.1 关联失败的九成原因,是边界没卡住
关联这个词听着高级,本质就一句话:从上一个响应里抠出一段内容,存成变量,下一个请求引用它。抠的动作是正则表达式提取器(或者 JSON 提取器)干的,用的动作是变量引用干的。
新手关联失败,绝大多数不是正则写错了,而是边界卡错了。举个真实例子:响应体里同时有"cityId":"101210101"和"parentCityId":"101210100",你用cityId":"(\d+)"去提,第一个匹配到的可能是parentCityId那一行,因为cityId":"这个片段在parentCityId":"里也被包含。结果你提出来的城市 ID 是父级城市的,接口能返回数据,但返回的是错的城市,脚本全绿,数据全错。这种 bug 最要命。
解决办法是在正则前面加上不会歧义的边界字符,比如"cityId"\s*:\s*"(\d+)",把双引号带上。如果响应体里有换行和空格,\s*就是用来兜住"cityId" : "101210101"这种带空格的格式。
3.2 正则提取器的五个输入框,一次说透
正则表达式提取器的界面上有五个需要动的地方,逐个说。
Name of created variable:存进变量的名字,比如写cityId,后面就用${cityId}引用。
Regular Expression:正则本体。这里要记住 JMeter 用的是 Java 正则,不是 JavaScript 正则,两者有细微差别。三个高频技巧:(?s)加在正则最前面可以让.匹配换行符,抓跨行内容时必须加;.*?是非贪婪匹配,比.*安全,但在大响应体上性能略差;[^"]*表示"非引号字符重复任意次",抓 JSON 里的字符串值最可靠,因为它天然停在引号边界上。
Template:模板,也就是最终存到什么。$1$表示第一个捕获组,$1$-$2$表示把前两个捕获组拼起来。这里最容易错的是写成\1或者$1(少了后面的$),JMeter 只认$1$这种写法,写错了提出来的是空字符串,而且不报错。
Match No.:匹配编号。填1表示取第一个匹配,填0表示随机取一个匹配,填-1表示取全部匹配。填-1是个非常有用的技巧:假设变量名是cityId,那么取到全部之后,${cityId_1}是第一个、${cityId_2}是第二个,同时${cityId_matchNr}会告诉你一共匹配到几个。这在"搜索结果列表里随机挑一个城市"的场景下很好用——用${__Random(1,${cityId_matchNr},)}生成下标,再拼成${cityId_${__Random(1,${cityId_matchNr},)},}。
Default Value:默认值。这一项千万别留空。正则没匹配到的时候,如果默认值是空的,变量就变成空字符串,下一个请求发出去是?cityId=,服务端可能返回一个模糊的错误,你查半天不知道怎么肥事。我的习惯是填一个明显不可能的值,比如NOT_FOUND,这样在察看结果树里一眼就能看出来是关联失败了。
还有两个界的面的选项:Use empty default value勾上等于默认值强制为空,一般不勾;Apply to决定从哪个范围里找,默认是 Main sample and sub-samples,如果响应里有重定向或者嵌入资源,这个范围会扩大,抓到意外内容的风险就高了,建议改成 Main sample only。
3.3 返回体是 JSON 的时候,优先用 JSON Extractor
正则虽然万能,但在 JSON 面前确实有点苦。{"data":{"city":{"id":123,"name":"杭州"},"temp":18}}这种嵌套结构,用正则抓temp,你得写"temp"\s*:\s*([\d.-]+),能抓到,但是如果响应里还有别的地方也叫 temp 呢?嵌套层级一深,正则的可维护性会断崖式下降。
JMeter 从 4.0 开始自带 JSON Extractor(用 JSONPath 语法),用法比重正好:写$.data.city.id就能拿到123,写$.data.temp拿到18,写$.data.list[*].name配合 Match No. 为 -1 就能把所有名字抓出来。JMeter 5.6 里还有一个 JSON JMESPath Extractor,语法略有不同,功能类似。
但正则不要急着丢。有两个场景我依然用正则:一是响应体的结构不规则,或者埋在一堆 HTML 里(这里顺带一提,用 JMeter 测老式 MVC 项目时,__RequestVerificationToken这个防伪标记就藏在表单的 hidden input 里,标准做法就是用正则从页面里把 value 抠出来,再作为参数回传,和 JSON 提取是同一个道理);二是要提的内容和前后文有强依赖关系,比如订单号:([A-Z0-9]{12}),用正则反而更直观。
判断标准很简单:数据结构化就用结构化工具,数据埋在一堆噪声里就用正则。别拿正则硬啃深层 JSON,也别拿 JSONPath 去啃 HTML。
3.4 跨线程组的参数怎么传
同一个线程组里,提取出来的变量在下个请求里直接${cityId}就完事了。但如果链接被拆到了两个线程组——比如线程组 A 做登录拿凭证,线程组 B 做业务查询——变量就不通了,因为每个线程组的变量作用域是隔离的。
这时候要用 JMeter 属性(Property)做中转。线程组 A 里用__setProperty把值写进全局属性,线程组 B 里用__P读出来:
线程组 A 的 JSR223 PostProcessor: ${__setProperty(globalCityId,${cityId},)} 线程组 B 的 HTTP 请求: /api/weather/now?cityId=${__P(globalCityId)}__setProperty的第二个参数是要写入的值,第三个参数是"是否覆盖已有值",留空表示覆盖。用${__P(名称,默认值)}读取时,第二个参数是拿不到时的兜底值,强烈建议填上,比如${__P(globalCityId,NONE)},不然属性不存在时引用会原样输出字符串${__P(globalCityId)},很难排查。
还有一个前提条件:必须勾选测试计划上的Run Thread Groups consecutively(顺序执行线程组),否则两个线程组可能同时跑,线程组 B 在读属性的时候,线程组 A 可能还没写完。属性是测试计划级的全局对象,多个线程同时读写会有竞争,这一点在并发场景下尤其要注意。
4. 断言:让脚本自己说清楚哪一条挂了
4.1 Response Assertion 的三个维度
响应断言(Response Assertion)是使用频率最高的断言元件,它有门口三个维度要选对,选错了一个就全错。
Field to Test(要测哪个字段):常用的是 Response Code(响应码)、Response Message(响应消息)、Response Data(响应体)、Response Headers(响应头)、Request Headers(请求头)、URL sampled(请求的 URL)。新手最容易犯的错是想校验响应体内容但选了 Response Code,结果断言永远通过。
Pattern Matching Rules(匹配规则):Contains(包行)、Matches(完全匹配,这里用的是整个字符串匹配,正则要写完整)、Equals(相等)、Substring(子串)、Not(取反)、以及可以组合的 Or。最容易被误用的是 Matches——它要求整个字段与模式完全一致,写Matches加200去比响应码,是能过的,但写Matches加success去比一大坨响应体,永远不会过。
Patterns to Test(要匹配的内容):一行一个模式,多行之间默认是"或"关系,要改成"并且"就点下面的 And 按钮。
一个可以直接抄的配置组合,用三个断言覆盖三层:
| 断言目标 | Field to Test | 规则 | 模式 |
|---|---|---|---|
| 接口通了 | Response Code | Equals | 200 |
| 业务成功 | Response Data | Substring | "code":0 |
| 不是错误页 | Response Data | Not + Contains | "error" |
注意第三个用了 Not + Contains 的组合,意思就是响应体里不能包含 error 这个词。这类反向断言在回归测试里非常有用,能把那些"返回了 200 但回的是错误页面"的情况拦住。
4.2 200 不等于成功,JSON 断言要校验到字段级
JSON 断言(JSON Assertion)是 JMeter 4.0 之后新增的,用起来很简单:写一个 JSONPath,勾上 Additionally assert value,再填上期望值,就成了。比如 JSONPath 写$.code,期望值写0,这一条就同时完成了"字段存在"和"值正确"两件事。
但它有个限制:只能做相等判断。温度是 18 还是 18.2,它管不了,也做不了范围判断。所以我的分层做法是:
- 协议层:响应码 200,加上响应头里的
Content-Type包含application/json。这一层保证通信正常。 - 业务层:用 JSON 断言校验
$.code等于 0,校验$.data.city.id等于请求里发出去的那个城市 ID。这一条很重要,它能拦住"参数没生效,服务端用了默认值"这种沉默的错误。做关联的时候,我习惯把请求参数和响应内容做一个交叉验证,两边对得上才认为关联是成功的。 - 数据层:用 JSR223 断言做范围和格式校验。
数据层为什么不能也用 JSON 断言?因为要判断的东西太活了。温度得在合理区间内,更新时间得是合法的日期格式,风力等级得是整数且在 0 到 12 之间,湿度得是 0 到 100。这些判断逻辑用相等断言写不出来,只能上脚本。
4.3 JSR223 断言:范围判断、枚举判断与容差
JSR223 断言支持 Groovy、JavaScript 等语言,我固定用 Groovy。它的核心 API 就三个:AssertionResult.setFailure(true)标记失败、AssertionResult.setFailureMessage("...")写失败原因、prev拿到上一个取样器的结果。
一段可以覆盖大部分天气校验场景的代码:
import groovy.json.JsonSlurper def respCode = prev.getResponseCode() def body = prev.getResponseDataAsString() // 第一层:协议层校验 if (respCode != "200") { AssertionResult.setFailure(true) AssertionResult.setFailureMessage("HTTP 状态码异常:" + respCode) return } // 第二层:能解析成 JSON 吗 def json try { json = new JsonSlurper().parseText(body) } catch (Exception e) { AssertionResult.setFailure(true) AssertionResult.setFailureMessage("响应不是合法 JSON:" + body.take(200)) return } // 第三层:字段级校验 if (json.code != 0) { AssertionResult.setFailure(true) AssertionResult.setFailureMessage("业务码异常:" + json.code + ",msg=" + json.msg) return } def temp = json.data?.temp if (temp == null || !(temp instanceof Number)) { AssertionResult.setFailure(true) AssertionResult.setFailureMessage("temp 字段缺失或类型不对:" + temp) return } // 范围校验:零下 60 到零上 60 度,超出就是数据异常 if (temp < -60 || temp > 60) { AssertionResult.setFailure(true) AssertionResult.setFailureMessage("温度值超出合理区间:" + temp) return } // 格式校验:时间字符串必须是 yyyy-MM-dd HH:mm:ss def updateTime = json.data?.updateTime def pattern = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/ if (updateTime == null || !(updateTime ==~ pattern)) { AssertionResult.setFailure(true) AssertionResult.setFailureMessage("updateTime 格式不符合预期:" + updateTime) }这段代码里有三个技巧值得展开。
第一,return用得很密。一旦某个前置条件不满足,立刻标记失败并返回,不要继需往下跑。不返回的后果是:响应不是 JSON 的时候,json.code那行会抛异常,异常信息会把真正的失败原因埋掉,你看到的是一堆堆栈,而不是"响应不是合法 JSON"。
第二,失败信息里一定要带上具体的值。"温度值超出合理区间:" + temp和干巴巴的一句"温度校验失败",排障效率差十倍。前者在聚合报告里一眼就能看到出问题的具体数值。
第三,==~是 Groovy 的完整匹配运算符,等价于 Java 里的Pattern.matches。它要求整个字符串完全匹配这个模式。如果只想判断"包含",用=~。这两个符号长得很像,用错了不会报错,但会得到完全相反的结论,实测的时候一定要用一条明知会失败的用例去验证断言真的能挂。
顺便说一个容差问题。浮点数的比较永远不要用==去比。服务端返回 18.300000000000001 和你期望的 18.3,直接比是不相等的。做数值校验的时候用Math.abs(actual - expected) < 0.01这种形式,别嫌麻烦,这是踩过坑的人才会写的一行代码。
4.4 断言不是越多越好,作用域放大是全链路灾难
这一点我被坑过一次,印象特别深。当时我在线程组下面放了一个"响应码等于 200"的断言,本意是想让所有请求都校验响应码。结果测试计划里有一个 JDBC Request 取样器,它没有 HTTP 响应码这个概念,这个断言就对着它一顿判,直接把所有数据库查询都标成失败。排查的时候看错误信息完全摸不着头脑,最后才发现是断言的继承关系在作怪。
规则很简单:断言和定时器、后置处理器一样,都遵循"就近原则",放在哪一层就管哪一层以及它下面的所有取样器。所以我现在的习惯是:公共断言尽量不放在线程组级,而是放在每个 HTTP 请求下面。听起来有点琐碎,但出问题的时候定位成本低得多。
性能上也有代价。JSR223 断言里如果拿整个响应体去做正则匹配,大响应体上单次开销可能到几十毫秒。一百个并发乘上去就很可观了。所以断言里的正则要尽量精确,不要用.*去扫全文本,能不解析 JSON 就不解析,能只取必要字段就不要反复getResponseDataAsString()。
5. 四个考点串成一条链:脚本落地与实测
5.1 一份可以直接照抄的测试计划结构
前面讲了四个独立的点,现在把它们组装起来。下面这套结构我用了很久,稍微改参数就能套到别的接口上:
测试计划 weather-auto-test ├─ 用户定义的变量 │ ├─ apiHost = https://your-weather-host │ ├─ apiKey = YOUR_KEY │ └─ timeout = 5000 ├─ HTTP 请求默认值(服务器、端口、协议、编码、超时) ├─ HTTP 信息头管理器(Content-Type: application/json;charset=UTF-8) ├─ 线程组:查询天气(线程数 5,循环 5,调度器关闭) │ ├─ JSR223 PreProcessor(分块取值,计算 cityCode) │ ├─ 事务控制器 "完整查询链路" │ │ ├─ HTTP 请求:城市搜索 /api/city/search │ │ │ ├─ JSON 提取器 cityId ← $.data[0].id │ │ │ ─ 响应断言(状态码 200 + JSON 提取器默认值检查) │ │ ├─ HTTP 请求:实时天气 /api/weather/now │ │ │ ├─ JSON 断言 $.code == 0 │ │ │ ├─ JSON 断言 $.data.cityId == ${cityId} │ │ │ └─ JSR223 断言(范围与格式校验) │ │ └─ 统一随机定时器(延迟 300ms,模拟真实间隔) │ ├─ 聚合报告 │ └─ 察看结果树(仅调试时启用,压测前禁用)有几个地方要解释一下为什么这么放。
事务控制器用在这儿的价值在于,它把"搜索城市"和"查询天气"两个请求合并成一条事务,聚合报告里看到的就是这条完整链路的响应时间,而不是两个割裂的数字。如果只看单个请求,你永远不知道用户在完成一次"查天气"这个动作时到底等了多久。事务控制器上有一个 Generate parent sample 的复选框,勾上之后,聚合报告里只显示事务这一行,两个子请求的数字被隐藏,报告会干净很多。
定时器的位置也很讲究。JMeter 里的定时器作用域同样遵循就近原则,放在事务控制器下面,它就会在每个取样器前都加一次延迟,相当于两个请求都延迟了 300ms。如果你只想在事务之间加间隔,应该把定时器放到事务控制器外面,或者用 Flow Control Action 取样器。定时器的时间设置上,我一般不追求模拟得特别真,300ms 到 1s 之间随便取,目的是避免本地压测把服务端的连接数瞬间打满,那属于压测范畴,不是功能自动化该关心的事。
JSON 提取器给默认值这一条是我现在写脚本的硬性规范。城市搜索如果没找到结果,$.data[0].id提取不到,变量就是空的,第二个请求会带着空参数发出去。给默认值NONE之后,第二个请求发的是cityId=NONE,服务端的响应会明确告诉你参数非法,你也一眼就能看出是关联断了。这比对着一个空请求发呆强太多。
5.2 跑一遍看看输出:从 200 到"业务通过"
脚本搭好之后,第一次跑建议把线程数设成 1、循环设成 1,把察看结果树打开,请求和响应的内容全看一遍。重点看四个地方。
看请求参数是否正确。在察看结果树的请求标签页里确认cityId=101210101,而不是cityId=或者cityId=${cityId}。如果是后者,说明变量名写错了,或者提取器的变量名和引用名对不上(大小写敏感,cityId和cityid是两个变量)。
看提取器是否命中。JMeter 在调试阶段很有用的一个技巧是,把提取器的默认值设成NONE之后,如果请求里出现了NONE,就说明正则没匹配上。这时候回到响应体里,把正则贴进去重新数一遍引号和空格。九成的失败都是空格问题:响应里是"id": 101210101,带一个空格,你的正则是"id":(\d+),没写\s*,自然匹配不上。写正则的时候宁可写松一点,加上\s*兜住格式差异,也不要不写,因为服务端升级一次,序列化格式就可能变。
看断言有没有真的执行。断言失败之后,察看结果树的取样器会标红,右侧的"断言结果"面板里会显示失败原因。这里有一个必做的验证动作:故意把期望值改错(比如把$.code的期望值从 0 改成 999),跑一次,确认脚本真的变红了,再改回来。没验证过会失败的断言,等于没有断言。我见过太多脚本里的断言是花的,从来没红过,因为断言写错了永远能过。
看响应时间的大致分布。单线程单循环的时候,响应时间一般在几十到几百毫秒之间,如果第一条就是五六秒,那大概率是网络问题或者服务端限流,先把超时设置调大确认能通,再谈后面的并发。HTTP 请求默认值里的超时我一般设连接 5000ms、响应 10000ms。
5.3 那些绕不过去的报错,怎么解
java.io.IOException: error writing to server:这个报错在压测和功能测试里都会遇到,它不是 JMeter 的问题,而是连接被对端断掉了。常见原因有四个:请求体太大超过了服务端的限制(调大 JMeter 的堆内存并检查请求体大小);服务端处理时间太长,在返回前就把连接关了(调大超时);HTTP 信息头里带了Expect: 100-continue,服务端不买账(在信息头管理器里把它删掉);用了 keep-alive 复用连接,但服务端短连接(在 HTTP 请求的 Implementation 里改成 HttpClient4,或者加一个Connection: close请求头)。排查这类问题时,先用 curl 发一遍同样的请求,curl 能通就说明是 JMeter 的配置问题,curl 也不通就说明是对端的问题,这一步能把排查范围砍一半。
证书相关:测 HTTPS 接口时,如果服务端用的是自签证书,JMeter 会直接报 SSL 握手失败。两种处理方式:一是把服务端证书导入到 JMeter 用的信任库里(keytool -importcert打进bin/下的 cacerts,或者自己指定一个信任库文件并配上密码);二是调试阶段干脆在 HTTP 请求的配置里关掉证书校验,但正式回归绝对不能关,那等于把测试的有效性扔了。另外,如果你想用 JMeter 自带的录制功能录 HTTPS 脚本,需要先装 JMeter 生成的根证书,这个证书在第一次启动录制时会在bin/目录下自动生成,按提示导入系统信任库就行。
防伪标记未提供的报错:测一些传统 MVC 项目的时候,会碰到类似__RequestVerificationToken 未提供必要的防伪标记的返回。这本质上就是一个关联问题:页面里有一个 hidden 字段带着一次性令牌,表单提交时必须带上它。做法就是在访问页面的请求下挂一个正则提取器,从 HTML 里把 value 抓出来,再作为 POST 参数传到提交请求里。正则大概长这样:
name="__RequestVerificationToken"[^>]*value="([^"]+)"模板填$1$,变量名填token。这种场景特别能体现"关联不等于只抓 JSON"——数据藏在 HTML 属性里的时候,正则就是唯一的选择。
中文乱码:前面提过了,sampleresult.default.encoding=UTF-8,CSV 用 UTF-8 无 BOM。区分一下乱码的来源,请求里乱码是编码问题,响应里乱码也是编码问题,只有两者的编码配置都对了才能都正常。
5.4 模拟 100 用户并发之前的三个前提
功能脚本跑通了,不代表能直接拿去压测。100 并发之前,有三个前提要先解决,否则压出来的数据毫无参考价值。
第一,合到非 GUI 模式。命令大致是这样:
jmeter -n -t weather.jmx -l result.jtl -e -o ./report -Jjmeter.save.saveservice.output_format=csv-n非 GUI,-t指定脚本,-l指定结果文件,-e -o生成 HTML 报告。注意-o指向的目录必须是不存在的或者空的,否则会报错退出。如果结果文件已经存在,也会报错,我通常会在命令前面加一个删除旧文件的动作。
第二,调整堆内存。默认的 HEAP 是 1G,100 线程加上结果收集,很容易 OOM。改的方式是编辑bin/jmeter(Linux)或bin/jmeter.bat(Windows),找到 HEAP 那一行,改成-Xms1g -Xmx4g。同时建议把结果收集里的"保存响应数据"关掉,压测时保存每一条响应体会让内存增速快得离谱。
第三,想清楚这次压测的目的是什么。如果目的是找上限,那线程数应该阶梯上升,先 50 再 100 再 200,看在哪一档响应时间开始指数上升;如果目的是验证"100 并发下能不能扛住",那就要保证 100 并发能真实打出去,别用 10 个线程循环 10 次去假装 100 并发,那两件事在服务端眼里完全不同。
顺带说一个本地机器的限制。单机 JMeter 跑 100 个 HTTP 线程,CPU 和网络一般是够的,但如果响应体很大、要解析 JSON、还要跑 JSR223 断言,瓶颈可能在 JMeter 自己身上。判断方法是看聚合报告的吞吐量是不是先上升后平稳:如果线程数加了吞吐量不动,说明 JMeter 已经到极限了,这时候要上分布式压测,一台控制机加多台执行机,参数和脚本保持一致,用-R指定远程节点。
6. 脚本从能跑到有人敢用:报告、规范与维护
6.1 察看结果树导出与聚合报告里真正该看的列
察看结果树的定位是调试工具,不是报告工具。但它在调试期确实需要导出数据,做法有两种:一是用工具栏的"配置"按钮勾选需要的字段(比如只勾请求头、响应数据、断言结果),然后通过监听器的文件名输入框和"保存"动作把当前结果落到 JTL 文件里;二是更推荐的做法——另外加一个 Simple Data Writer 监听器,专门负责把结果写到文件,察看结果树只管显示不管存储。这样两个职责分开了,压测的时候把察看结果树一关,数据照样有。
注意 JTL 文件的格式。JMeter 5.6 默认写的是 XML 格式的 JTL,体积很大,100 万条采样能写到几个 G。改成 CSV 格式体积能小一个数量级,改的方式是编辑jmeter.properties里的jmeter.save.saveservice.output_format=csv,或者在启动命令里加-Jjmeter.save.saveservice.output_format=csv。另外还要想清楚要不要保存响应数据,jmeter.save.saveservice.response_data=false是压测时的标配。
聚合报告里最容易被忽略的是分位数。Average 会被极端值拉高,看平均响应时间很容被骗。真正有参考价值的是 90% Line 和 95% Line——90% Line 是 300ms,意思是 90% 的请求在 300ms 内完成,剩下一成慢得多。要看长尾就看 99% Line。还有一个容易看错的列是 Throughput,它的单位是"每秒完成的请求数",注意它统计的是取样器数量,如果你的脚本里一个事务包含两个请求,那吞吐量看起来会是并发数的两倍,这是正常的。
6.2 断言的三层设计:协议层、业务层、数据层
前面在讲断言的时候已经提到这三层,这里把它单独拎出来说,因为这是脚本质量的分水岭。
只有协议层断言的脚本,能拦住网络问题和 5xx,但拦不住业务异常,价值很低。加上业务层断言,能拦住参数错误、权限不足、数据为空,这已经能覆盖大部分回归场景。再加数据层断言,才能拦住"数据算错了"这种最难发现的问题——温度值是 180 度、更新时间是去年、城市 ID 和请求的对不上,这三类错误在业务层断言下全都是绿的。
这三层的成本递增,收益也递增。我的经验是:核心链路的断言必须三层齐全,边缘接口至少要有协议层加业务层。判断一个接口是不是核心链路,看它挂了之后用户能不能感知到——天气查询挂了用户立刻知道,那就是核心链路。
写断言的时候还有两个规范值得坚持。一是失败信息要包括具体值,"业务码异常:1002,msg=invalid key"比"断言失败"有用一百倍,尤其是在 CI 里跑的时候,你只能看到报告,看不到响应体。二是每条断言都要能独立判断出问题在哪一层,别把三层逻辑写在一个 JSR223 断言里不加区分,那样失败信息只能告诉你"挂",不能告诉你"哪一层挂了"。
6.3 让脚本活过三个月:把易变的东西抽出去
接口自动化脚本真正的敌人不是技术难点,是维护成本。一个脚本写完,两个月后服务端改个字段名,如果你的脚本里这个字段名散落在二十个地方,那就等着加班吧。
我的做法是三个"抽出来"。环境地址抽出来:用用户定义的变量加${__P()},本地、测试、预发三套地址通过启动参数切换,脚本本身一行不改。测试数据抽出来:城市列表、账号密码放 CSV 或数据库,脚本里只留引用。断言规则抽出来:比如"温度合理区间是 -60 到 60",把这个数字写到用户定义的变量里,断言脚本去读变量,而不是把数字硬编码在 Groovy 代码里。业务规则变了,改一个变量就行。
还有一个很小的习惯,收益特别大:给每个 HTTP 请求起一个人看得懂的名字。JMeter 默认的名字是"HTTP 请求",十个请求排下来全是"HTTP 请求",报告里出了问题你根本定位不到是哪个接口。命名规范我用的是"动作 - 接口路径",比如"查询天气 - /api/weather/now",一眼能看出来在干什么。
最后说一个我用了很多年的自检清单,每次脚本要交付之前过一遍:
- 断言是否真的验证过会失败?故意改错期望值跑一遍,确认能变红。
- 参数化的默认值是否都填了?空默认值是最隐蔽的坑。
- 提取器的默认值是否填了一个明显不可能的值?这样关联断了能一眼看出来。
- 线程数乘以循环次数是否大于数据行数?不满足就要确认 Recycle on EOF 的设置。
- 察看结果树是否在正式跑的时候关掉了?它的内存开销比你想象的大得多。
- 脚本里是否还有写死的 IP、端口、密钥?有就抽成变量,为下一步做准备。
这套东西没有什么高深的技术含量,都是被坑出来的。我个人的体会是,JMeter 这个工具的门槛不在元件怎么拖,而在你知不知道自己每一个配置项为什么这么填。参数化、关联、断言、正则这四件事,学一遍可能只要一天,但把这四件事在每一个接口上都做对,需要的是对接口本身的理解——知道哪个字段是依赖的、哪个值是有范围的、哪个响应是成功的样子。工具是手,理解才是脑。