Skip to content

REST Assured 与 Java HTTP Client ​

T2-005必学进阶约 90 分钟JDK 21测试专项 B:接口·UI·性能 · 接口自动化

1. 本节定位 ​

T2-001 设计了用例矩阵,本节把矩阵变成自动化代码 ✓

主角是 REST Assured——它用 given / when / then 三段式把"发请求 + 断言"写成一门可读的"语言" ✓

java
given()
    .contentType(ContentType.JSON)
    .body(请求体)
.when()
    .post("/api/orders")               // 当:发起请求
.then()
    .statusCode(201)                   // then:断言
    .body("orderNo", startsWith("ORD-"));

2. 学习目标 ​

  • 会引入 REST Assured 并与 @SpringBootTest 随机端口配合 ★★;
  • 掌握 given / when / then 三段式 ★★;
  • 会用 JsonPath 断言嵌套结构与数组 ★★;
  • 会用 Groovy 表达式做"查找式"断言(find { it.field == 'x' })★★★;
  • 知道 Java 17+ 的 HttpClient 与 RestClient 什么时候用;
  • 能把 T2-001 的用例矩阵实现为参数化测试 ★★。

3. 前置知识检查 ​

  • 完成 T2-001 的用例矩阵了吗?✓
  • 会用 @SpringBootTest(webEnvironment = RANDOM_PORT) 吗?(T1-017)✓

4. 为什么需要这个知识 ​

用裸 HttpClient 写断言:

java
// ✗ 要自己发请求、自己解析 JSON、自己断言——样板代码淹没意图
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body)).build();
HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
assertEquals(201, response.statusCode());
assertTrue(response.body().contains("ORD-"));       // ✗ 字符串断言太脆

REST Assured 的同一件事:

java
given().contentType(JSON).body(body)
  .when().post("/api/orders")
  .then().statusCode(201).body("orderNo", startsWith("ORD-"));   // ✓ 一眼读懂

差别不在行数,在"可读性即可维护性" ✓

5. 核心概念 ​

5.1 依赖与基础设置 ​

xml
<!-- 版本由 Spring Boot BOM 管理(S3-028)✓ -->
<dependency>
  <groupId>io.rest-assured</groupId>
  <artifactId>rest-assured</artifactId>
  <scope>test</scope>
</dependency>

与随机端口配合 ★★:

java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiRestAssuredTest {

    @LocalServerPort
    private int port;

    @BeforeEach
    void setUp() {
        RestAssured.baseURI = "http://localhost";
        RestAssured.port = port;                  // ★ 每个测试类前设置(RANDOM_PORT 的端口每次不同)✓
    }
}

★ 这与 T1-017 的 TestRestTemplate 是"同一个测试形态、两套客户端" ——选 REST Assured 的理由是断言表达力 ✓

5.2 given / when / then 三段式 ★★ ​

java
given()                                    // given:准备(头、体、参数)
    .contentType(ContentType.JSON)
    .body("""
          {"productId":"P-1","quantity":2,"address":"上海市浦东新区"}
          """)
.when()                                    // when:动作(方法 + 路径)
    .post("/api/orders")
.then()                                    // then:断言 ★
    .statusCode(201)
    .header("Location", containsString("/api/orders/"))
    .body("orderNo", startsWith("ORD-"))
    .body("amountInCents", equalTo(39800))          // ★ 业务断言:19900 × 2 ✓
    .body("status", equalTo("CREATED"));

实测(本项目运行结果):

text
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 21.85 s
  -- in cn.kb.learn.web.OrderApiRestAssuredTest
BUILD SUCCESS

三段式的"读法":

"给定这样一个请求,当发到这个端点,那么应该得到这样的响应。"

这就是 BDD(行为驱动)的自然语言结构 ✓ ——非开发人员也能读懂用例 ✓

5.3 JsonPath 断言 ★★ ​

.body() 的第一个参数是 JsonPath 表达式(类似 《JSON 与 XML》 提到的路径语法):

表达式含义
orderNo根字段
details.size()数组长度 ★
details[0].field数组第一个元素的字段
details.find { it.field == 'quantity' }.message ★★★Groovy 查找:字段为 quantity 的那条错误的 message ✓

实测(《Spring MVC 请求映射与参数绑定》 的校验失败场景):

java
given().contentType(JSON)
    .body("""
          {"productId":"P-2","quantity":0,"address":""}
          """)
.when().post("/api/orders")
.then()
    .statusCode(422)
    .body("code", equalTo("VALIDATION_FAILED"))
    .body("details", hasSize(2))                                    // ← 收集了两个错误 ✓
    .body("details.find { it.field == 'quantity' }.message",
          equalTo("数量必须大于 0"))                                  // ★ 查找式断言
    .body("traceId", notNullValue());                                 // traceId 存在 ✓

find { } 的价值:

"details 数组里,field 是 quantity 的那一条的 message" ——一行表达"按条件找元素再取属性" ✓

用 Java 手写要"遍历 + 过滤 + 断言"三步 ✗ ——这就是 Groovy 表达式的威力 ✓

⚠️ 注意:find 是 Groovy 语法(REST Assured 内置)——它不是标准 JsonPath ✓

5.4 完整生命周期测试 ★★ ​

接口测试的经典形态:一条测试走完"创建 → 查询 → 删除 → 验证删除" ✓

java
@Test
void full_lifecycle() {
    // 创建(并提取 orderNo ★)
    String orderNo = given().contentType(JSON)
        .body("""
              {"productId":"P-LIFE","quantity":1,"address":"北京市"}
              """)
    .when().post("/api/orders")
    .then().statusCode(201)
        .extract().jsonPath().getString("orderNo");      // ★ extract:从响应提取值

    // 查询
    given().when().get("/api/orders/" + orderNo)
        .then().statusCode(200).body("productId", equalTo("P-LIFE"));

    // 删除
    given().when().delete("/api/orders/" + orderNo)
        .then().statusCode(204);

    // 再查 → 404(★ 证明"删除真的生效了")
    given().when().get("/api/orders/" + orderNo)
        .then().statusCode(404);
}

★ extract() 的价值:把响应中的值取出来,供后续请求使用 ✓ ——这是"接口依赖测试"的基础(T2-007)✓

第四步为什么必要:只验证 204 不够——"删除后再查是 404"才证明删除生效 ✓✓

5.5 参数化:把矩阵变成代码 ★★ ​

T2-001 的用例矩阵 → @MethodSource ✓

java
static Stream<Arguments> invalidRequests() {
    return Stream.of(
        Arguments.of("quantity=0",   """
            {"productId":"P","quantity":0,"address":"上海"}""", "quantity"),
        Arguments.of("productId 空白", """
            {"productId":"   ","quantity":1,"address":"上海"}""", "productId")
    );
}

@ParameterizedTest(name = "{0}")
@MethodSource("invalidRequests")
void should_reject(String label, String body, String expectedField) {
    given().contentType(JSON).body(body)
    .when().post("/api/orders")
    .then().statusCode(422)
        .body("details[0].field", equalTo(expectedField));

    verifyNoInteractions(orderService);       // 校验层拦截,未触达业务 ✓(S3-023)
}

★ "18 条用例矩阵 → 一个参数化测试" ✓ ——设计直接变现 ✓

5.6 Java 17+ 的 HttpClient 与 RestClient ★ ​

什么时候不用 REST Assured:

场景选择
接口测试断言REST Assured ✓
业务代码里调第三方 APISpring 的 RestClient(Spring 6.1+)或 WebClient ✓
JDK 原生、无依赖java.net.http.HttpClient ✓
java
// Spring 6.1 的 RestClient(同步、流式 API)★
RestClient client = RestClient.create();
OrderResponse order = client.get()
        .uri("https://api.example.com/orders/{no}", orderNo)
        .retrieve()
        .body(OrderResponse.class);

⚠️ 一个重要区分:

RestClient 是"业务代码调用别人"用的;REST Assured 是"测试自己"用的。 ✓

不要在生产代码里用 REST Assured ✗(它是测试框架)✓

5.7 常用匹配器速查 ★ ​

匹配器用途
equalTo(x)相等
startsWith / containsString字符串 ✓
hasSize(n) / hasSize(greaterThan(n))集合大小 ★
hasKey / hasEntryMap/字段
notNullValue()存在性(如 traceId)✓
matchesPattern正则 ★

6. 递进示例 ​

6.1 最小示例 ​

java
given().when().get("/api/orders").then().statusCode(200);

6.2 业务示例 ​

见 5.2 ~ 5.5(四个测试 = 本项目实测源码)✓

6.3 反例:字符串断言 ​

java
// ✗ 用整个响应体做字符串断言
assertEquals(201, response.getStatusCode());
assertTrue(response.getBody().asString().contains("ORD-"));
// ✗ "ORD-" 在任何字段里出现都算过——断言太弱 ✗

修:JsonPath 精确断言字段 ✓

7. 完整可运行代码 ​

文件:labs/spring-boot-web/src/test/java/cn/kb/learn/web/OrderApiRestAssuredTest.java(4 个测试 ✓)

运行:

powershell
cd labs
.\mvnw.cmd -pl spring-boot-web test "-Dtest=OrderApiRestAssuredTest"

预期输出:

text
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 21.85 s
BUILD SUCCESS

四个测试对应 T2-001 矩阵的哪几条:

测试对应矩阵用例
创建 201 + Location用例 1(正向)✓
校验失败 422 + 2 条 details用例 4/6/9/13(反向 + 组合)✓
404 + 错误契约用例 3 的变体(资源不存在)✓
完整生命周期接口依赖(T2-007 的预演)✓

8. Debug 观察点 ​

  1. 加 .log().ifValidationFails():断言失败时自动打印完整请求与响应 ★✓;
  2. extract().response():把整个响应对象取出来自由分析 ✓;
  3. 断言失败时看 JsonPath 报错:"No value at JSON path"通常意味着路径写错或字段名不同 ⚠️;
  4. 对比 curl 与测试:同一条请求先 curl 后测试,理解"两者是等价的" ✓。

9. 常见错误 ​

#错误说明
1JSON 路径写错(大小写、嵌套层级)No value at JSON path ⚠️ ——先 r.json() 打印看结构 ✓
2忘了随机端口连接被拒(连到了 8080 上没有的服务)✗
3测试间数据污染用例互相依赖(T1-007)✗ ——每条测试自建数据 ✓
4断言太弱(只查状态码)见 T2-001 的 5.2 ✗
5生产代码引入 REST Assured ✗它是测试框架 ✗ ——scope=test 且只在 test 用 ✓

10. 动手实验 ​

  1. 跑第 7 节命令,4 个测试全绿 ✓;
  2. 验证断言的"保护力":把后端的 @Min(1) 删掉 → 跑 REST Assured 测试 → 观察 422 那条变红 ★(测试在保护契约)✓;
  3. 加一条"重复提交"用例:连续两次相同创建,断言两次都 201(记录 orderNo 不同——为 T2-007 的幂等性讨论埋点)✓;
  4. 用 extract() 提取 traceId 并打印 ✓;
  5. 把 .log().ifValidationFails() 加到 given,故意改错一个断言 ——观察自动打印的完整请求/响应 ★。

验收方式:能说出第 2 步"哪条用例红了、它保护的是什么契约"。

扩展任务:把 T2-001 的 18 条用例矩阵全部实现(反向用例用参数化),统计总用例数并与矩阵核对 ✓。

11. 练习题 ​

基础题

  1. 判断:REST Assured 的 .body("orderNo", ...) 断言的是 HTTP 头。(对 / 错)
  2. 填空:given 对应 ______,when 对应 ______,then 对应 ______;从响应中提取值用 ______。
  3. 简答:为什么"随机端口"要配合 @BeforeEach 设置 RestAssured.port?

代码阅读题

java
given().contentType(JSON).body("""
    {"productId":"P-1","quantity":2,"address":"上海"}""")
.when().post("/api/orders")
.then().statusCode(201)
    .body("details", hasSize(2));
  1. 这个断言有什么问题?(提示:201 的响应里有 details 吗?)
  2. 如果想断言"Location 头指向刚创建的资源",怎么写?

编程题

  1. 实现本节 4 个测试 + T2-001 矩阵的反向用例(参数化):
    • 至少 6 条反向用例(quantity 边界/类型、productId、address、非法 JSON);
    • 全部断言 422/400 + details 的字段与消息;
    • 注意区分 422 与 400(校验失败 vs JSON 解析失败——《JSON 与 XML》 的 5.7)★;
    • 统计总用例数 ✓。

综合任务

  1. 设计"接口测试的分层结构":
    • 背景:团队 10 个接口 30 条用例,散落在各类里,重复代码多(都手写 fetch/断言);
    • 要求:
      • 设计封装层(api() 封装、请求构造器、断言辅助);
      • 说明测试类怎么组织(按资源?按场景?)★;
      • 说明测试数据的管理(T2-006 会展开,这里给原则);
      • 说明为什么"given/when/then + 封装"比"裸 HttpClient"更易维护;
    • 额外要求:说明"REST Assured 与 T1-016 的 MockMvc 测试该并存还是二选一",并给出取舍依据。
分级提示

提示 1(方向):第 4 题的关键是"201 的响应体是订单,不是错误结构"。

提示 2(关键点):第 7 题"并存还是二选一"——MockMvc 快(不起容器)适合大量小断言;REST Assured 真(走全链路)适合关键路径 ✓ 两者互补 ✓

提示 3(接近答案):第 7 题的组织——按资源分文件(OrderApiTest),按场景分方法(create/list/delete/lifecycle) ✓

12. 参考答案(要点) ​

基础题

  1. 错。.body() 断言的是 JSON 响应体(JsonPath)✓;HTTP 头用 .header() ✓
  2. 准备(请求);动作(请求);断言(响应);extract().jsonPath() ✓
  3. 因为 RANDOM_PORT 每次运行端口都不同 ✓ ——硬编码端口会连不上 ✗

代码阅读题

  1. 有问题:201 的响应体是 OrderResponse(没有 details 字段) ✗ ——这个断言永远失败 ✗✗(把 422 的结构错用在了 201 上) 正确:断言 orderNo / amountInCents 等 ✓
  2. .header("Location", containsString("/api/orders/")) ✓ 更强的写法:.header("Location", equalTo("/api/orders/" + orderNo)) ——先 extract 再精确断言 ✓

编程题

  1. 要点:422(校验)与 400(JSON 解析)要分开断言——它们走的是不同异常路径(《JSON 与 XML》 的 5.7)✓ HttpMessageNotReadableException → 400;MethodArgumentNotValidException → 422 ★

综合任务

  1. 分层结构:
text
test/
├── support/
│   ├── ApiClient.java          ← 封装 given/when 的通用部分(baseURI、公共头)
│   └── TestData.java           ← 测试数据工厂([T2-006](./T2-006))
├── order/
│   ├── OrderApiTest.java       ← 按"资源"分文件
│   │   ├── create_should_201
│   │   ├── list_should_paginate
│   │   └── ...
│   └── OrderLifecycleTest.java ← 按"场景"分(跨接口的生命周期)★

组织原则:

原则说明
按资源分文件一个资源一个测试类 ✓
按场景分方法方法名 = 场景名(英文描述性命名)✓
公共逻辑上提封装到 support/(DRY)✓
每个测试独立自建数据(T1-007)✓

与 MockMvc 的取舍:

MockMvc(T1-016)REST Assured + 真端口
速度快(不起容器)✓慢(启动容器)✗
真实度不走网络走真实 HTTP ✓
适合Web 层的大量小断言 ✓关键路径 + 跨层验证 ✓

结论:并存且互补 ✓

MockMvc 像单元测试的"快",REST Assured 像集成测试的"真" ✓

规则:MockMvc 覆盖"Web 层细节"(校验、异常映射);REST Assured 覆盖"关键业务路径"(每个接口 1~3 条) ✓

这与 T1-017 的"切片 vs 集成"是同一组关系 ✓ :::

13. 掌握标准 ​

  • [ ] 会用 given/when/then 三段式发请求并断言 ★★;
  • [ ] 会用 JsonPath 断言嵌套结构与数组长度 ★★;
  • [ ] 会用 Groovy 的 find { } 做"查找式断言" ★★★;
  • [ ] 会用 extract() 提取响应值做接口依赖 ★★;
  • [ ] 知道 RestClient 与 REST Assured 的用途边界 ✓;
  • [ ] 能把 T2-001 的矩阵实现为参数化测试 ✓。

14. 扩展资源 ​

15. 下一步学习 ​

《接口鉴权与测试数据管理》:受保护接口怎么测?数据怎么准备与清理。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引