ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

REST Assured POST接口测试实战:请求体构造与响应校验全攻略

REST Assured POST接口测试实战:请求体构造与响应校验全攻略 1. 从GET到POST请求体才是测试的主战场1.1 为什么POST测试比GET请求更容易翻车上一篇文章我们搭好了REST Assured的基础环境把GET请求的断言和日志跑通了。今天这篇接续前面的进度专门把POST这条链路彻底打通。如果你正准备用REST Assured把注册、登录、创建订单、文件上传这些核心业务场景做成自动化用例这篇文章就是冲着你来的。先说一个我观察到的现象很多刚开始写接口自动化的朋友用GET请求写两个用例就觉得自己会了结果一碰到POST就卡住。原因很简单——GET请求的参数都写在URL上断言起来就是查一下返回码、对一下字段值POST请求则完全不同它要组织请求体、处理序列化、应对multipart上传还要处理登录态整个链路的变量一下子多了好几倍。接口测试里真正的业务逻辑集中地带恰恰就是POST接口。所以这一篇我不会再花篇幅讲环境搭建和Maven依赖那些在第一部分里已经说透了。直接进入POST请求测试的实战环节请求体怎么构造、响应怎么校验、文件上传怎么做、有哪些坑我已经替你们踩过了。1.2 POST请求测试的完整链路长什么样我习惯把一个POST接口的自动化测试拆成四个环节来看请求准备构造请求体与鉴权信息、发起请求POST到指定URI、响应提取拿到返回内容、结果断言校验状态码和业务字段。given() .contentType(ContentType.JSON) .body(requestBody) .when() .post(/api/users) .then() .statusCode(201) .body(data.id, notNullValue());这段代码麻雀虽小五脏俱全。given()负责请求准备body()是POST请求区别于GET的核心——它承载了要提交给服务器的数据when().post()发起请求then()里做状态码和响应体的双重校验。很多新手在这里会有一个误区以为POST请求就是比GET多了一个body其实不然。body里放什么格式、字段类型怎么组织、null值怎么处理、时间日期怎么序列化每一个细节都可能成为线上事故的导火索。我开始做接口自动化测试前两年一大半的bug修复工作都是在跟请求体搏斗。所以这篇的主体内容就是告诉你如何在构造请求体这一步就把问题拦住而不是等后端返回500了再去翻日志。2. 构造POST请求体的四种方式Map、POJO、字符串与外部文件2.1 四种方式横向对比REST Assured对请求体的类型几乎没有限制可以是Java集合可以是POJO对象可以是字符串甚至可以是File。这也是很多初学者懵圈的地方——网上教程一会放Map一会放对象到底学哪个我的建议是先理解每种方式的适用场景再根据项目情况选。下面这张表是我实践中的总结构造方式代码示例优点缺点适用场景MapMapString, Object写法直观、零额外依赖无类型检查重构困难快速验证接口、临时调试POJO对象new CreateUserRequest(...)类型安全、可复用、易重构需要定义类和序列化依赖正式测试代码、团队协作JSON字符串{\username\:\tester\}所见即所得、调试方便转义麻烦、易出错验证固定报文、压测构造外部JSON文件new File(request.json)数据与代码分离、易维护需要额外的文件加载逻辑大批量测试数据、多环境配置2.2 Map方式最快跑通的捷径Map方式最接近人类思维。你不需要额外建类直接在测试方法里put字段即可MapString, Object requestBody new HashMap(); requestBody.put(username, tester); requestBody.put(age, 28); requestBody.put(tags, Arrays.asList(java, api)); requestBody.put(address, Map.of(city, Shanghai, zip, 200000)); given() .contentType(ContentType.JSON) .body(requestBody) .when() .post(/api/users) .then() .statusCode(201);REST Assured底层会借助Jackson或Gson把Map序列化成JSON。嵌套结构也可以通过Map.of或者往Map里再塞一个List来实现。我早期做调试的时候特别喜欢这种方式因为不用开IDE新建类写起来快改起来也快。2.3 POJO方式正式项目里的正解当接口字段开始变多、测试用例规模超过十几个之后我强烈建议切换到POJO方式。原因有两点第一Java是强类型语言POJO能在编译期拦截字段拼写错误第二接口的字段是会进化的用POJO时字段变更、删除IDE可以直接帮你找到所有引用点而Map方式只能靠眼力和全局搜索。public class CreateUserRequest { private String username; private Integer age; private ListString tags; private Address address; // getter和setter省略 }测试代码变成这样CreateUserRequest request new CreateUserRequest(); request.setUsername(tester); request.setAge(28); request.setTags(List.of(java, api)); given() .contentType(ContentType.JSON) .body(request) .when() .post(/api/users) .then() .statusCode(201);注意POJO要能被正常序列化需要满足两个条件一是类上有无参构造器REST Assured通过Jackson/Gson反射创建对象二是字段必须有getter/setter或者字段本身是public且配合对应序列化配置。否则抛出来的异常会让人摸不着头脑。2.4 字符串与外部文件固定报文和批量数据怎么办如果只是调试单个接口字符串方式也够用。Java 15之后有了文本块写JSON字符串变得舒服多了String json { username: tester, age: 28, tags: [java, api] } ;文本块省去了\n和\的转义噩梦。不过我还是建议别在测试代码里硬编码大段JSON尤其是那些很长的报文。更好的做法是把JSON放进src/test/resources/requests/目录用File加载given() .contentType(ContentType.JSON) .body(new File(src/test/resources/requests/createUser.json)) .when() .post(/api/users) .then() .statusCode(201);当你想针对不同场景比如不同用户类型、不同边界年龄构造请求时可以在外部文件里放模板占位符比如{username:{{username}}}然后在代码里先读取文件内容用replaceAll替换占位符再放进body()。这个方法虽然土但非常实用尤其在批量生成测试数据环节。3. 响应校验闭环状态码、JsonPath断言与Schema验证3.1 状态码校验不是简单的200POST请求的状态码语义比GET丰富得多——创建成功通常是201 Created但也有接口设计成200 OK参数校验失败一般是400 Bad Request未登录是401权限不足是403。如果你的断言写死在200上遇到设计严谨的后端你的测试用例会全挂。我建议在断言时把状态码和业务码分开看待。正确姿势是这样.then() .statusCode(201) .body(code, equalTo(0)) .body(message, equalTo(创建成功));有些公司会在响应体里放code和message字段业务是否成功以code为准。这时候如果HTTP状态码是200但code不是0说明后端对异常情况兜底了测试用例应该能敏锐地捕捉到这一点。REST Assured的then()里可以同时断言很多东西我习惯先状态码、再业务码、再具体字段值三层校验缺一不可。3.2 用JsonPath高效提取响应字段REST Assured内置了一套类JsonPath语法和JSONPath规范基本一致。比如响应体长这样{ data: { id: 1024, username: tester, orders: [ {id: 1, amount: 99.9}, {id: 2, amount: 19.9} ] } }断言可以这样写.then() .body(data.id, notNullValue()) .body(data.username, equalTo(tester)) .body(data.orders[0].amount, equalTo(99.9f));注意最后一行我写了equalTo(99.9f)而不是equalTo(99.9)。这是一个细节坑REST Assured内部用Groovy的JsonSlurper解析JSON数字小数会被解析成Float或Double而equalTo(99.9)传入的是Double类型不一致会导致断言失败。遇到小数字段要么加f后缀要么用is(99.9f)。如果要对整个响应体做复杂提取也可以把响应内容先拿出来再用独立的JsonPath实例处理JsonPath jsonPath response.then().extract().jsonPath(); int userId jsonPath.getInt(data.id); String username jsonPath.getString(data.username); ListInteger orderIds jsonPath.getList(data.orders.id);这种写法的好处是可以把提取响应数据和断言拆开便于在多个断言步骤之间复用同一个响应内容。3.3 响应体反序列化为POJO泛型也能搞定如果你的测试代码已经全面转向POJO风格那响应体也应该对称地反序列化成对象来校验。REST Assured里用as()方法即可CreateUserResponse resp given() .contentType(ContentType.JSON) .body(request) .when() .post(/api/users) .then() .statusCode(201) .extract() .as(CreateUserResponse.class); assertEquals(tester, resp.getData().getUsername()); assertNotNull(resp.getData().getId());遇到带泛型的响应体比如ResultListOrder直接用as(Class)不行需要借助TypeRefResultListOrder result response.as(new TypeRefResultListOrder() {});TypeRef来自io.restassured.common.mapper.TypeRef通过匿名子类的方式让Java在运行期保留泛型信息。我第一次用的时候在包里翻了好久才找到这个细节值得记一下。3.4 JSON Schema验证把响应校验提一个维度字段断言能拦住大多数问题但拦不住响应结构变了这类大问题。比如后端某天突然把data从对象改成数组或者把字段名username改成了userName你的字段断言照样能过吗不一定因为不涉及的字段根本不会被校验到。这时候JSON Schema验证就有用武之地了。REST Assured有一个独立模块json-schema-validatorMaven引入dependency groupIdio.rest-assured/groupId artifactIdjson-schema-validator/artifactId version5.4.0/version /dependency用法很简单把Schema文件放在src/test/resources/schemas/下然后import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath; given() .contentType(ContentType.JSON) .body(request) .when() .post(/api/users) .then() .statusCode(201) .body(matchesJsonSchemaInClasspath(schemas/createUserResponseSchema.json));Schema文件的核心片段长这样{ type: object, required: [code, data], properties: { code: {type: integer}, data: { type: object, required: [id, username], properties: { id: {type: integer}, username: {type: string} } } } }我个人的做法是核心业务流程的POST接口响应体Schema验证作为兜底在此基础上再写针对性字段断言。这样既有结构保障又有业务细节保障。4. 文件上传、Token认证与Session管理真实业务的三座山4.1 multipart文件上传POST接口的专属场景POST请求里绕不开的一个场景就是文件上传。REST Assured对multipart/form-data的支持很完善核心就是一个multiPart()方法given() .multiPart(file, new File(src/test/resources/files/test.pdf)) .multiPart(description, 这是测试上传) .when() .post(/api/upload) .then() .statusCode(200);细心的朋友会发现这里没有设置contentType(ContentType.JSON)也不需要手动设置ContentType.MULTIPART。REST Assured检测到multiPart()之后会自动切换成multipart/form-data并生成正确的boundary。那如果既要传文件又要传JSON结构的数据怎么办比如上传文件时附带一个JSON格式的元信息字段。可以给multiPart()的第三个参数指定ContentTypegiven() .multiPart(file, new File(test.pdf)) .multiPart(metadata, jsonString, application/json) .when() .post(/api/upload) .then() .statusCode(200);这里有个经验multiPart(file, new File(...))的第二个参数也可以传InputStream和byte[]但调试时直接用File最省事因为报错信息里能看到真实的文件路径排查效率高很多。4.2 认证方式Token和Basic Auth怎么选POST接口因为涉及数据变更一般都会有鉴权。最常见的是Bearer TokenREST Assured里写起来非常简洁given() .auth().oauth2(token) .contentType(ContentType.JSON) .body(request) .when() .post(/api/secure/orders) .then() .statusCode(201);如果接口用的是Basic Auth也就是用户名密码的组合given() .auth().basic(username, password) .body(request) .when() .post(/api/admin/users) .then() .statusCode(201);我见到不少团队会把Token硬编码在测试类里或者放在src/test/resources下的properties文件中。硬编码的坏处是Token一过期就要改代码放在配置文件里又容易泄露。更稳妥的做法是把登录获取Token的步骤做成一个BeforeAll方法所有测试类继承同一个基类登录态统一管理。这章的第六部分我会给出具体代码。4.3 Session与Cookie老系统最爱的登录态方案如果你测的是一个历史悠久的Java Web系统很可能还在用Session Cookie。REST Assured推荐的方案是SessionFilterSessionFilter sessionFilter new SessionFilter(); given() .auth().form(admin, 123456, new FormAuthConfig(/login, username, password)) .filter(sessionFilter) .when() .post(/api/login) .then() .statusCode(200); // 后续请求自动携带session given() .filter(sessionFilter) .contentType(ContentType.JSON) .body(request) .when() .post(/api/create) .then() .statusCode(201);SessionFilter会把登录过程中服务端下发的Cookie或Session ID自动保存下来并在后续请求中携带。这个设计非常巧妙你不用手动维护Session变量只需要在每个需要登录态的请求上挂同一个filter实例即可。4.4 参数化十个参数不同的POST请求怎么优雅实现开头提了一个很常见的场景用jmeter并发十个参数不同的POST请求。其实在REST Assured里多个不同参数的请求更常用的叫法是参数化测试。我用JUnit 5的ParameterizedTestMethodSource来写ParameterizedTest MethodSource(userDataProvider) void createUserWithDifferentProfiles(String username, Integer age, int expectedStatus) { CreateUserRequest request new CreateUserRequest(); request.setUsername(username); request.setAge(age); given() .contentType(ContentType.JSON) .body(request) .when() .post(/api/users) .then() .statusCode(expectedStatus); } static StreamArguments userDataProvider() { return Stream.of( Arguments.of(tester, 28, 201), Arguments.of(, 28, 400), Arguments.of(tester, 0, 400) ); }推荐这种方式做参数化因为测试报告里每个参数组合都是一个独立用例定位失败用例非常清晰。如果只是在一个for循环里发请求失败了都堆在同一个方法里排错成本会高很多。5. 我在POST测试里踩过的坑编码、序列化与类型比较5.1 中文变问号编码问题如何根治我第一次用REST Assured提交一个含中文的POST接口时后端存进去的数据全是问号。当时排查了很久最后发现是Content-Type缺少charsetUTF-8导致的。REST Assured默认使用的字符编码不一定对得上后端接口的解析方式。解法有两个层面。第一个层面在请求上显式声明编码given() .contentType(application/json;charsetUTF-8) .body(json) .when() .post(/api/users);第二个层面如果你有大量请求都要带这个头可以统一配置RestAssuredConfig config RestAssuredConfig.config() .encoderConfig(EncoderConfig.encoderConfig().defaultContentCharset(UTF-8)); given() .config(config) .contentType(ContentType.JSON) .body(json) .when() .post(/api/users);经验是只要出现了中文乱码优先检查请求头里的charset如果确认请求侧没问题再检查服务端数据库的字符集配置这两个地方是高频故障点。5.2 LocalDateTime序列化失败两个库的行为不一样如果你用java.time.LocalDateTime作为POJO字段在序列化时很容易报错。这里的关键在于REST Assured底层用的是哪个序列化库——Jackson和Gson对Java 8时间类型的支持程度完全不同。Gson默认不支持LocalDateTime会直接抛UnsupportedOperationException。Jackson则要好一些但也需要注册JavaTimeModule并且默认会把时间序列化成时间戳数组而不是ISO字符串这往往也不是后端想要的。最简单的规避方案有两种。第一种在POJO里把时间字段声明为String自己控制格式化逻辑private String birthday;第二种用时间戳long与后端约定。如果后端已经把时间定义成标准字符串格式那直接用String是最省心的。我不建议在测试代码里为了支持LocalDateTime去配置复杂的序列化适配器除非你确实需要验证前端传入的时间格式是否正确这类场景。5.3 null字段被发送后端非空校验直接打回POJO中没赋值的字段序列化时会被保留成null。如果你的后端对某些字段做了非空校验哪怕你没打算传这个字段null值也会触发校验失败。Jackson和Gson在这个行为上不一样。Gson默认会忽略null字段Jackson默认会把null字段也序列化输出。所以同样的POJO换了序列化库请求体可能天差地别。如果你用的是Jackson需要在POJO类或字段上标注JsonInclude(JsonInclude.Include.NON_NULL) private String remark;如果整个项目都希望默认忽略null可以在对象映射器层面全局配置。这个坑很容易被忽略我见过不少测试用例在本地跑得好好的一到CI环境就报400最后发现是环境里序列化库版本不一致导致的。5.4 数值断言失败类型问题是最大元凶前面提过小数断言要加f后缀。除此之外整数类型也有坑。REST Assured的JsonPath对于JSON里的整数解析结果可能是Integer也可能是Long、BigInteger、BigDecimal取决于数值大小。我在实战中遇到过最莫名其妙的失败是.body(data.totalAmount, equalTo(0))明明响应里返回的就是0但断言就是红。后来把返回类型打印出来发现是个BigDecimal。加上.intValue()转换或者改用equalTo(BigDecimal.ZERO)才搞定。所以我的经验是断言数值类型时如果接口返回的是浮点金额、大整数这类特殊数值不要盲目用equalTo可以先用extract().jsonPath().getObject(data.totalAmount, 具体的类.class)确认类型再决定断言方式。排查成本远低于在CI日志里反复试错。5.5 日志配置排查问题前先学会看请求报文最后一个坑不是功能层面的而是排查效率层面的。很多测试用例报错了第一反应就是看服务端日志但其实大部分POST请求的问题看请求报文就够了。我推荐每条POST用例至少带上这个配置given() .log().all() .contentType(ContentType.JSON) .body(request) .when() .post(/api/users) .then() .log().ifValidationFails() .statusCode(201);log().all()会打印完整的请求行、请求头、请求体log().ifValidationFails()则只在断言失败时打印响应内容避免正常运行的时候控制台刷屏。这套组合我用了很久基本覆盖了90%的排查场景。6. 从能跑到稳定跑把POST测试落地到团队流程6.1 抽一个ApiTestBase基类让每个用例少写十行样板如果每个POST测试方法都从given()开始写项目里会出现大量重复代码——同样的baseUri、同样的鉴权、同样的ContentType、同样的日志配置。我建议抽一个测试基类public abstract class ApiTestBase { protected static String token; BeforeAll static void setUp() { token fetchToken(); } protected RequestSpecification defaultRequestSpec() { return RestAssured.given() .baseUri(https://api.example.com) .auth().oauth2(token) .contentType(ContentType.JSON) .config(RestAssuredConfig.config() .encoderConfig(EncoderConfig.encoderConfig().defaultContentCharset(UTF-8))) .log().ifValidationFails(); } }测试方法里只需要Test void createUser_success_whenValidRequest() { CreateUserRequest request new CreateUserRequest(); request.setUsername(tester); defaultRequestSpec() .body(request) .when() .post(/api/users) .then() .statusCode(201) .body(data.username, equalTo(tester)); }6.2 RequestSpecBuilder比基类更灵活的半成品请求如果你的测试场景比较复杂比如有的测试类需要不同的鉴权用户或者不同的ContentType可以考虑RequestSpecBuilder。它相当于一个半成品请求模板你可以在构建时设置公共部分使用时按需追加RequestSpecification spec new RequestSpecBuilder() .setBaseUri(https://api.example.com) .addHeader(X-Client-Source, automation) .build(); given().spec(spec) .auth().oauth2(adminToken) .body(adminRequest) .when() .post(/api/admin/users);这种方式比基类更灵活因为它没有强制继承关系每个测试类可以组合出不同的spec。我团队里现在的做法是基类 RequestSpecBuilder结合公共部分写在基类个性化部分通过spec叠加。6.3 测试命名与分组让CI报告说人话POST接口的用例命名我强烈建议遵循行为 场景 期望三段式。比如createUser_success_whenValidRequest、createUser_fail_whenUsernameEmpty、createOrder_created_whenStockEnough。这样的命名在CI报告中一眼就能看懂不用点开代码。如果要做分层执行——比如提交代码后只跑冒烟测试晚上再跑全量回归——可以用JUnit 5的TagTest Tag(smoke) void createUser_success_whenValidRequest() { ... }然后在Maven Surefire插件里配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId configuration groupssmoke/groups /configuration /plugin配合CI流水线参数就能做到PR阶段只跑冒烟master分支合并后跑全量。这个改造对团队来说成本极低收益却非常明显——测试执行时间从半小时压缩到三分钟开发同学自然就更愿意跑测试了。6.4 实测中加入环境配置一个命令切换测试环境稳定落地的最后一步是让测试脚本可以在不同环境间切换。比如你有dev、staging、prod三个环境。做法是在src/test/resources下放不同环境的properties文件然后用系统参数驱动String env System.getProperty(env, dev); String baseUri PropertiesLoader.get(env- env, api.base.uri);配合Maven命令mvn test -Denvstaging -Dgroupssmoke这样一来一套测试代码可以通用跑多个环境CI上只需要把环境参数替换一下即可。我在实际落地这套方案之后最直观的感受是团队不再依赖于手工用Postman点来点去线上出问题之后可以马上跑一条自动化用例复现而且因为POST接口的请求体全部被测试代码管起来了前后端字段不一致的问题在开发阶段就会被暴露而不是等上线之后才被发现。这大概就是接口自动化测试最有价值的回报。
返回列表