RestAssured 进阶实战——认证、文件上传、JSON Schema 验证、测试链
RestAssured 进阶实战——认证、文件上传、JSON Schema 验证、测试链
适读人群:有 RestAssured 基础的 Java 开发者 | 阅读时长:约 16 分钟 | 核心价值:掌握 RestAssured 的高级特性,覆盖生产级 API 测试的复杂场景
上篇写完 RestAssured 的基础用法,好几个读者私信问:认证怎么做?文件上传怎么测?接口返回的字段很多,一个个断言太繁琐,有没有更好的方式?
这些问题,在我们做的第一个真实项目集成测试的时候,也都遇到了。
当时最让我头疼的,是一个内部管理系统的文件上传接口。业务逻辑很复杂:用户上传 Excel,系统解析后写入数据库,同时触发一个异步的数据校验任务,校验完成后发邮件通知。
这个场景,Mock 完全测不到,因为 Excel 解析、数据库写入、异步任务这三个步骤的串联才是业务核心。而且每次手动测要上传文件、等待异步完成、查邮件,流程冗长,几乎没人愿意手动测第二次。
用 RestAssured 写了集成测试之后,这个场景 10 秒内自动跑完,从此进了 CI。
今天这篇,把 RestAssured 的高级用法全部写出来。
一、认证:OAuth2 / JWT 完整方案
1.1 Bearer Token 认证
// 方式一:每个请求手动加 header
given()
.header("Authorization", "Bearer " + jwtToken)
.get("/api/profile");
// 方式二:用 oauth2 方法(语义更清晰)
given()
.auth().oauth2(jwtToken)
.get("/api/profile");
// 方式三:全局配置(最推荐)
RequestSpecification authSpec = new RequestSpecBuilder()
.setBaseUri("http://localhost:" + port)
.setBasePath("/api")
.addHeader("Authorization", "Bearer " + jwtToken)
.setContentType(ContentType.JSON)
.build();
// 复用
given(authSpec).get("/profile");
given(authSpec).post("/orders");1.2 登录-获取 Token-使用的完整流程
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class AuthenticatedApiTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@LocalServerPort
private int port;
private RequestSpecification userSpec;
private RequestSpecification adminSpec;
@BeforeEach
void setUp() {
userSpec = buildSpec(loginAndGetToken("user@test.com", "user123"));
adminSpec = buildSpec(loginAndGetToken("admin@test.com", "admin123"));
}
private String loginAndGetToken(String email, String password) {
return given()
.port(port)
.contentType(ContentType.JSON)
.body("""
{"email": "%s", "password": "%s"}
""".formatted(email, password))
.when()
.post("/api/auth/login")
.then()
.statusCode(200)
.body("accessToken", notNullValue())
.extract()
.jsonPath()
.getString("accessToken");
}
private RequestSpecification buildSpec(String token) {
return new RequestSpecBuilder()
.setPort(port)
.setBasePath("/api")
.setContentType(ContentType.JSON)
.addHeader("Authorization", "Bearer " + token)
.build();
}
@Test
void 普通用户_访问管理员接口_返回403() {
given(userSpec)
.when()
.get("/admin/users")
.then()
.statusCode(403);
}
@Test
void 管理员_访问管理员接口_正常返回() {
given(adminSpec)
.when()
.get("/admin/users")
.then()
.statusCode(200);
}
}二、文件上传测试
@Test
void 上传商品图片_合法图片_返回图片URL() {
// 准备测试文件(从 classpath 读取)
ClassPathResource imageResource = new ClassPathResource("test-images/product.jpg");
String imageUrl =
given(authSpec)
.contentType("multipart/form-data")
.multiPart("file", imageResource.getFile(), "image/jpeg")
.multiPart("productId", "1001")
.multiPart("imageType", "MAIN")
.when()
.post("/api/products/images/upload")
.then()
.statusCode(200)
.body("url", startsWith("https://"))
.body("url", endsWith(".jpg"))
.body("size", greaterThan(0))
.extract()
.jsonPath()
.getString("url");
assertThat(imageUrl).isNotBlank();
}
@Test
void 上传Excel_解析导入_数据写入数据库() throws Exception {
// 用 Apache POI 动态创建测试 Excel
Workbook workbook = createTestExcel(50); // 50 条商品数据
File tempFile = File.createTempFile("test-import", ".xlsx");
workbook.write(new FileOutputStream(tempFile));
given(adminSpec)
.contentType("multipart/form-data")
.multiPart("file", tempFile,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.when()
.post("/api/products/import")
.then()
.statusCode(202) // 异步处理,返回 202 Accepted
.body("taskId", notNullValue())
.body("status", equalTo("PROCESSING"));
// 等待异步任务完成
await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> {
given(adminSpec)
.queryParam("taskId", "xxx")
.when()
.get("/api/tasks/import-status")
.then()
.body("status", equalTo("COMPLETED"))
.body("successCount", equalTo(50))
.body("failureCount", equalTo(0));
});
// 验证数据库
assertThat(productRepository.count()).isEqualTo(50);
}
@Test
void 上传文件_超过大小限制_返回413() {
// 创建一个 11MB 的文件(超过 10MB 限制)
byte[] largeFile = new byte[11 * 1024 * 1024];
Arrays.fill(largeFile, (byte) 0);
given(authSpec)
.contentType("multipart/form-data")
.multiPart("file", "large.jpg", largeFile, "image/jpeg")
.when()
.post("/api/products/images/upload")
.then()
.statusCode(413);
}三、JSON Schema 验证
对于字段多的响应,逐个断言太繁琐,JSON Schema 验证是更好的方式:
定义 Schema(src/test/resources/schemas/product-response.json):
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "name", "price", "status", "createdAt"],
"properties": {
"id": {
"type": "integer",
"minimum": 1
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"price": {
"type": "number",
"minimum": 0,
"exclusiveMinimum": true
},
"status": {
"type": "string",
"enum": ["ACTIVE", "INACTIVE", "OUT_OF_STOCK"]
},
"stock": {
"type": "integer",
"minimum": 0
},
"imageUrls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
}
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
}
},
"additionalProperties": false
}使用 Schema 验证:
@Test
void 获取商品详情_响应结构_符合JSON_Schema() {
Long productId = createTestProduct("测试商品", 199.0);
given()
.pathParam("id", productId)
.when()
.get("/api/products/{id}")
.then()
.statusCode(200)
// Schema 验证:一行代码验证整个响应结构
.body(matchesJsonSchemaInClasspath("schemas/product-response.json"));
}
@Test
void 获取商品列表_分页响应结构_符合Schema() {
given()
.queryParam("page", 0)
.queryParam("size", 10)
.when()
.get("/api/products")
.then()
.statusCode(200)
.body(matchesJsonSchemaInClasspath("schemas/paged-product-response.json"));
}四、测试链:多步骤业务流程
@Test
void 完整购买流程_从浏览到支付() {
// Step 1: 查看商品
ProductResponse product = given(authSpec)
.pathParam("id", 1001)
.when()
.get("/api/products/{id}")
.then()
.statusCode(200)
.body("stock", greaterThan(0))
.extract()
.as(ProductResponse.class);
// Step 2: 加入购物车
String cartItemId = given(authSpec)
.body("""
{"productId": %d, "quantity": 2}
""".formatted(product.getId()))
.when()
.post("/api/cart/items")
.then()
.statusCode(201)
.extract()
.jsonPath()
.getString("itemId");
// Step 3: 确认购物车
given(authSpec)
.when()
.get("/api/cart")
.then()
.statusCode(200)
.body("items", hasSize(1))
.body("items[0].productName", equalTo(product.getName()))
.body("totalAmount", equalTo(product.getPrice().multiply(new BigDecimal("2")).floatValue()));
// Step 4: 创建订单
String orderId = given(authSpec)
.body("""
{
"cartItems": ["%s"],
"addressId": 1,
"paymentMethod": "ALIPAY"
}
""".formatted(cartItemId))
.when()
.post("/api/orders")
.then()
.statusCode(201)
.body("status", equalTo("PENDING_PAYMENT"))
.extract()
.jsonPath()
.getString("orderId");
// Step 5: 支付
given(authSpec)
.body("""
{"orderId": "%s"}
""".formatted(orderId))
.when()
.post("/api/payments/alipay/create")
.then()
.statusCode(200)
.body("paymentUrl", startsWith("https://"));
}五、三个踩坑实录
坑 1:multiPart 上传后文件名乱码
现象: 上传文件后,服务端收到的文件名是乱码(????.jpg 之类)。
原因: multiPart 方法没有指定字符集,默认用 ISO-8859-1 编码文件名。
解法:
given()
.multiPart(new MultiPartSpecBuilder(fileBytes)
.fileName("测试图片.jpg")
.controlName("file")
.mimeType("image/jpeg")
.charset("UTF-8") // 指定 UTF-8
.build())
.post("/upload");坑 2:JSON Schema 验证对 null 字段过于严格
现象: 正常响应里某些可选字段为 null,Schema 验证报错 type is not string, was null。
原因: JSON Schema 的 type: "string" 默认不允许 null。
解法: 在 Schema 里允许 null:
{
"description": {
"type": ["string", "null"]
}
}坑 3:并发测试中 RequestSpecification 状态共享
现象: 并行跑测试类,某些请求带了错误的认证 token。
原因: RequestSpecification 对象如果在 @BeforeAll 里创建(静态),会被所有测试方法共享,并发修改时出现状态污染。
解法: 在 @BeforeEach 里创建(实例级别),不共享状态:
@BeforeEach // 不要用 @BeforeAll
void setUp() {
// 每个测试方法前重新创建
authSpec = buildSpec(loginAndGetToken(email, password));
}六、响应日志与调试
测试失败时的调试利器:
// 只在失败时打印日志(推荐用于 CI)
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
// 始终打印(本地调试用)
given()
.log().all() // 打印请求
.when()
.get("/api/products")
.then()
.log().all() // 打印响应
.statusCode(200);
// 只打印特定部分
given()
.log().headers()
.log().body()
.when()
.get("/api/products")
.then()
.log().status()
.log().body();RestAssured 的强大之处在于它把 API 测试的复杂性封装得非常好。认证、文件上传、Schema 验证、请求链——这些在真实项目里最常见的场景,用 RestAssured 处理起来都不超过 30 行代码。
