若依框架接口测试实战:从登录到用户列表的全链路验证
1. 为什么选若依做接口测试入门——不是因为它“简单”而是它“真实”若依RuoYi框架在Java后端开发圈里几乎成了新人绕不开的“第一块磨刀石”。但很多人把它当练习Spring Boot的玩具项目装完就扔也有人一上来就猛啃源码结果卡在Shiro权限拦截器里三天没动弹。我带过十几期后端实习发现一个反直觉的事实真正能跑通“登录→获取token→查用户列表”这条链路的人不到三成。不是他们不会写Controller而是根本没搞清若依里“认证流程”和“接口调用逻辑”的耦合点在哪——比如前端传的是Authorization: Bearer xxx但后端校验时却去解析X-Access-Token这个Header又比如用户列表接口明明返回200但data字段却是空数组排查半天才发现是MyBatis Plus的分页插件没生效而控制台连SQL都没打出来。这恰恰是若依的价值所在它不抽象、不简化把企业级系统里最典型的“认证-鉴权-数据查询”闭环原样塞进一个可本地启动的单体应用里。它用Shiro或新版的Spring Security做权限控制用JWT或Session管理会话用PageHelper或MyBatis Plus分页插件处理列表所有组件都开着调试日志所有拦截器都留着断点入口。你测的不是“Hello World”而是未来三个月你会天天打交道的真实链路登录态怎么传递、token怎么刷新、分页参数怎么对齐、401和403怎么区分、响应体结构怎么统一。关键词——若依、接口测试、登录、用户列表、Shiro、JWT、Postman、Swagger、分页插件——这些不是标签是每个环节必须亲手拧紧的螺丝。这篇文章不讲“如何安装若依”而是带你从Postman里敲下第一个POST /login开始一层层剥开它的认证洋葱直到你能在没有前端、没有文档、只有Swagger UI和日志输出的情况下独立完成一次完整、可复现、带断点验证的接口链路测试。适合刚转Java后端的测试工程师、想补全接口能力的初级开发以及被外包项目里“登录一直失败”问题折磨到凌晨两点的运维同学。2. 登录接口深度拆解看清Shiro拦截器与JWT生成的握手全过程2.1 登录请求的表象与真相为什么/login返回的不是token而是sysUser对象打开若依的Swagger UI默认http://localhost:8080/swagger-ui.html找到/login接口你会看到它的请求体是LoginBody对象包含username和password两个字段响应体是SysUser类型。这很反直觉——按常规认知登录成功应该返回access_token才对。但若依的设计逻辑是前端登录后后端不直接返回token而是返回用户基础信息并由前端自行拼装token存入localStorage后续所有请求再通过Header携带该token。这个设计源于若依早期版本对JWT的轻量级封装思路token本身由后端生成并签名但只用于后端校验不暴露给前端原始值前端拿到的是经过Base64编码的用户信息时间戳再配合密钥重新计算签名形成最终的X-Access-Token。我们来实测验证。用Postman发一个标准登录请求POST http://localhost:8080/login Content-Type: application/json { username: admin, password: admin123 }响应体如下截取关键部分{ code: 200, msg: 登录成功, data: { userId: 1, deptId: 103, userName: admin, nickName: 若依, email: ry163.com, phonenumber: 15888888888, sex: 1, avatar: , password: , salt: , status: 0, delFlag: 0, loginIp: 127.0.0.1, loginDate: 2024-06-15T10:23:45.123, createBy: admin, createTime: 2022-03-15T14:22:33.000, updateBy: , updateTime: , remark: , dept: { deptId: 103, parentId: 101, deptName: 研发部门, orderNum: 1, leader: 若依, phone: 15888888888, email: ry163.com, status: 0, delFlag: 0, parentName: 技术部, ancestors: 0,100,101,103 } } }注意看data字段里面根本没有token。但如果你打开浏览器开发者工具的Network面板再执行一次登录会发现响应头里多了一行X-Access-Token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTcxODQyNjIwMCwiaWF0IjoxNzE4NDIyNjAwfQ.XXXXXX这才是真正的JWT token。若依的LoginController.login()方法里实际逻辑是调用loginService.login(username, password)完成密码校验MD5加盐比对若成功调用tokenService.createToken(sysUser)生成JWT字符串将该字符串作为X-Access-Token响应头写回同时将sysUser对象序列化为JSON作为响应体主体。提示若依的JWT密钥默认配置在application.yml中shiro: jwtSecret: 77864767564765765765765765765765。这个密钥必须前后端一致否则前端无法正确解析token。生产环境务必替换为高强度随机字符串且不可硬编码在配置文件中。2.2 Shiro拦截器的三次“拦停”从AuthenticationFilter到AuthorizationFilter的流转路径登录成功只是起点。真正决定接口能否被调用的是Shiro的拦截器链。若依的ShiroConfig.java中定义了核心过滤器链// anon匿名访问无需认证 filterChainDefinitionMap.put(/login, anon); filterChainDefinitionMap.put(/captchaImage, anon); // authc需要认证即检查token有效性 filterChainDefinitionMap.put(/**, authc);这意味着除/login和/captchaImage外所有接口都必须经过authc过滤器。而authc对应的是JwtFilter类若依新版或FormAuthenticationFilter旧版。我们以新版JwtFilter为例它继承自AccessControlFilter核心逻辑在onAccessDenied()方法中第一次拦停token缺失检查请求Header中是否存在X-Access-Token。若不存在直接返回401 Unauthorizedmsg无效的令牌第二次拦停token解析失败尝试用JwtUtil.parseToken(token)解析JWT。若签名错误、过期、格式非法则抛出ExpiredJwtException或SignatureException捕获后返回401第三次拦停权限不足token解析成功后得到Claims对象从中提取username再调用securityRealm.doGetAuthorizationInfo(principal)查询该用户的角色和权限。若用户无任何角色如被禁用或请求的URL不在其权限范围内则onAccessDenied()返回false触发403 Forbidden。这个三层拦截机制是接口测试中最容易踩坑的地方。比如你用Postman测试用户列表接口忘记在Headers里加X-Access-Token得到401加上后还是401可能是token过期JWT默认有效期2小时改成403则说明当前用户如admin虽已登录但该接口未在sys_role_menu表中为其角色授权。注意若依的权限控制是“URL粒度”的。/user/list这个路径必须在数据库sys_menu表中存在且其perms字段如system:user:list需与用户角色在sys_role_menu中的记录匹配。测试前务必确认admin角色已绑定该菜单权限否则永远卡在403。2.3 实战排错Postman里token粘贴错位导致的“401之谜”我遇到过最典型的案例一位测试同事反复测试/user/list始终返回401日志里却显示JwtUtil.parseToken: token is null。他确认Header里写了X-Access-Token值也复制了Swagger UI里登录响应头的完整字符串。问题出在——Postman的Header键名自动补全功能。他输入X-Access-Token时Postman智能提示了X-Access-Token和X-Access-Token:两个选项他手快点了后者导致Header变成了X-Access-Token:: eyJhbGciOiJIUzI1NiJ9...多了一个冒号Shiro的getRequest().getHeader(X-Access-Token)自然取不到值。这种低级错误在接口测试初期极其普遍。解决方案很简单在Postman的Headers面板手动删除该行重新输入X-Access-Token不带冒号再粘贴token值。更稳妥的做法是在Postman里创建一个环境变量{{token}}登录请求的Tests脚本里添加// 在登录接口的Tests标签页中 const response pm.response.json(); pm.environment.set(token, pm.response.headers.get(X-Access-Token));这样后续所有请求的Header里直接写X-Access-Token: {{token}}即可彻底规避手动粘贴错误。3. 用户列表接口的全链路验证从分页参数到SQL执行的逐层穿透3.1/user/list接口的参数迷宫pageNum、pageSize、orderByColumn背后的MyBatis Plus逻辑登录成功拿到token后下一步就是调用用户列表接口。Swagger UI里/user/list的请求方式是GET参数看起来很“标准”pageNum当前页码、pageSize每页条数、userName用户名模糊查询、phonenumber手机号、status状态、orderByColumn排序字段、isAsc升序降序。但若依的实现细节决定了你必须理解这些参数如何影响最终SQL。首先看Controller层代码UserController.selectUserList()GetMapping(/list) public TableDataInfo selectUserList(User user) { startPage(); // 关键这是PageHelper的分页起点 ListUser list userService.selectUserList(user); return getDataTable(list); }startPage()是PageHelper的核心方法它会将分页参数pageNum,pageSize绑定到当前线程的ThreadLocal中。接着userService.selectUserList(user)调用Mapper接口ListUser selectUserList(Param(user) User user);对应的XML映射文件UserMapper.xml中select语句末尾有if testuser ! null and user.orderByColumn ! null and user.orderByColumn ! order by ${user.orderByColumn} ${user.isAsc} /if注意这里用了${}而不是#{}——这是MyBatis的“字符串拼接”语法意味着orderByColumn的值如user_name会被原样插入SQL不经过预编译。这带来两个风险一是SQL注入若依做了白名单校验只允许user_name,email,create_time等字段二是如果传入非法字段名会导致SQL语法错误。我们用Postman构造一个典型请求GET http://localhost:8080/user/list?pageNum1pageSize10userNameadminorderByColumnuser_nameisAscasc Authorization: Bearer {{token}}提示若依新版已支持Authorization: Bearer xxx格式但默认仍使用X-Access-Token。若用Bearer方式需确认JwtFilter中getAuthzHeader()方法是否已适配。建议初期统一用X-Access-Token避免混淆。3.2 日志里的SQL真相如何让MyBatis打印出真实的执行SQL光看接口响应不够必须看到后端到底执行了什么SQL。若依默认的日志级别是INFOMyBatis的SQL日志被屏蔽了。你需要修改logback-spring.xml位于ruoyi-admin/src/main/resources/!-- 找到 root 标签添加以下logger -- logger namecom.ruoyi levelDEBUG / logger nameorg.mybatis levelDEBUG / logger namecom.baomidou.mybatisplus levelDEBUG /重启服务后控制台会疯狂刷出类似内容 Preparing: SELECT user_id, dept_id, user_name, nick_name, email, phonenumber, sex, avatar, password, salt, status, del_flag, login_ip, login_date, create_by, create_time, update_by, update_time, remark FROM sys_user WHERE del_flag 0 AND user_name LIKE ? ORDER BY user_name ASC LIMIT ?,? Parameters: %admin%(String), 0(Integer), 10(Integer) Columns: user_id, dept_id, user_name, nick_name, email, phonenumber, sex, avatar, password, salt, status, del_flag, login_ip, login_date, create_by, create_time, update_by, update_time, remark Row: 1, 103, admin, 若依, ry163.com, 15888888888, 1, , , , 0, 0, 127.0.0.1, 2024-06-15 10:23:45, admin, 2022-03-15 14:22:33, , , Total: 1这就是PageHelper生成的真实SQL。注意LIMIT ?,?中的两个参数第一个是offset偏移量等于(pageNum-1)*pageSize第二个是pageSize。若你传pageNum1, pageSize10offset就是0传pageNum2, pageSize10offset就是10。这个计算逻辑必须和前端分页组件严格对齐否则会出现“第2页数据重复第1页”的诡异现象。3.3 响应体结构陷阱TableDataInfo的data字段为何有时是空数组若依的TableDataInfo是自定义的分页响应包装类结构如下public class TableDataInfo implements Serializable { private static final long serialVersionUID 1L; /** 总记录数 */ private long total; /** 列表数据 */ private List? rows; /** 消息状态码 */ private int code; /** 消息内容 */ private String msg; }/user/list接口返回的JSON中data字段对应的是rows而total对应total。但新手常犯的错误是在Postman的Tests脚本里试图用pm.response.json().data去取数据却得到undefined。因为若依的全局响应统一封装在R类中/user/list返回的其实是{ code: 200, msg: 查询成功, data: { total: 1, rows: [ { userId: 1, userName: admin, ... } ], code: 200, msg: 查询成功 } }看到了吗外层data是一个TableDataInfo对象它内部还有自己的rows字段。所以正确的取值方式是// Postman Tests脚本 const response pm.response.json(); const userList response.data.rows; // 注意是 response.data.rows不是 response.data console.log(用户列表长度, userList.length);更进一步若你想断言“返回的用户数量大于0”不能只看response.data.total 0因为total是数据库总记录数而rows才是本次查询返回的实际数据。两者在分页场景下必然相等但逻辑上rows.length才是你真正要校验的。经验技巧在Postman里右键点击任意响应体选择“Save Response → Save to file”保存为user-list-response.json。然后用VS Code打开安装“Prettify JSON”插件一键格式化。这样你能清晰看到嵌套层级避免因JSON结构复杂而写错取值路径。4. 从单点测试到链路回归构建可复用的Postman集合与自动化断言4.1 设计健壮的Postman集合环境变量、前置脚本与动态参数的协同一个合格的接口测试集合绝不是零散的几个请求。它应该像一条流水线登录→获取token→调用业务接口→校验响应→清理如登出。若依的/logout接口是POST但实际是空操作仅清空session我们可以忽略。重点在于集合结构。在Postman中创建新集合命名为RuoYi-API-Test。然后按顺序添加三个请求01-LoginPOST /loginBody为JSONTests脚本负责提取token02-Get-User-ListGET /user/listHeaders中引用{{token}}URL参数动态化03-Get-User-ById可选GET /user/{id}用于验证单个用户查询。关键在于环境变量的全局管理。在Postman的“Environments”里新建一个环境如RuoYi-Local定义变量变量名初始值当前值说明base_urlhttp://localhost:8080http://localhost:8080服务根地址方便切换测试/预发环境token登录后自动填充供后续请求使用admin_user_id11用于/user/{id}请求可由01-Login响应体中动态提取01-Login的Tests脚本已提过// 提取X-Access-Token响应头 const token pm.response.headers.get(X-Access-Token); if (token) { pm.environment.set(token, token); console.log(✅ Token 已保存到环境变量); } else { console.error(❌ 未在响应头中找到 X-Access-Token); } // 提取admin用户的userId用于后续请求 try { const jsonData pm.response.json(); if (jsonData.data jsonData.data.userId) { pm.environment.set(admin_user_id, jsonData.data.userId); console.log(✅ admin_user_id 已保存, jsonData.data.userId); } } catch (e) { console.error(❌ 解析响应体失败, e.message); }02-Get-User-List的URL参数设置为{{base_url}}/user/list?pageNum{{page_num}}pageSize{{page_size}}userName{{search_name}}其中page_num、page_size、search_name也定义为环境变量初始值分别为1,10,admin。这样你只需修改环境变量就能批量测试不同分页条件和搜索词无需逐个编辑每个请求。4.2 编写高价值断言不只是200更要校验业务逻辑Postman的Tests脚本是接口测试的灵魂。若依的响应体结构固定我们可以编写精准断言。以02-Get-User-List为例完整的Tests脚本如下// 1. 断言HTTP状态码 pm.test(【状态码】应为200, function () { pm.response.to.have.status(200); }); // 2. 断言响应体JSON格式有效 pm.test(【JSON】响应体应为合法JSON, function () { pm.expect(pm.response.text()).to.be.json; }); // 3. 断言外层code为200 pm.test(【外层code】应为200, function () { var jsonData pm.response.json(); pm.expect(jsonData.code).to.eql(200); }); // 4. 断言内层data存在且为对象 pm.test(【内层data】应为TableDataInfo对象, function () { var jsonData pm.response.json(); pm.expect(jsonData.data).to.be.an(object); pm.expect(jsonData.data).to.have.property(total); pm.expect(jsonData.data).to.have.property(rows); }); // 5. 断言rows为数组且长度0 pm.test(【rows】应为非负整数长度的数组, function () { var jsonData pm.response.json(); var rows jsonData.data.rows; pm.expect(rows).to.be.an(array); pm.expect(rows.length).to.be.at.least(0); }); // 6. 断言至少有一个用户名为admin pm.test(【业务校验】应包含用户名为admin的用户, function () { var jsonData pm.response.json(); var rows jsonData.data.rows; var hasAdmin rows.some(function (user) { return user.userName admin; }); pm.expect(hasAdmin).to.be.true; }); // 7. 断言total与rows.length相等分页一致性 pm.test(【分页一致性】total应等于rows.length, function () { var jsonData pm.response.json(); pm.expect(jsonData.data.total).to.eql(jsonData.data.rows.length); });这段脚本覆盖了7个维度协议层、语法层、结构层、业务层、一致性层。特别是第6条“业务校验”它不依赖于数据库ID而是基于用户名这个业务标识确保接口返回的数据符合预期。如果某天admin用户被误删这个断言会立刻失败比单纯检查rows.length 0更有业务意义。4.3 链路回归与性能初探Collection Runner的批量执行与响应时间监控单个请求测试完下一步是链路回归。Postman的Collection Runner可以批量执行整个集合。设置Runner参数Iterations: 1先跑一遍确认流程通Delay: 1000 ms每个请求间隔1秒避免并发冲击Environment: 选择RuoYi-LocalData: 留空暂不导入CSV进行数据驱动点击RunRunner会按顺序执行01-Login→02-Get-User-List。观察右侧的Results面板每个请求旁有绿色对勾✓或红色叉✗点击任一请求可查看详细日志、响应体、响应头、测试结果最下方的Summary会统计总请求数、失败数、平均响应时间、最大/最小响应时间。若依本地启动/login通常在200ms内/user/list在150ms内。如果某个请求耗时超过1s就要警惕是不是数据库连接池满了是不是开启了debug日志导致IO阻塞是不是PageHelper.startPage()没被正确调用导致全表扫描经验技巧在Runner运行时打开若依的控制台观察SQL日志的刷屏速度。如果/user/list的SQL日志迟迟不出现说明请求卡在Shiro拦截器里如token校验失败如果SQL日志很快打出但Postman一直等待说明问题出在getDataTable()的序列化环节如循环引用导致Jackson卡死。这种“日志-响应”时间差是定位性能瓶颈的第一线索。5. 进阶实战绕过前端用curl命令行完成全流程测试5.1 从Postman到curl为什么命令行是接口测试的终极形态Postman图形界面友好但生产环境或CI/CD流水线里你无法打开GUI。学会用curl命令行完成相同测试是工程师走向成熟的标志。它强迫你直面HTTP协议本质Header、Body、重定向、Cookie、证书。若依的接口完全兼容curl我们来还原整个流程。第一步登录并提取tokenLinux/macOS终端# 发送登录请求将响应头保存到临时文件 curl -X POST http://localhost:8080/login \ -H Content-Type: application/json \ -d {username:admin,password:admin123} \ -D /tmp/login_headers.txt \ -o /tmp/login_response.json # 从响应头中提取X-Access-Token TOKEN$(grep -i X-Access-Token /tmp/login_headers.txt | cut -d -f2- | tr -d \r\n) echo ✅ 获取到Token: $TOKEN第二步用token调用用户列表# 发送GET请求携带token Header curl -X GET http://localhost:8080/user/list?pageNum1pageSize10userNameadmin \ -H X-Access-Token: $TOKEN \ -o /tmp/user_list.json # 格式化并查看响应 cat /tmp/user_list.json | python3 -m json.tool | head -20第三步用jq工具进行断言需提前安装brew install jq或apt-get install jq# 断言响应code为200 CODE$(cat /tmp/user_list.json | jq -r .code) if [ $CODE 200 ]; then echo ✅ 外层code校验通过 else echo ❌ 外层code错误期望200得到$CODE exit 1 fi # 断言rows数组长度大于0 ROWS_LENGTH$(cat /tmp/user_list.json | jq -r .data.rows | length) if [ $ROWS_LENGTH -gt 0 ]; then echo ✅ 用户列表非空共$ROWS_LENGTH条 else echo ❌ 用户列表为空 exit 1 fi这套脚本可以保存为test-ruoyi.sh在服务器上一键执行。它不依赖任何GUI纯文本交互日志可审计失败可追溯。CI/CD中你可以把它集成到Jenkins Pipeline或GitHub Actions里每次代码提交后自动运行成为质量门禁。5.2 安全加固提醒本地测试与生产环境的三道防火墙最后必须强调安全红线。若依是学习框架但它的配置极易被误用于生产JWT密钥硬编码application.yml中的shiro.jwtSecret是明文字符串。生产环境必须通过--shiro.jwtSecretxxx命令行参数或环境变量注入且密钥长度不低于32位H2数据库暴露若依默认使用H2内存数据库spring.h2.console.enabledtrue开启Web控制台。此配置绝对禁止在生产环境启用否则数据库可被任意读写Swagger UI未关闭springdoc.swagger-ui.enabledtrue在开发阶段方便但上线前必须设为false或通过Nginx反向代理限制IP访问。我在一家公司见过真实事故运维同学将若依的application-prod.yml直接部署到生产未修改jwtSecret结果攻击者通过逆向APK轻易破解了App的登录token批量爬取了所有用户手机号。教训是任何学习框架的配置都不能原样照搬至生产。必须建立配置审查清单每一条配置都要回答“它在生产环境是否必要是否安全”6. 我的实战体会接口测试不是“点按钮”而是“读代码看日志写断言”的三位一体写完这篇我翻出自己三年前第一次测若依的笔记上面写着“登录成功了但用户列表401查了两小时最后发现Postman的Header里多了一个空格。”现在回头看那不是笨而是必经之路。接口测试的底层能力从来不是工具熟练度而是三件事的肌肉记忆第一读代码。你不需要精通Shiro源码但必须知道JwtFilter在哪里、PageHelper.startPage()调用时机、TableDataInfo的包路径。遇到问题CtrlClick跳转到对应类5分钟内定位到关键方法比百度搜“若依401”快十倍。第二看日志。若依的日志体系非常清晰com.ruoyi打业务日志org.mybatis打SQL日志org.apache.shiro打权限日志。把logback-spring.xml调成DEBUG让日志成为你的“第三只眼”。一个401日志里是JwtUtil.parseToken: token is null还是SecurityRealm.doGetAuthorizationInfo: user not found指向完全不同的排查方向。第三写断言。Postman的Tests脚本不是可有可无的装饰。它把你的测试意图固化下来这个接口必须返回200这个字段必须存在这个数组长度必须大于0。当团队里新来同事接手你的测试用例他不需要问“这个接口该怎么测”直接点Run失败的断言会告诉他“哪里不对”。若依框架接口测试的终点不是跑通/login和/user/list而是当你看到一个新的/order/detail接口时能本能地问出三个问题它的认证方式是什么Shiro还是JWT它的分页逻辑用的PageHelper还是MyBatis Plus内置分页它的响应体结构遵循R统一封装还是自定义这三个问题的答案决定了你接下来10分钟是高效完成测试还是陷入无休止的401/403/500循环。所以别急着收藏这篇教程。关掉页面打开你的IDE拉下若依最新版从mvn clean package开始亲手敲一遍curl命令让日志在终端里滚动起来。真正的接口测试能力永远诞生于键盘敲击的节奏里而不是阅读文字的流畅中。