RestAssured 完整实战——API 集成测试的最强 Java DSL 完整使用指南
RestAssured 完整实战——API 集成测试的最强 Java DSL 完整使用指南
适读人群:Java 后端开发者、API 测试工程师 | 阅读时长:约 18 分钟 | 核心价值:掌握 RestAssured 从基础到高级的完整用法,让 API 集成测试简洁优雅
我们团队做 API 测试经历了三个阶段。
第一个阶段:用 MockMvc,每个断言都是 .andExpect(jsonPath(...)) 的长链,复杂的 JSON 结构验证写起来像在咒骂代码。
第二个阶段:有人说不如用 Postman,然后集合文件交给 Newman 在 CI 里跑。结果 Postman 测试用例没有类型安全,重构接口字段名时,没有 IDE 提示,测试用例悄悄跑了好几个月都是假通过(因为字段不存在时 jsonpath 断言没有报错,只是 null 匹配了 null)。
第三个阶段:RestAssured。从第一次用就没想过再换别的。
RestAssured 是 Java 里做 REST API 测试最接近"自然语言"的 DSL,链式调用、BDD 风格、内置 JSON/XML 断言,搭配 Spring Boot 的 RANDOM_PORT 模式,写出来的 API 测试代码清晰得像在写文档。
今天这篇,把 RestAssured 从入门到高级全部写出来,配合真实业务场景。
一、依赖引入
<dependencies>
<!-- RestAssured 核心 -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
<!-- Jackson 支持(JSON 序列化) -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>json-path</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
<!-- Spring MockMvc 集成(如果要用 MockMvc 而不是真实服务器) -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>spring-mock-mvc</artifactId>
<version>5.4.0</version>
<scope>test</scope>
</dependency>
<!-- Hamcrest(断言) -->
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest</artifactId>
<scope>test</scope>
</dependency>
</dependencies>二、基础配置
方式一:真实 HTTP 服务器(推荐用于集成测试)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class ProductApiIntegrationTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
RestAssured.basePath = "/api";
// 全局配置日志(测试失败时打印请求/响应详情)
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
}
}方式二:MockMvc 模式(不需要真实服务器)
@SpringBootTest
@AutoConfigureMockMvc
class ProductApiMockMvcTest {
@Autowired
private MockMvc mockMvc;
@BeforeEach
void setUp() {
RestAssuredMockMvc.mockMvc(mockMvc);
}
@Test
void 获取商品列表() {
RestAssuredMockMvc
.given()
.accept(ContentType.JSON)
.when()
.get("/api/products")
.then()
.statusCode(200);
}
}三、完整 CRUD API 测试
以商品管理 API 为例,展示 RestAssured 的完整用法:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProductCrudApiTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@LocalServerPort
private int port;
@Autowired
private ProductRepository productRepository;
private static Long createdProductId;
@BeforeEach
void setUp() {
RestAssured.port = port;
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
}
@AfterEach
void tearDown() {
productRepository.deleteAll();
}
@Test
@Order(1)
void 创建商品_请求合法_返回201和商品ID() {
String requestBody = """
{
"name": "iPhone 15 Pro",
"description": "苹果旗舰手机",
"price": 8999.00,
"stock": 100,
"categoryId": 1
}
""";
createdProductId =
given()
.contentType(ContentType.JSON)
.body(requestBody)
.when()
.post("/api/products")
.then()
.statusCode(201)
.header("Location", matchesPattern(".*/api/products/\\d+"))
.body("id", notNullValue())
.body("name", equalTo("iPhone 15 Pro"))
.body("price", equalTo(8999.00f))
.body("status", equalTo("ACTIVE"))
.extract()
.jsonPath()
.getLong("id");
assertThat(createdProductId).isNotNull();
}
@Test
@Order(2)
void 获取商品详情_存在_返回200和完整数据() {
// 先创建商品
Long productId = createTestProduct("MacBook Pro", 12999.00);
given()
.pathParam("id", productId)
.when()
.get("/api/products/{id}")
.then()
.statusCode(200)
.body("id", equalTo(productId.intValue()))
.body("name", equalTo("MacBook Pro"))
.body("price", equalTo(12999.00f))
.body("createdAt", notNullValue())
.body("updatedAt", notNullValue());
}
@Test
void 获取商品详情_不存在_返回404() {
given()
.pathParam("id", 999999L)
.when()
.get("/api/products/{id}")
.then()
.statusCode(404)
.body("error", equalTo("Product not found"))
.body("code", equalTo("PRODUCT_NOT_FOUND"));
}
@Test
void 获取商品列表_分页查询_返回正确分页数据() {
// 准备测试数据
for (int i = 1; i <= 15; i++) {
createTestProduct("商品" + i, i * 100.0);
}
given()
.queryParam("page", 0)
.queryParam("size", 5)
.queryParam("sort", "price,asc")
.when()
.get("/api/products")
.then()
.statusCode(200)
.body("content", hasSize(5))
.body("totalElements", equalTo(15))
.body("totalPages", equalTo(3))
.body("first", equalTo(true))
.body("content[0].price", equalTo(100.0f))
.body("content[4].price", equalTo(500.0f));
}
@Test
void 更新商品_修改价格_数据库更新生效() {
Long productId = createTestProduct("旧商品名", 100.0);
String updateBody = """
{
"name": "新商品名",
"price": 199.00
}
""";
given()
.contentType(ContentType.JSON)
.pathParam("id", productId)
.body(updateBody)
.when()
.put("/api/products/{id}")
.then()
.statusCode(200)
.body("name", equalTo("新商品名"))
.body("price", equalTo(199.00f));
// 验证数据库
Product updated = productRepository.findById(productId).orElseThrow();
assertThat(updated.getName()).isEqualTo("新商品名");
}
@Test
void 删除商品_存在_返回204数据库无记录() {
Long productId = createTestProduct("待删除商品", 100.0);
given()
.pathParam("id", productId)
.when()
.delete("/api/products/{id}")
.then()
.statusCode(204);
assertThat(productRepository.findById(productId)).isEmpty();
}
@Test
void 搜索商品_关键词匹配_返回相关结果() {
createTestProduct("Java 编程指南", 89.0);
createTestProduct("Python 数据分析", 79.0);
createTestProduct("Java 并发编程", 99.0);
given()
.queryParam("keyword", "Java")
.when()
.get("/api/products/search")
.then()
.statusCode(200)
.body("content", hasSize(2))
.body("content.name", everyItem(containsString("Java")));
}
// 辅助方法:创建测试商品并返回 ID
private Long createTestProduct(String name, Double price) {
String body = String.format("""
{"name": "%s", "price": %.2f, "stock": 10, "categoryId": 1}
""", name, price);
return given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post("/api/products")
.then()
.statusCode(201)
.extract()
.jsonPath()
.getLong("id");
}
}四、三个踩坑实录
坑 1:浮点数精度问题导致断言失败
现象: 数据库存的是 8999.00,RestAssured 断言 .body("price", equalTo(8999.00)) 失败,报 expected 8999.00 but was 8999.0。
原因: JSON 数字默认被解析为 float 类型,而 Java 的 8999.00 是 double,精度对不上。
解法:
// 方式一:用 float 类型的字面量
.body("price", equalTo(8999.00f)) // 注意 f 后缀
// 方式二:用字符串比较
.body("price", equalTo("8999.00"))
// 方式三:用 closeTo 断言(允许浮点误差)
.body("price", closeTo(8999.00, 0.001))
// 方式四:配置 RestAssured 使用 BigDecimal(最准确)
RestAssured.config = RestAssured.config()
.jsonConfig(jsonConfig().numberReturnType(JsonPathConfig.NumberReturnType.BIG_DECIMAL));坑 2:并发测试端口冲突
现象: 并行跑多个测试类,偶发 Address already in use。
原因: 每个 @SpringBootTest(webEnvironment = RANDOM_PORT) 都会启动一个端口,但 RestAssured.port = port 是一个全局静态变量,并发时被互相覆盖。
解法: 不要用静态的 RestAssured.port,而是在每个请求里指定端口:
// 不要这样(全局状态,并发不安全)
RestAssured.port = port;
given().get("/api/products");
// 要这样(每次请求指定端口)
given()
.port(port)
.get("/api/products");
// 或者用 RequestSpecification
RequestSpecification spec = new RequestSpecBuilder()
.setPort(port)
.setBasePath("/api")
.build();
given(spec).get("/products");坑 3:响应体中文乱码
现象: 接口返回的中文字段,在 RestAssured 断言时出现乱码。
原因: 响应的 Content-Type 没有指定 charset=UTF-8,RestAssured 使用默认字符集(ISO-8859-1)解码。
解法:
// 方式一:在 Controller 里明确指定 produces 字符集
@GetMapping(value = "/api/products", produces = "application/json;charset=UTF-8")
// 方式二:Spring Boot 配置(推荐)
// application.yml
// server.servlet.encoding.charset=UTF-8
// server.servlet.encoding.force=true
// 方式三:RestAssured 配置
RestAssured.config = RestAssured.config()
.encoderConfig(encoderConfig()
.encodeContentTypeAs("application/json", ContentType.JSON)
.defaultContentCharset("UTF-8"));五、高级用法:RequestSpecification 复用
对于需要认证的接口,把认证配置抽成 RequestSpecification 复用:
public abstract class AuthenticatedApiTest {
protected RequestSpecification authSpec;
protected RequestSpecification adminSpec;
@BeforeEach
void setupSpecs(@LocalServerPort int port) {
// 普通用户 token
String userToken = obtainToken("user@example.com", "password");
authSpec = new RequestSpecBuilder()
.setPort(port)
.setBasePath("/api")
.addHeader("Authorization", "Bearer " + userToken)
.addHeader("Content-Type", "application/json")
.build();
// 管理员 token
String adminToken = obtainToken("admin@example.com", "admin123");
adminSpec = new RequestSpecBuilder()
.setPort(port)
.setBasePath("/api")
.addHeader("Authorization", "Bearer " + adminToken)
.addHeader("Content-Type", "application/json")
.build();
}
private String obtainToken(String email, String password) {
return given()
.port(RestAssured.port)
.contentType(ContentType.JSON)
.body("""
{"email": "%s", "password": "%s"}
""".formatted(email, password))
.when()
.post("/api/auth/login")
.then()
.statusCode(200)
.extract()
.jsonPath()
.getString("token");
}
}
// 使用
class ProductAdminApiTest extends AuthenticatedApiTest {
@Test
void 管理员上架商品_普通用户无权限() {
// 管理员能操作
given(adminSpec)
.pathParam("id", 1)
.when()
.post("/products/{id}/publish")
.then()
.statusCode(200);
// 普通用户不能操作
given(authSpec)
.pathParam("id", 1)
.when()
.post("/products/{id}/publish")
.then()
.statusCode(403);
}
}六、提取响应数据用于后续测试
@Test
void 创建订单后立即支付_完整流程() {
// 第一步:创建订单
String orderId = given(authSpec)
.body("""
{"productId": 100, "quantity": 1}
""")
.when()
.post("/orders")
.then()
.statusCode(201)
.extract()
.jsonPath()
.getString("orderId");
// 第二步:发起支付(用上一步的订单 ID)
given(authSpec)
.body("""
{"orderId": "%s", "paymentMethod": "ALIPAY"}
""".formatted(orderId))
.when()
.post("/payments")
.then()
.statusCode(200)
.body("paymentUrl", startsWith("https://"));
}RestAssured 的 DSL 让 API 测试代码变得可读、可维护,这才是它真正的价值所在。
