1. 为什么我要把接口校验交给Tests断言
2019年底到2020年初那段时间,前后端分离的项目越来越多,接口联调和回归测试的量一下子压了上来。当时我手里维护着十几个业务接口,每次后端改完代码,我都要打开Postman重新点一遍请求,然后盯着响应体里的JSON手动核对字段。说实话,字段少的时候还好,一旦返回一个几百行的嵌套对象,眼睛很快就花了。更让人头疼的是,人工核对常常漏掉一些“隐藏变化”——比如某个返回字段从字符串变成了数字,页面解析不出来,但肉眼第一眼看过去并不容易发现。
后来我开始认真用Postman里的Tests面板写断言,把“人工看返回结果”变成“脚本自动校验返回结果”,整个流程才真正稳定下来。所谓断言,可以理解成一个自动验收员:请求发出去之后,脚本自动检查返回结果对不对——状态码是不是200、返回的JSON里有没有关键字段、字段类型是否符合预期、业务码是否是成功值。所有检查项全部通过,这条用例才算绿;任何一个检查项不满足,用例立刻标红,并且你一眼就能看出是哪个断言挂了、具体什么值和预期不符。
这篇文章主要围绕Postman里最实用的一个能力——Tests断言校验返回结果展开。内容适合正在做接口测试、想把手动回归变成自动校验的测试工程师和开发同学参考。我会从为什么需要断言、常用断言写法、接口链路的完整校验、踩坑记录以及如何接入批量执行这几个角度,把能直接落地的思路和代码都整理出来。
2. 高频可用的Tests断言写法
2.1 最基础的断言结构:pm.test
在Postman的Tests面板里,最核心的入口就是pm.test函数。它的写法通常长这样:
pm.test('响应状态码是 200', function () { pm.response.to.have.status(200); });这段代码的意思是:注册一个名为“响应状态码是 200”的测试用例,里面传入一个回调函数,Postman在执行完请求后自动运行这个回调。回调里如果没有任何断言抛出异常,测试结果就是通过;如果断言失败,结果就会显示为失败,并且测试名称会原样显示在Results区域,方便你快速定位是哪一个检查点挂了。
我第一次用的时候,对pm.test的理解有点偏差。我以为是“先定义一个测试函数,后面手动调用”,但实际不是。pm.test更像是给Postman的测试列表里添加一条测试项,请求完成之后自动执行。你写了多少条pm.test,测试结果里就会出现多少条结果记录。这个机制很关键,因为很多人把一组断言放在一个pm.test里,结果失败了却很难看出是其中哪个小条件不满足。我在实际项目中习惯将一个独立校验点对应一条pm.test,宁可多写几条,也不把所有条件堆在一起。
2.2 校验响应状态码、响应时间和响应头
状态码是最基本的返回结果校验,很多接口只要状态码不是200,就说明请求链路本身出了问题。但状态码通过不代表业务一定成功,后文我会单独讲这个坑。先来看几种写法:
// 写法一:直接判断响应状态码 pm.test('状态码为 200', function () { pm.response.to.have.status(200); }); // 写法二:用 pm.response 对象里的 code 字段做判断 pm.test('状态码为 200(写法二)', function () { pm.expect(pm.response.code).to.eql(200); });两种写法都常见,我一般用第一种,因为阅读起来更直观。接着是响应时间,接口性能也是返回结果的一部分。如果一个接口日常都要跑3秒,那功能正确也不代表体验合格。这里可以设置一个阈值:
pm.test('响应时间低于 800ms', function () { pm.expect(pm.response.responseTime).to.be.below(800); });响应头也可以做校验,比如判断返回的内容类型是否是JSON,这能提前拦截一些网关返回的HTML错误页:
pm.test('响应头包含 JSON 类型', function () { const contentType = pm.response.headers.get('Content-Type'); pm.expect(contentType).to.include('application/json'); });那个headers.get方法很实用,取不到对应的请求头时返回null,所以断言用include会比直接用equal更稳妥,因为很多Web框架返回的Content-Type会带charset等参数,比如“application/json;charset=utf-8”,用include恰好符合实际。
2.3 校验JSON返回体:字段、类型和数组
接口返回结果中,JSON是最常见的数据格式。Postman里用pm.response.json()可以把原始响应体解析成JavaScript对象,然后就能像平常操作对象一样去取字段、判断类型、比较值。
先看一段完整的示例,假设某个接口返回:
{ "code": 0, "message": "success", "data": { "token": "abc123", "userName": "tester", "vipLevel": 2 } }那我们的断言可以这么写:
// 将响应体解析为对象 const jsonData = pm.response.json(); pm.test('业务码为 0', function () { pm.expect(jsonData.code).to.eql(0); }); pm.test('返回 message 是 success', function () { pm.expect(jsonData.message).to.eql('success'); }); pm.test('data 下的 token 字段值不为空', function () { pm.expect(jsonData.data.token).to.not.be.empty; }); pm.test('vipLevel 是数字类型', function () { pm.expect(jsonData.data.vipLevel).to.be.a('number'); });关于to.eql和to.equal的选择,是我在实际中朝大梁踩出来的细节。Chai断言库语义下,to.equal使用的是严格相等,对于对象和数组往往要逐个字段比对,而to.eql做的是深度相等,可以直接比较两个对象或数组是否在结构上完全一致。判断单个数字或字符串时两者差别不大,但一旦涉及到对象整体比较,我会统一用to.eql,避免因为字段顺序不同或者嵌套导致莫名其妙的失败。
还有几种常用的断言表达式,值得记一下:
pm.expect(jsonData.data.list).to.be.an('array'); pm.expect(jsonData.data.list).to.have.lengthOf(3); pm.expect(jsonData.data.name).to.be.a('string'); pm.expect(jsonData.data.count).to.be.at.least(1);其中at.least可能受框架版本影响,如果你发现依赖环境不支持,直接用大于等于运算符替代也行:
pm.test('count 大于等于 1', function () { pm.expect(jsonData.data.count >= 1).to.eql(true); });2.4 字符串匹配和值的枚举范围
除了精确相等,返回结果校验中还经常用到“包含”和“属于指定范围”的判断。比如手机号脱敏后返回“138****8888”,你想判断星号位置是否正确,可以用include:
pm.test('手机号脱敏格式正确', function () { pm.expect(jsonData.data.mobile).to.include('****'); });对于订单状态这类字段,值可能在一个枚举集合内。比如订单状态有pending、paid、shipped、done四个取值,那我一般会定义预期集合,然后用include判断:
pm.test('订单状态在合法枚举范围内', function () { const allowedStatus = ['pending', 'paid', 'shipped', 'done']; pm.expect(allowedStatus).to.include(jsonData.data.orderStatus); });这种写法比直接“状态等于paid”灵活很多,因为业务上有可能返回多种合法状态,只要落在预期集合里就算通过。否则一旦后端从paid改成了shipped,直接相等的断言就会误报,反而增加排查成本。
2.5 为什么用pm.test包一层而不是直接写判断语句
有读者可能会问:既然pm.expect本身就能判断,那直接用if判断再抛出错误行不行?可以,但pm.test的价值在于它能把一个Assertion封装成一条独立的测试记录,最终体现在测试结果面板里。如果你写一堆throw Error,Postman只会显示脚本执行异常,你很难快速看出具体哪条业务校验没通过。
另外一个理解上的关键点是,Postman的Tests脚本是一段请求完成后自动执行的JavaScript代码,它从上到下依次运行。比如你写了三条pm.test,即使第一条断言失败了,第二条和第三条还是会被执行,并不会因为你写在前面的断言失败就中断整个脚本。这一点很多人不知道,结果就会出现“一个接口已经失败了,后面的变量保存和断言还在继续跑”的情况。想中途跳过后面的检查,只能在代码里自己加条件判断。
3. 从登录到下单:一套接口链路的完整断言编排
3.1 模拟项目X的接口设计
单独给一个接口写断言比较容易,但实际项目中往往是多个接口相互依赖,需要把上一个接口返回的数据传到下一个请求里。这里我模拟一个项目X,场景是某电商后台的登录和下单流程:
| 接口名称 | 接口路径 | 请求方式 | 关键参数 | 返回结果核心字段 |
|---|---|---|---|---|
| 用户登录 | {{baseUrl}}/api/v1/login | POST | username, password | code, data.token |
| 创建订单 | {{baseUrl}}/api/v1/order/create | POST | Header: Authorization | code, data.orderId |
| 查询订单 | {{baseUrl}}/api/v1/order/detail?orderId={{createdOrderId}} | GET | 无 | code, data.status, data.amountText |
这里我用{{baseUrl}}这种环境变量写法,这样切换测试环境时只需要改环境变量即可,不需要改动脚本。
在这个链路里,真正的校验难点不单是每个接口自身的返回字段,还要看接口之间数据是否正确传递。登录成功后要把token保存下来,创建订单后要把orderId保存下来,后面的查询接口才能正常使用。
3.2 登录接口:校验token并保存到环境变量
先看登录接口的Tests脚本。这里的目标是:校验状态码、业务码、token字段存在性,并且在所有关键校验通过后把token存进环境变量:
const jsonData = pm.response.json(); pm.test('登录接口状态码为 200', function () { pm.response.to.have.status(200); }); pm.test('登录业务码为 0', function () { pm.expect(jsonData.code).to.eql(0); }); pm.test('token 字段存在且非空字符串', function () { pm.expect(jsonData.data.token).to.be.a('string'); pm.expect(jsonData.data.token).to.not.be.empty; }); pm.test('用户名与请求参数一致', function () { pm.expect(jsonData.data.userName).to.eql(pm.request.body.raw); });实际上第三个断言以我现在来看没必要写成精确比较用户名,除非接口明确返回了这个字段,这里只是示意。更要的是下面这段条件保存:
if (jsonData.code === 0 && jsonData.data && jsonData.data.token) { pm.environment.set('token', jsonData.data.token); }之所以要加if判断,是因为Tests脚本不会因为前面的断言失败而自动停止执行。如果登录接口返回的是错误码,data字段下压根没有token,而后面的请求又在傻傻地等token,就会把错误数据一路带下去。换句话说,先判空再保存,是我在实际联调中总结出来的一个刚需。
3.3 创建订单接口:校验动态生成的orderId
创建订单接口需要在Headers里带上Authorization: Bearer {{token}},然后返回一个动态生成的订单号。这个时候我们关注的不是“预期值是什么”,而是“返回结果里有没有满足条件的订单号”。
const jsonData = pm.response.json(); pm.test('创建订单状态码为 200', function () { pm.response.to.have.status(200); }); pm.test('创建订单业务码为 0', function () { pm.expect(jsonData.code).to.eql(0); }); pm.test('orderId 为字符串且非空', function () { pm.expect(jsonData.data.orderId).to.be.a('string'); pm.expect(jsonData.data.orderId).to.not.be.empty; }); pm.test('订单金额字段在合理区间', function () { const amountText = jsonData.data.amountText; const amount = parseFloat(amountText); pm.expect(amount).to.be.a('number'); pm.expect(amount > 0).to.eql(true); }); if (jsonData.data && jsonData.data.orderId) { pm.environment.set('createdOrderId', jsonData.data.orderId); }这里有几个细节值得展开。第一,orderId是动态生成的,你没法提前知道具体值,所以关键是校验它的“类型和存在性”,而不是比较具体内容。第二,金额这类浮点数据如果直接拿float比较很容易出问题。比如接口返回0.10,JavaScript浮点运算是0.1加0.2不等于0.3的那种,用to.eql(0.1)很不稳。更靠谱的做法是把金额换算成“分”再比较,用整数相等来判断:
pm.test('金额与预期一致(按分比较)', function () { const returned = Math.round(parseFloat('19.90') * 100); const expected = Math.round(19.90 * 100); pm.expect(returned).to.eql(expected); });最早我直接比较浮点值,后来被一个“多收一分钱”的bug坑过一次,才养成这个习惯。接口返回结果只要是涉及金额、库存这类需要精确比较的字段,统一转成最小单位再比较,是最省事的方案。
3.4 查询订单接口:校验列表中的指定订单
查询接口是我们链路里的最后一环。这里不仅校验状态码和业务码,还要从返回的订单列表里找到刚才创建的那条订单,再对这条订单的状态、金额做二次校验。这就涉及到一个很有价值的操作:从“整个返回结果”中筛选出“目标数据”。
const jsonData = pm.response.json(); const expectedOrderId = pm.environment.get('createdOrderId'); pm.test('查询订单状态码为 200', function () { pm.response.to.have.status(200); }); pm.test('查询结果里能匹配到刚创建的订单', function () { const list = jsonData.data.list; pm.expect(list).to.be.an('array'); const matched = list.find(function (item) { return item.orderId === expectedOrderId; }); pm.expect(matched).to.not.be.undefined; }); pm.test('匹配订单的状态合法', function () { const list = jsonData.data.list; const matched = list.find(function (item) { return item.orderId === expectedOrderId; }); const allowedStatus = ['pending', 'paid', 'shipped', 'done']; pm.expect(allowedStatus).to.include(matched.status); });从这个例子可以看出,断言的本质并不复杂,真正有价值的是“如何从返回结果里定位到你要校验的那部分数据”。find方法是JavaScript数组原生提供的能力,比遍历取第一个元素或者写一堆for循环更简洁。这里有一个小教训:断言里用到的变量如果依赖前面接口的保存结果,最好在脚本开头先判空,否则一旦前面链路断了,这里一执行就会因为undefined而直接报错。
3.5 失败快速终止的取舍
在Collection Runner里跑完整套用例时,如果登录接口挂了,后面所有依赖token的接口肯定都会挂。这种场景下,与其让后面十条用例全红,不如先停掉,修好登录再继续。Postman里可以通过postman.setNextRequest来控制流程,比如在登录脚本里判断失败后直接跳过后续请求:
if (jsonData.code !== 0 || !jsonData.data.token) { postman.setNextRequest(null); }如果返回null或空字符串,Runner就会停止继续执行后面的请求。但这个用法有一个限制:它只在Collection Runner或Newman批量运行时有效,单独Send一个请求时看不出作用。我一般在跑完整集合时加这个快速失败逻辑,平时手工调试时不依赖它。
关于数据保存,我再强调一下环境变量和全局变量的选择。pm.environment.set是把数据写到当前环境,跟随Postman右上角的环境切换变化;pm.globals.set是写到全局,所有环境都能访问。我的习惯是:环境相关的数据用环境变量,全局固定的配置才用全局变量。否则在多环境切换时,很容易出现“测试环境跑的token跑到生产环境请求里”这种低级事故。
4. 实测中踩过的坑:断言失败排查速查
4.1 还没开始断言就报错的原因
断言脚本本身是JavaScript,所以只要代码有语法错误、或者运行时抛了异常,整段脚本都执行不了,自然也就谈不上校验返回结果了。最常见的两个原因,我在这里单独列出来。
第一个是pm.response.json()解析异常。Postman脚本里,pm.response.json()只有在响应体是合法JSON时才能正常解析。如果接口在异常情况下返回了一个HTML错误页(比如网关拦截后返回一串HTML代码),或者返回的JSON里包含了无法解析的转义符,那这行代码一执行就会抛错。为了不崩掉后续所有断言,我们需要给解析加一层保护。我常用的写法是:
let jsonData; try { jsonData = pm.response.json(); } catch (e) { pm.test('返回结果必须是合法 JSON', function () { pm.expect.fail('响应体不是合法 JSON'); }); }这样即使解析失败,脚本也能继续执行到后面的断言,并且明确标注出是JSON格式的问题,而不是干等着看一大段红色的异常堆栈。
第二个是字段取错。比如后端返回结构里data字段下面还有一层result,你的脚本直接写jsonData.data.token,结果拿到undefined。很多新手一看断言失败就以为接口返错了,其实往往是自己路径写错了。我的建议是,写复杂断言之前,先在Tests面板最前面加一行console.log(JSON.stringify(jsonData)),发送完请求后到Console里看一遍实际结构,再动手写断言。
4.2 断言逻辑写错导致“假通过”
比断言失败更隐蔽的问题是“假通过”。也就是说,用例显示绿色,看起来全过了,实际上断言本身有问题。
最常见的情况是:你取了一个不存在的字段,然后断言它不等于某个值。比如预期期望jsonData.data.user.status === 1,但实际字段名是userStatus。如果直接写成以下这种,就不会报错:
pm.test('用户状态正常', function () { pm.expect(jsonData.data.user.status).to.equals(1); });问题在于,如果jsonData.data.user下面根本没有status字段,那表达式的值是undefined,严格等于1当然不成立,测试应该是失败的。但如果你把它反过来,写成“不等于一个错误值”,那就很容易出现假通过:
pm.test('用户状态正常', function () { pm.expect(jsonData.data.user.status).to.not.eql(-1); });undefined不等于-1,断言直接通过。这类问题是真真实实会出现的,而且极难察觉。我最开始写断言时也这么干过,后来总结出一个笨办法:写完一组断言,故意把一个条件改成错误值,比如把期望的code改成233,看测试是不是真的变红。如果改成错误值之后它还是绿的,说明这条断言根本没在正常工作,得赶紧检查字段路径。
再比如字符串包含判断时,接口返回了“success ”后面带个空格,你又只写了include('success'),这种通常能过,不会被空格影响。但如果接口返回“Success”首字母大写,而你写的是小写success,就会误报失败。判断这种场景,最好先把两端空格trim掉,再做大小写归一的处理:
pm.test('返回文案包含关键字', function () { const message = jsonData.message.trim().toLowerCase(); pm.expect(message).to.include('success'); });4.3 常见问题速查表
我整理了一张排查速查表,平时遇到断言异常可以直接对照着看。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| pm.response.json() 报错 | 返回体不是合法JSON,可能被网关拦截返回HTML | 用try/catch包裹解析,先判断Content-Type |
| 断言一直通过,但明显不符合预期 | 字段路径写错,取到了undefined后做了“不等于某个值”的判断 | 使用to.be.undefined先确认字段存在,或将断言改成精确相等 |
| 状态码200但断言还是失败 | 后端把业务错误包装成了HTTP 200,实际业务码非0 | 同时断言HTTP状态码和业务code |
| 批量运行偶发失败 | 前置请求写入的变量名与其他请求冲突 | 检查环境变量名唯一性,必要时加请求名前缀 |
| 数组中某个目标元素吻合但断言找不到 | 返回列表里的字段不是orderId而是order_id | 打印实际字段名做对比,不要靠猜 |
| 响应慢导致超时断言失败 | 测试环境网络波动 | 阈值放宽一点,或者区分本地和CI环境的耗时指标 |
这些场景我基本上都遇到过,特别是“状态码200但业务失败”这个,几乎可以说是接口测试中最高频的坑。很多后端框架统一的返回结构会在HTTP层始终保持200,真正的错误通过body里的code和message表达。如果只校验状态码,等于一个重要的错误信号直接被吞掉了。所以我在任何接口断言里,都会要求至少同时覆盖状态码和业务码这两层。
5. 把断言脚本跑进批量执行和自动回归流程
5.1 用Collection Runner跑完整回归
单个接口的断言写完,价值是零散校验;真正发挥威力的是把一组请求组织成Collection,然后批量执行。Postman左上角的“Runner”入口可以打开Collection Runner,选择集合、选择环境、填迭代次数,就能一次性跑完所有请求和Tests脚本。
在Runner的结果面板里,每个请求下的每个pm.test都会对应一条独立的运行记录。比如登录接口写了4条断言,这批断言全部通过才显示该请求通过,只要有1条失败,整个请求和对应断言就标红。这也是为什么我前面强调“一个校验点对应一条pm.test”,因为结果面板就是按pm.test条目来展示的,如果你的断言写得粗,失败时根本看不出具体是哪一项业务条件不满足。
我第一次跑完整套collection的时候,最大的收获还不是“看到绿”,而是“看到红”。一堆之前手工测试没发现的问题被批量暴露出来。比如某个接口在某种数据条件下会返回code=20001,但因为接口整体状态码还是200,手工看根本不会在意,断言一上,它就红了。这就是把校验交给脚本的价值。
5.2 用Newman把它跑进命令行和自动化流程
Collection Runner适合在Postman界面里手动跑,如果我们要让接口回归自动化起来,就得脱离界面执行。Postman配套提供了一款命令行工具Newman,它可以直接运行导出的Collection文件和环境文件,并把Tests断言的结果反映在进程退出码上。
我常用的命令大致是这样:
newman run tests/collection.json \ -e tests/env.json \ -d tests/data.csv \ --reporters cli,junit \ --reporter-junit-export reports/report.xml简单解释一下这几个参数:run后面跟的是集合文件;-e指定环境变量文件;-d可以传入一份CSV数据文件,用来做表格化驱动测试,相当于用多组测试数据跑同一个请求;--reporters指定要输出哪些格式的报告,这里用cli控制台输出加junit报告,因为junit报告可以方便地被后续的CI平台解析。
第一次使用Newman时,我建议先跑一条最简单的请求,确认环境变量没有串环境。如果发现找不到环境变量,多半是Collection里引用了{{xxx}},但e参数指定的环境文件里没有定义这个变量。
如果要在自动化流水线里让“断言失败”导致构建失败,关键点是理解命令行的退出码。Newman只有在所有Tests断言都通过时,才返回0;只要有任何一条断言失败,退出码就是非0。流水线里只需要执行这个命令并检查退出状态即可。还有一个参数值得记住:
newman run tests/collection.json -n 3 --bail-n 3表示对整个集合跑3轮,--bail表示只要出现第一次失败就让后续请求停止。这样适合快速验证“当前版本是否可部署”。
5.3 断言报告怎么看
跑完Newman或者Collection Runner之后,我一般先看失败用例的分布。如果失败集中在一两个接口上,说明大概率是这两个接口本身有变化,需要打开Postman单跑确认。如果失败铺开来,一大片全红,那先怀疑前置公共数据断了,比如登录token没取到,或者环境变量被覆盖。
再往细看,点开一个失败请求后,Tests标签页里会显示具体的断言结果。如果断言名称写得好,此刻你不需要去看任何日志,光凭“订单状态在合法枚举范围内”这一条就能知道是状态枚举问题,还是字段缺失问题。这也是写断言时命名要尽量带业务语义的原因,不要写“test1”“断言2”这类的名称,真到排查阶段会非常痛苦。
6. 我写Tests断言的一些个人体会
6.1 先让它失败一次
我写断言有一个习惯,就是写完一组之后,故意把期望值改错,然后点Send。如果它真的红了,说明脚本是在正常工作的;如果它还是绿的,那说明这条断言根本没有起到校验作用,要么是字段取错了,要么是断言逻辑本身有问题。这看起来是个很笨的验证手段,但确实帮我抓出过好几次自己写错字段名导致“假通过”的问题。尤其是上面提到的那种“取到undefined然后去not.eql(-1)”的情况,只有靠故意改错才能暴露。
6.2 断言要跟着接口文档一起维护
接口返回结果是会演变的。字段改名、类型调整、结构层级变化,这些在后端重构时太常见了。一旦接口文档变了,我需要跟着改的就是Tests脚本。如果断言还是老字段名,那测试立刻开始报错,这正是自动化回归的价值:用失败提醒你接口变了。问题是,如果你平时懒得更新断言,时间一长,断言就会和线上接口脱节,最后变成“反正它一直红着,大家都习惯了”。这是一个非常危险的状态。
所以我给自己定了一个规矩:接口文档变更时,第一时间同步去改对应的断言,并且跑一次完整集合确认全绿。这样这条集合才有资格作为回归防线。那些“反正它一直红着”的集合,我宁愿直接把那些失效断言清掉,也不要让一屋子红变成没人管的噪音。
6.3 断言不是越多越好,但要踩在业务关键点上
刚开始写断言的时候容易陷入另一个极端,就是把能判断的全写上去,字段存在性、长度、类型、精确值、返回顺序……结果每次后端微调格式,测试就翻红一遍,维护成本很高。后来我慢慢学会做减法:只对真正影响业务的关键点做断言。比如登录接口的token不能为空、业务码必须是成功、订单号生成了、列表里能查到自己创建的那条订单。这些是“业务是否真的成功”的底线。而那些“返回的data里的logo字段是不是String”之类的问题,除非前端解析依赖它,否则真的没必要写成一票否决式的断言。
我个人在实际操作中的体会是,接口自动化测试不是比谁写的断言多,而是比谁能用最稳定的断言把业务流程的关键成功条件锁住。把Tests断言理解成一套自动验收机器,那么验收标准需要跟业务对齐,而不是跟代码实现抠字眼。
再分享一个小技巧:如果你发现自己经常在Postman的Console和Results面板之间反复切换,可以在Tests脚本开头统一加一行console.log,把关键的返回体片段打印出来,这样即使断言失败,你也能立刻在旁边看到“当时返回的实际值是什么”,排查效率能提升一大截。对返回结果校验这件事来说,让失败信息清晰、可定位,有时候比多写两条断言更重要。