上一篇文章我们把 TestNG 接口自动化框架的骨架搭起来了:Maven 工程结构、TestNG 依赖和 testng.xml 套件、RestAssured 基础请求,以及一个能真正跑通的登录接口冒烟脚本。但说实话,那个阶段只是“能跑”,离“好用”还有一段路。这一段路往往是决定框架能不能在团队里持续用下去的关键:脚本是不是每次执行都要手工改数据,失败了能不能一眼看出是哪一环出了问题,几十上百个接口能不能并行跑还不出错。
这篇文章就专门解决这几个问题。我会围绕若依这类后台管理系统的实际接口特征,拆解四个改造重点:Token 的统一管理与自动刷新、请求层的统一封装、数据驱动的用例组织方式、断言体系的分层设计。每一部分都会给出可以直接抄走的代码思路和踩坑记录,不是教科书式的理论,是我在实际项目中反复调过的东西。
1. 框架现状盘点与本期改造目标
先给框架做个阶段性的体检。上一步我们做完的东西,本质上是一堆能够独立执行的 TestNG 测试类,里面通过 RestAssured 直接调用接口。这种做法的好处是快,坏处也很明显:每个类里面都有重复的 Header 设置、重复的登录逻辑、重复的响应解析,一旦环境地址变了,得把所有类翻出来改一遍。
1.1 上一阶段成果的边界在哪里
我们做完的第一版,通常包括这么几样东西:
- 一个干净的 Maven 工程,pom.xml 里引入了 TestNG、RestAssured、Jackson 或 Fastjson、Apache POI 这类基础依赖;
- testng.xml 把测试套件和监听器配置好了;
- 一个 LoginTest 或类似的冒烟用例,能够调用若依的登录接口拿到 token;
- 可能还有一个简单的 BaseTest 类,但里面内容还不丰满,只是把 RestAssured 的基础配置放进去。
这个版本的适用场景是验证环境通不通、登录接口能不能用。但真要让接口自动化在迭代里发挥价值,下一阶段要补的能力是四个:会话共享、请求封装、数据分离、断言标准化。
1.2 本期改造的四个目标
这期文章我按这样的顺序推进:
第一,Token 管理模块。不再每个测试类都先登录一次,而是有一个全局的 Token 管理者,统一缓存、统一过期检查、统一自动重登。
第二,统一请求封装。把 RestAssured 的“裸调用”收敛到一个 ApiClient 服务类里,所有测试都通过这个入口发请求。这样做的好处是后续加日志、加鉴权头、加超时重试,只需要改一个地方。
第三,数据驱动。把测试数据从 Java 代码里剥离开,放到 Excel 或 JSON 里。测试方法只负责执行,具体参数和期望结果由数据文件驱动。这是用例量上来之后必须做的一步,否则每加一个用例就要改 Java 代码再重新编译,效率太低。
第四,断言体系。若依这类后台系统,接口响应大多遵循统一包装结构,比如{ code, msg, data },但分页列表又可能是{ code, msg, rows, total }。我们要针对这些结构设计一套可复用的断言基类,而不是每个用例里用一堆重复的 Assert 代码。
把这四块做扎实,框架才算真正立住了。
2. 登录态复用:Token 管理模块设计与实现
接口自动化里最容易忽略又最影响稳定性的,就是登录态的管理。很多人一开始图省事,在每个测试类的@BeforeClass里直接调登录接口,拿到 token 就发起后续请求。结果用例一多,跑一次全量回归光是登录请求就刷了几十次,接口慢一点整个执行时间直接翻倍。更坑的是,如果登录接口有验证码或者有时间窗口限制,这种写法随时会挂。
2.1 若依接口鉴权链路分析
若依的鉴权流程不复杂:客户端把用户名和密码提交到/login接口,服务器校验通过后返回一个 token 字段,后续请求在 Header 里带上Authorization: Bearer <token>。如果开启了验证码功能,登录还要求先请求/captchaImage拿到 uuid 和验证码图片,再连同验证码一起提交。
在实际做自动化的时候,我强烈建议在测试环境关闭验证码,或者在造数环节就解决掉验证码的干扰。否则每次登录都要处理验证码识别,既不稳定又没有意义,这不是自动化框架应该承担的工作。登录接口返回的 token 有几个特点:有效期内可以复用,过期后需要重新登录;token 与用户身份绑定,同一个 token 可以在多个请求里共用,但如果要模拟多用户角色,就得分别登录。
2.2 TokenManager 的落地实现
我用的方案是一个基于 ThreadLocal 的 TokenManager。ThreadLocal 的选择不是炫技,而是因为它天然适合 TestNG 并行执行场景。TestNG 支持方法级并行,如果用一个普通静态变量存 token,线程之间会互相覆盖,可能 A 线程刚存进去,B 线程就把它换了。ThreadLocal 让每个线程持有自己的 token 副本,从根上避免串号问题。
核心代码长这样:
public class TokenManager { private static final ThreadLocal<String> TOKEN_CACHE = new ThreadLocal<>(); private static final ThreadLocal<Long> TOKEN_CREATE_TIME = new ThreadLocal<>(); private static final long MAX_AGE_MILLIS = 2 * 60 * 60 * 1000L; public static String getToken() { String token = TOKEN_CACHE.get(); if (isBlank(token) || isExpired()) { synchronized (TokenManager.class) { token = TOKEN_CACHE.get(); if (isBlank(token) || isExpired()) { token = doLogin(); TOKEN_CACHE.set(token); TOKEN_CREATE_TIME.set(System.currentTimeMillis()); } } } return token; } public static void invalidate() { TOKEN_CACHE.remove(); TOKEN_CREATE_TIME.remove(); } private static boolean isExpired() { Long createTime = TOKEN_CREATE_TIME.get(); if (createTime == null) { return true; } return System.currentTimeMillis() - createTime > MAX_AGE_MILLIS; } private static String doLogin() { Map<String, Object> body = new HashMap<>(); body.put("username", EnvConfig.get("admin.username")); body.put("password", EnvConfig.get("admin.password")); Response response = given() .contentType(ContentType.JSON) .body(body) .post(EnvConfig.get("base.url") + "/login"); String token = response.jsonPath().getString("token"); if (isBlank(token)) { throw new IllegalStateException("登录接口未返回 token,响应原文:" + response.asString()); } return token; } }这里有几个细节要说一下。synchronized块只在两个判断都落空时才进入,目的不是串行化所有线程,而是防止并发环境下多个线程同时去登录,把登录压力降到最低。MAX_AGE_MILLIS为什么定 2 小时,因为若依默认的令牌过期时间就是 2 小时左右。你可以根据自己环境的配置调整这个值,注意别比服务端的实际过期时间还长,否则会出现 token 明明已经被服务端判死,本地还一直复用的尴尬情况。
2.3 并发场景与自动刷新策略
并行执行时还有一个常见的坑:某个请求返回了 401,说明 token 已经失效。最直接的策略是捕获 401 后调用TokenManager.invalidate(),然后再执行一次原请求。这个重试逻辑不适合写散在业务代码里,更优雅的做法是放在 RestAssured 的过滤器层,或者封装在 ApiClient 的统一方法里。我会在下一节给出封装思路,这里先记住一个原则:框架里只允许存在一条通往鉴权的代码路径,其余所有请求都走这一条路。
另外,在多用户场景下,如果测的是管理员权限和普通用户权限的差异,建议在 TokenManager 外面再包一层“用户身份标识”的概念,比如支持传入admin、commonUser两种角色,内部用两个 ThreadLocal 或者一个 ConcurrentHashMap 按角色缓存。否则所有用例都拿管理员 token,权限类用例根本没法做。
3. 统一请求封装:从散落代码到公共服务层
如果你看过团队里写过一阵子的接口自动化代码,大概率见过这种情况:每个测试类里都有几坨given().header(...).body(...).when().post(...),大同小异,但 Header 名字可能前后不一致,有的写Authorization,有的写成token,有的漏了 Content-Type。一旦服务端要求统一改成Authorization,你就得全局搜索替换,改漏一个就是线上误报。
3.1 为什么必须收敛到一层
统一封装的价值,不是在“当前能跑”的时候提现的,而是在“出问题的时候”。接口自动化框架最怕的就是排查一个失败时,你分不清它是环境挂了、数据污染了、代码 bug 了,还是脚本本身写错了。如果所有请求都经过同一层,这一层可以在请求前后做好日志记录、异常捕获和状态感知,问题定位的效率会成倍提升。
同时,封装层也是埋点的地方。后续如果要加超时重试、加签名、加 trace 追踪、加失败自动截图,统一入口意味着这些能力只需要实现一次,所有用例自动继承。
3.2 ApiClient 的封装示例
我通常把封装层做成一个名为 ApiClient 的类,静态方法暴露 get、post、put、delete 几个基本动作。所有测试类只依赖 ApiClient,不直接使用 RestAssured 的静态方法。下面是简化版:
public class ApiClient { static { RestAssured.baseURI = EnvConfig.get("base.url"); } public static Response post(String path, Object requestBody) { return baseRequest() .body(requestBody) .when() .post(path) .then() .extract() .response(); } public static Response get(String path) { return baseRequest() .when() .get(path) .then() .extract() .response(); } public static Response put(String path, Object requestBody) { return baseRequest() .body(requestBody) .when() .put(path) .then() .extract() .response(); } public static Response delete(String path) { return baseRequest() .when() .delete(path) .then() .extract() .response(); } private static RequestSpecification baseRequest() { return given() .contentType(ContentType.JSON) .header("Authorization", "Bearer " + TokenManager.getToken()) .log().ifValidationFails(); } }这里需要注意baseRequest()每次请求前都会调用TokenManager.getToken(),这意味着 token 过期检查被收敛到了最核心的一层,测试代码本身完全不用关心 token 从哪里来。另外,日志我用了log().ifValidationFails()而不是log().all(),因为全量日志在并行执行时会把控制台刷得没法看,只有失败时才打印,配合报告查看更容易聚焦。
3.3 请求日志与链路追踪
如果你想要更完整的请求日志,可以在 RestAssured 里注册一个 Filter。这个 Filter 会在请求发出前和响应返回后各执行一次,能把请求行、Header、Body 和响应状态码都捞出来,存到测试报告或日志文件里。对于疑难问题排查非常有用。
public class ApiLogFilter implements Filter { @Override public Response filter(FilterableRequestSpecification requestSpec, FilterableResponseSpecification responseSpec, FilterContext ctx) { Response response = ctx.next(requestSpec, responseSpec); String log = "请求: " + requestSpec.getMethod() + " " + requestSpec.getURI() + " | 状态码: " + response.getStatusCode() + " | 响应: " + response.asString(); TestReportHolder.appendLog(log); return response; } }这个 Filter 注册到 RestAssured 全局即可,比如在静态初始化块里RestAssured.filters(new ApiLogFilter())。我在实际项目里用类似的思路把请求响应挂到了测试报告里,出了问题打开报告就能看到完整的失败现场,连复现都不需要。
4. 数据驱动改造:用例数据与执行逻辑分离
当测试用例数量增长到几百上千时,如果还在 Java 代码里写死 JSON 参数,维护成本会高到让人崩溃。数据驱动的本质,是让“测试什么”和“怎么执行”解耦:测试方法只负责按模板执行,具体用哪些数据、期望什么结果,全部从外部数据源读取。
4.1 数据文件怎么组织
我见过有人把所有用例塞进一个巨大的 Excel 文件,几十个 sheet,几千行,看起来挺唬人,但维护起来很痛苦。我的建议是按模块拆分文件,或者至少按 sheet 拆分模块。比如若依后台常见的用户管理、角色管理、菜单管理,一个模块一个 sheet。
Excel 的列这么设计比较合理:用例编号、用例名称、请求方式、请求路径、请求参数(JSON字符串)、期望code、期望msg、是否执行。其中请求参数以 JSON 字符串形式放在单元格里,读取后转成 Map 或直接作为请求 body 传给接口。这个方案的优点是非技术人员也能看懂,测试同学不写代码也能增改用例。
4.2 基于 Hutool 的 Excel 读取器
若依项目里本来就有 Hutool 依赖,测试工程里直接引入 hutool-all 也很方便。用 Hutool 的 ExcelReader 读取数据比裸写 Apache POI 省太多事。核心代码:
public class ExcelCaseLoader { private static final String CASE_FILE = "testdata/api_cases.xlsx"; public static List<ApiCase> loadSheet(String sheetName) { ExcelReader reader = ExcelUtil.getReader(FileUtil.file(CASE_FILE), sheetName); List<Map<String, Object>> rows = reader.readAll(); List<ApiCase> cases = new ArrayList<>(); for (Map<String, Object> row : rows) { ApiCase apiCase = new ApiCase(); apiCase.setCaseId(StrUtil.toString(row.get("用例编号"))); apiCase.setCaseName(StrUtil.toString(row.get("用例名称"))); apiCase.setMethod(StrUtil.toString(row.get("请求方式"))); apiCase.setPath(StrUtil.toString(row.get("请求路径"))); apiCase.setParams(StrUtil.toString(row.get("请求参数"))); apiCase.setExpectCode(Convert.toInt(row.get("期望code"))); apiCase.setExpectMsg(StrUtil.toString(row.get("期望msg"))); apiCase.setRun(StrUtil.toString(row.get("是否执行"))); cases.add(apiCase); } return cases; } }ApiCase就是一个普通 POJO,字段对应 Excel 列即可。这里我特别提醒一个坑:Excel 单元格里如果是数字,POI 读出来会变成 Double,比如你期望 code 是 200,结果拿到的是200.0,转成整数必须用Convert.toInt这类方式兜底,不能直接(Integer) row.get("期望code"),会抛 ClassCastException。
4.3 与 TestNG DataProvider 的绑定
TestNG 的@DataProvider很适合对接外部数据。注意一点:DataProvider 的方法签名可以带Method参数,这样我能根据当前测试方法名动态决定加载哪个 sheet,实现一个 provider 支撑多个测试类:
@DataProvider(name = "apiCaseProvider") public Object[] provideApiCases(Method method) { List<ApiCase> cases = ExcelCaseLoader.loadSheet(method.getName()); return cases.stream() .filter(ApiCase::isRun) .map(c -> new Object[]{c}) .toArray(Object[]::new); }测试方法里这样写:
@Test(dataProvider = "apiCaseProvider") public void userManage(ApiCase apiCase) { Response response = RequestExecutor.execute(apiCase.getMethod(), apiCase.getPath(), apiCase.getParams()); Assert.assertEquals(response.jsonPath().getInt("code"), apiCase.getExpectCode()); }用method.getName()作为 sheet 名有个隐性约定:测试方法名要和 sheet 名保持一致,否则加载不到数据。这是故意做的命名约束,比在注解里传 sheet 名更直白,团队成员一眼就能看懂。
4.4 参数动态化与数据隔离
Excel 里如果写死用户名,第二次跑就会撞数据。你可以在用例数据里预留占位符,执行前动态替换。比如请求参数写成{"userName": "test_{random}", "deptId": 103},然后在 RequestExecutor 里执行:
String finalParams = params.replace("{random}", RandomUtil.randomString(6)); RequestBody body = JSONUtil.parseObj(finalParams);替换之后还要考虑每次构造的数据不要互相污染。若依的 sys_user 表对 userName 有唯一约束,用例重复执行时建议统一使用test_前缀,并用时间戳或随机串保证唯一。数据清理放到测试套件跑完后统一做,可以直接连数据库按前缀删除。
5. 断言体系与结果校验:先解决断言什么,再解决怎么断言
接口自动化做了一段时间后,大家会发现测试能否抓住真问题,很大程度取决于断言写得到不到位。如果只是断言 HTTP 200,那这个用例基本没价值。若依这类后台系统,需要把断言分成几个层次来看。
5.1 若依响应结构解析
若依大部分业务接口的响应结构是这样:
{ "code": 200, "msg": "操作成功", "data": { ... } }但列表接口在使用了分页插件后,返回结构会变成:
{ "code": 200, "msg": "查询成功", "rows": [ ... ], "total": 36 }这两种结构虽然都有 code 和 msg,但业务数据一个在data,一个在rows。如果断言代码里写死了response.jsonPath().getString("data"),遇到列表接口必然拿不到值。所以断言基类必须考虑到两类响应,或者通过一个开关告诉执行器目标字段。
5.2 响应断言基类设计
我习惯把断言收敛到一个 ApiAssert 类里,跟 ApiClient 搭配使用。下面这段代码描述了几种高频断言:
public class ApiAssert { public static void assertCodeSuccess(Response response) { Assert.assertEquals(response.getStatusCode(), 200, "HTTP 状态码异常"); int code = response.jsonPath().getInt("code"); Assert.assertEquals(code, 200, "业务 code 异常,完整响应:" + response.asString()); } public static void assertCode(Response response, int expectCode) { int actualCode = response.jsonPath().getInt("code"); Assert.assertEquals(actualCode, expectCode, "业务 code 不符,期望:" + expectCode + " 实际:" + actualCode + " | 响应:" + response.asString()); } public static void assertDataField(Response response, String field, Object expectValue) { Object actualValue = response.jsonPath().get(field); Assert.assertEquals(actualValue, expectValue, "字段 " + field + " 校验失败 | 响应:" + response.asString()); } public static void assertRowsNotEmpty(Response response) { Assert.assertTrue(response.jsonPath().getList("rows").size() > 0, "rows 为空,可能未查询到数据 | 响应:" + response.asString()); } }几个设计要点:每次断言失败都把完整响应拼进去,这样报告里直接能看到响应内容,不用再去翻日志;对于期望 code 非 200 的场景,比如权限不足要断言 403,单独提供assertCode方法;对于列表接口,assertRowsNotEmpty这个断言非常实用,能判断接口是否真的返回了业务数据,而不是一个空壳成功。
5.3 数据库回查校验
接口断言只能证明前端入口的行为正确,但它写进数据库没有,需要靠数据库回查来兜底。比如测试创建用户接口,请求返回 code 200 之后,紧接着查一下 sys_user 表里到底有没有这条记录。这里我建议直接用 Hutool 的 Db 模块,配置简单,代码直观:
public class DbAssert { private static DataSource getDataSource() { HikariConfig config = new HikariConfig(); config.setJdbcUrl(EnvConfig.get("db.url")); config.setUsername(EnvConfig.get("db.username")); config.setPassword(EnvConfig.get("db.password")); return new HikariDataSource(config); } public static int countUserByUserName(String userName) { return Db.use(getDataSource()) .queryInt("select count(1) from sys_user where user_name = ?", userName); } }测试方法里这样组合:
ApiAssert.assertCodeSuccess(response); int count = DbAssert.countUserByUserName(userName); Assert.assertEquals(count, 1, "数据库应存在新创建的用户记录");数据库回查有一个隐性收益:它能顺带校验接口的幂等性。同一个创建接口如果重复执行,数据库里的记录数应该是稳定增长的,但如果接口没有做防重校验,你会发现每次执行都插一条新记录,这种问题光看接口返回是发现不了的。
6. 报告输出与失败定位优化
TestNG 自带的 HTML 报告能看个大概,但接口自动化的失败现场往往藏在请求参数和响应体里,默认报告不会帮你保留这些。所以报告增强的核心思路是两条:失败时把完整请求响应打出来;按用例维度聚合所有日志。
6.1 用监听器收集失败现场
TestNG 的IInvokedMethodListener可以在用例执行结束后的afterInvocation里拿到ITestResult,配合我们前面提到的TestReportHolder,把当前线程关联的请求日志挂到失败结果上,写到一个统一的 HTML 或 Markdown 报告里。关键代码思路:
public class ApiResultListener implements IInvokedMethodListener { @Override public void afterInvocation(IInvokedMethod method, ITestResult testResult) { if (testResult.getStatus() == ITestResult.FAILURE) { testResult.setAttribute("apiLog", TestReportHolder.getLog()); } TestReportHolder.clear(); } }然后在 testng.xml 里注册这个监听器。这样每次执行完,失败用例和对应的请求日志天然绑定,不用再去控制台翻找。
6.2 报告不只要好看,还要能定位
报告每一条失败记录里,至少要有这么几个字段:用例名、所属模块、请求方法、请求路径、请求参数、响应状态码、响应体、断言异常信息。有了这些,定位“是环境问题还是脚本问题”就很快。如果一个用例失败是因为接口真的改了返回结构,响应体会直接给出答案;如果一个用例失败是因为测试数据被污染,断言信息里的实际值和期望值对比也是一目了然。
我在实际项目中还做了一件事:给每个测试类定义一个模块属性,比如@Test(groups = "user"),报告里按模块分组展示。这样测试完成后的第一屏,能立刻看到哪个模块的失败率最高,这比挨个翻用例列表高效得多。
7. 实际踩坑与排查记录
这部分是我在实际搭建和使用这套框架过程中,反复遇到过的问题。整理成一个速查表,希望能帮你少走几步弯路。
7.1 高频问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 接口返回 401 Unauthorized | token 缺失、过期、或者 Header 名写错 | 检查 ApiClient 中 Authorization 头;确认本地 token 缓存是否过期;手动调登录接口,对比返回 token 是否为空 |
| 接口返回 500,但后端日志无异常 | 参数类型不对,比如字符串传给了 Integer 字段 | 用 JSON 校验工具检查请求参数格式;对比若依接口接收的实体类字段类型 |
| JSON 解析报 ClassCastException | Excel 读出来的数字被转成 Double | 使用 Convert.toInt 转换,不要直接强转 |
| 用例重复执行失败 | 测试数据没有隔离,唯一约束冲突 | 统一加 test_ 前缀,使用随机串,套件结束后清理数据 |
| 并行执行时 token 互相覆盖 | 用了普通静态变量存储,没有做线程隔离 | 改用 ThreadLocal 存 token,并在每个测试套件结束时 clear |
| TestNG 报告里中文乱码 | 控制台输出编码问题,或文件写入编码不一致 | 统一用 UTF-8,设置 testng.xml 的 encoding 为 UTF-8 |
| 列表接口断言取不到 data,但接口本身正常 | 把列表接口的返回结构当成了普通结构 | 分页列表数据在 rows 字段,不要用 data 取值 |
7.2 几条真实项目里的经验心得
第一个心得:不要每跑一个用例都重新登录,也不要永久复用同一个 token。正确的姿势是 TokenManager 里做“懒加载 + 过期检查”,只有 token 缺失或快过期时才重新登录。之前有个同事为了省事,把 token 直接写死在配置文件里,结果服务端重启后 token 全部失效,全量回归挂了半小时,排查半天才找到原因。
第二个心得:Excel 数据驱动虽然维护方便,但复杂嵌套结构描述能力有限。如果你要测的接口参数是一个非常复杂的 JSON 树,建议改用 JSON 文件按用例存储。Excel 适合字段扁平、结构稳定的接口,JSON 文件适合复杂业务场景。不用死守某一种方案,甚至可以两种共存。
第三个心得:数据库回查是好东西,但也要控制使用频率。每一条用例都回查数据库,执行时间会明显变长,而且数据库连接本身就是不稳定因素。我一般只在新增、修改、删除这类会改变数据状态的接口上做回查,纯查询接口只在关键字段上做。
第四个心得:写用例之前,先花时间整理一份若依框架的通用响应结构文档。把普通响应、分页响应、错误响应的 JSON 样例贴出来,让后面写断言的人有据可依。这份文档看起来不起眼,却能省下大量返工时间。
第五个心得:框架里所有工具类尽量减少静态继承链。我见过有团队把 BaseTest 类写成了一个巨大的上帝类,所有工具方法都往里塞,新成员接手的时候根本分不清哪些是测试逻辑,哪些是工具方法。更清晰的做法是拆成 TokenManager、ApiClient、ExcelCaseLoader、ApiAssert、DbAssert 这种按职责划分的小类,一个类只做一件事。
做接口自动化框架,最难的不是某个具体接口的脚本怎么写,而是整个体系能不能稳定的、低成本的持续运转。Token 管理统一了我最担心的并发串号,请求封装收敛了乱七八糟的重复代码,数据驱动让用例量上来之后还能轻松维护,断言分层让失败定位变得清爽。把这些基础打好,后续不管对接 CI、接入 Allure 报告,还是扩展 UI 自动化,都有了一个稳得住的底座。