主题
界面
主题
T2-001 设计了用例矩阵,本节把矩阵变成自动化代码 ✓
主角是 REST Assured——它用 given / when / then 三段式把"发请求 + 断言"写成一门可读的"语言" ✓
given()
.contentType(ContentType.JSON)
.body(请求体)
.when()
.post("/api/orders") // 当:发起请求
.then()
.statusCode(201) // then:断言
.body("orderNo", startsWith("ORD-"));@SpringBootTest 随机端口配合 ★★;find { it.field == 'x' })★★★;HttpClient 与 RestClient 什么时候用;@SpringBootTest(webEnvironment = RANDOM_PORT) 吗?(T1-017)✓用裸 HttpClient 写断言:
// ✗ 要自己发请求、自己解析 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 的同一件事:
given().contentType(JSON).body(body)
.when().post("/api/orders")
.then().statusCode(201).body("orderNo", startsWith("ORD-")); // ✓ 一眼读懂差别不在行数,在"可读性即可维护性" ✓
<!-- 版本由 Spring Boot BOM 管理(S3-028)✓ -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>与随机端口配合 ★★:
@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 的理由是断言表达力 ✓
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"));实测(本项目运行结果):
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 21.85 s
-- in cn.kb.learn.web.OrderApiRestAssuredTest
BUILD SUCCESS三段式的"读法":
"给定这样一个请求,当发到这个端点,那么应该得到这样的响应。"
这就是 BDD(行为驱动)的自然语言结构 ✓ ——非开发人员也能读懂用例 ✓
.body() 的第一个参数是 JsonPath 表达式(类似 《JSON 与 XML》 提到的路径语法):
| 表达式 | 含义 |
|---|---|
orderNo | 根字段 |
details.size() | 数组长度 ★ |
details[0].field | 数组第一个元素的字段 |
details.find { it.field == 'quantity' }.message ★★★ | Groovy 查找:字段为 quantity 的那条错误的 message ✓ |
实测(《Spring MVC 请求映射与参数绑定》 的校验失败场景):
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 ✓
接口测试的经典形态:一条测试走完"创建 → 查询 → 删除 → 验证删除" ✓
@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"才证明删除生效 ✓✓
T2-001 的用例矩阵 → @MethodSource ✓
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 条用例矩阵 → 一个参数化测试" ✓ ——设计直接变现 ✓
什么时候不用 REST Assured:
| 场景 | 选择 |
|---|---|
| 接口测试断言 | REST Assured ✓ |
| 业务代码里调第三方 API | Spring 的 RestClient(Spring 6.1+)或 WebClient ✓ |
| JDK 原生、无依赖 | java.net.http.HttpClient ✓ |
// 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 ✗(它是测试框架)✓
| 匹配器 | 用途 |
|---|---|
equalTo(x) | 相等 |
startsWith / containsString | 字符串 ✓ |
hasSize(n) / hasSize(greaterThan(n)) | 集合大小 ★ |
hasKey / hasEntry | Map/字段 |
notNullValue() | 存在性(如 traceId)✓ |
matchesPattern | 正则 ★ |
given().when().get("/api/orders").then().statusCode(200);见 5.2 ~ 5.5(四个测试 = 本项目实测源码)✓
// ✗ 用整个响应体做字符串断言
assertEquals(201, response.getStatusCode());
assertTrue(response.getBody().asString().contains("ORD-"));
// ✗ "ORD-" 在任何字段里出现都算过——断言太弱 ✗修:JsonPath 精确断言字段 ✓
文件:labs/spring-boot-web/src/test/java/cn/kb/learn/web/OrderApiRestAssuredTest.java(4 个测试 ✓)
运行:
cd labs
.\mvnw.cmd -pl spring-boot-web test "-Dtest=OrderApiRestAssuredTest"预期输出:
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 的预演)✓ |
.log().ifValidationFails():断言失败时自动打印完整请求与响应 ★✓;extract().response():把整个响应对象取出来自由分析 ✓;curl 与测试:同一条请求先 curl 后测试,理解"两者是等价的" ✓。| # | 错误 | 说明 |
|---|---|---|
| 1 | JSON 路径写错(大小写、嵌套层级) | No value at JSON path ⚠️ ——先 r.json() 打印看结构 ✓ |
| 2 | 忘了随机端口 | 连接被拒(连到了 8080 上没有的服务)✗ |
| 3 | 测试间数据污染 | 用例互相依赖(T1-007)✗ ——每条测试自建数据 ✓ |
| 4 | 断言太弱(只查状态码) | 见 T2-001 的 5.2 ✗ |
| 5 | 生产代码引入 REST Assured ✗ | 它是测试框架 ✗ ——scope=test 且只在 test 用 ✓ |
@Min(1) 删掉 → 跑 REST Assured 测试 → 观察 422 那条变红 ★(测试在保护契约)✓;extract() 提取 traceId 并打印 ✓;.log().ifValidationFails() 加到 given,故意改错一个断言 ——观察自动打印的完整请求/响应 ★。验收方式:能说出第 2 步"哪条用例红了、它保护的是什么契约"。
扩展任务:把 T2-001 的 18 条用例矩阵全部实现(反向用例用参数化),统计总用例数并与矩阵核对 ✓。
基础题
.body("orderNo", ...) 断言的是 HTTP 头。(对 / 错)@BeforeEach 设置 RestAssured.port?代码阅读题
given().contentType(JSON).body("""
{"productId":"P-1","quantity":2,"address":"上海"}""")
.when().post("/api/orders")
.then().statusCode(201)
.body("details", hasSize(2));details 吗?)编程题
422/400 + details 的字段与消息;综合任务
api() 封装、请求构造器、断言辅助);提示 1(方向):第 4 题的关键是"201 的响应体是订单,不是错误结构"。
提示 2(关键点):第 7 题"并存还是二选一"——MockMvc 快(不起容器)适合大量小断言;REST Assured 真(走全链路)适合关键路径 ✓ 两者互补 ✓
提示 3(接近答案):第 7 题的组织——按资源分文件(OrderApiTest),按场景分方法(create/list/delete/lifecycle) ✓
基础题
.body() 断言的是 JSON 响应体(JsonPath)✓;HTTP 头用 .header() ✓extract().jsonPath() ✓RANDOM_PORT 每次运行端口都不同 ✓ ——硬编码端口会连不上 ✗代码阅读题
201 的响应体是 OrderResponse(没有 details 字段) ✗ ——这个断言永远失败 ✗✗(把 422 的结构错用在了 201 上) 正确:断言 orderNo / amountInCents 等 ✓.header("Location", containsString("/api/orders/")) ✓ 更强的写法:.header("Location", equalTo("/api/orders/" + orderNo)) ——先 extract 再精确断言 ✓编程题
HttpMessageNotReadableException → 400;MethodArgumentNotValidException → 422 ★综合任务
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 集成"是同一组关系 ✓ :::
find { } 做"查找式断言" ★★★;extract() 提取响应值做接口依赖 ★★;RestClient 与 REST Assured 的用途边界 ✓;《接口鉴权与测试数据管理》:受保护接口怎么测?数据怎么准备与清理。