Appearance
Spring Boot MockMvc AssertJ 集成:让测试断言更优雅 🎯
概述
在 Spring Boot 的测试世界中,MockMvc 是我们进行 Web 层测试的得力助手。而当它与 AssertJ 集成后,就像给测试代码装上了"智能大脑",让我们的断言变得更加流畅、直观和强大。
NOTE
AssertJ 是一个流畅的断言库,它提供了丰富的断言方法,让测试代码更加易读和易维护。当它与 MockMvc 结合时,我们可以用更自然的方式来验证 HTTP 响应。
为什么需要 AssertJ 集成? 🤔
传统方式的痛点
在没有 AssertJ 集成之前,我们通常这样写测试:
kotlin
@Test
fun testGetHotel() {
mockMvc.perform(get("/hotels/42"))
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.name").value("Grand Hotel"))
.andExpect(jsonPath("$.id").value(42))
}
kotlin
@Test
fun testGetHotel() {
assertThat(mockMvc.get().uri("/hotels/{id}", 42))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.bodyJson().isLenientlyEqualTo("sample/hotel-42.json")
}
TIP
AssertJ 方式的优势:
- 更流畅的链式调用
- 更直观的方法命名
- 更强大的 JSON 处理能力
- 更好的错误信息提示
基础断言操作 📝
状态码和内容类型验证
kotlin
@RestController
class HotelController {
@GetMapping("/hotels/{id}")
fun getHotel(@PathVariable id: Long): Hotel {
return hotelService.findById(id)
}
}
@Test
fun `should return hotel with correct status and content type`() {
assertThat(mockMvc.get().uri("/hotels/{id}", 42))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
}
处理请求失败的情况
kotlin
@RestController
class HotelController {
@GetMapping("/hotels/{id}")
fun getHotel(@PathVariable id: Long): Hotel {
if (id <= 0) {
throw IllegalArgumentException("Identifier should be positive")
}
return hotelService.findById(id)
}
}
@Test
fun `should handle invalid hotel id gracefully`() {
assertThat(mockMvc.get().uri("/hotels/{id}", -1))
.hasFailed()
.hasStatus(HttpStatus.BAD_REQUEST)
.failure().hasMessageContaining("Identifier should be positive")
}
IMPORTANT
当请求失败时,AssertJ 不会抛出异常,而是让你可以优雅地断言失败的结果。这种设计让测试代码更加健壮。
JSON 响应处理的艺术 🎨
基础 JSON 断言
kotlin
data class Hotel(
val id: Long,
val name: String,
val address: String,
val rating: Double
)
@RestController
class HotelController {
@GetMapping("/hotels/{id}")
fun getHotel(@PathVariable id: Long): Hotel {
return Hotel(
id = id,
name = "Grand Hotel",
address = "123 Main St",
rating = 4.5
)
}
}
@Test
fun `should return hotel as JSON`() {
assertThat(mockMvc.get().uri("/hotels/{id}", 42))
.hasStatusOk()
.bodyJson().isLenientlyEqualTo("sample/hotel-42.json")
}
使用 JSONPath 进行精确断言
kotlin
data class Family(
val name: String,
val members: List<Member>
)
data class Member(
val name: String,
val age: Int,
val role: String
)
@RestController
class FamilyController {
@GetMapping("/family")
fun getFamily(): Family {
return Family(
name = "Simpson",
members = listOf(
Member("Homer", 39, "Father"),
Member("Marge", 36, "Mother"),
Member("Bart", 10, "Son"),
Member("Lisa", 8, "Daughter"),
Member("Maggie", 1, "Baby")
)
)
}
}
@Test
fun `should extract specific member from family JSON`() {
assertThat(mockMvc.get().uri("/family")).bodyJson()
.extractingPath("$.members[0]")
.asMap()
.contains(entry("name", "Homer"))
}
类型转换和复杂断言
kotlin
@Test
fun `should convert JSON to domain object for assertion`() {
assertThat(mockMvc.get().uri("/family")).bodyJson()
.extractingPath("$.members[0]")
.convertTo(Member::class.java)
.satisfies { member ->
assertThat(member.name).isEqualTo("Homer")
assertThat(member.age).isGreaterThan(30)
}
}
处理集合类型的高级断言
kotlin
@Test
fun `should handle list of members with type safety`() {
assertThat(mockMvc.get().uri("/family")).bodyJson()
.extractingPath("$.members")
.convertTo(InstanceOfAssertFactories.list(Member::class.java))
.hasSize(5)
.element(0).satisfies { member ->
assertThat(member.name).isEqualTo("Homer")
}
}
实战场景演示 🚀
完整的 RESTful API 测试
完整的测试示例代码
kotlin
@RestController
class BookController(private val bookService: BookService) {
@GetMapping("/books")
fun getAllBooks(): List<Book> = bookService.findAll()
@GetMapping("/books/{id}")
fun getBook(@PathVariable id: Long): Book = bookService.findById(id)
@PostMapping("/books")
fun createBook(@RequestBody @Valid book: Book): Book = bookService.save(book)
@PutMapping("/books/{id}")
fun updateBook(@PathVariable id: Long, @RequestBody @Valid book: Book): Book {
return bookService.update(id, book)
}
@DeleteMapping("/books/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteBook(@PathVariable id: Long) = bookService.delete(id)
}
@WebMvcTest(BookController::class)
class BookControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@MockBean
private lateinit var bookService: BookService
@Test
fun `should get all books successfully`() {
// Given
val books = listOf(
Book(1L, "Spring Boot in Action", "Craig Walls", 29.99),
Book(2L, "Kotlin in Action", "Dmitry Jemerov", 35.99)
)
`when`(bookService.findAll()).thenReturn(books)
// When & Then
assertThat(mockMvc.get().uri("/books"))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.bodyJson()
.extractingPath("$")
.convertTo(InstanceOfAssertFactories.list(Book::class.java))
.hasSize(2)
.element(0).satisfies { book ->
assertThat(book.title).isEqualTo("Spring Boot in Action")
assertThat(book.price).isEqualTo(29.99)
}
}
@Test
fun `should handle book not found gracefully`() {
// Given
`when`(bookService.findById(999L))
.thenThrow(BookNotFoundException("Book not found with id: 999"))
// When & Then
assertThat(mockMvc.get().uri("/books/{id}", 999L))
.hasFailed()
.hasStatus(HttpStatus.NOT_FOUND)
.failure().hasMessageContaining("Book not found with id: 999")
}
@Test
fun `should create book successfully`() {
// Given
val newBook = Book(null, "New Book", "New Author", 19.99)
val savedBook = Book(3L, "New Book", "New Author", 19.99)
`when`(bookService.save(any())).thenReturn(savedBook)
// When & Then
assertThat(mockMvc.post().uri("/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(newBook)))
.hasStatus(HttpStatus.OK)
.bodyJson()
.extractingPath("$.id")
.asNumber()
.isEqualTo(3L)
}
}
错误处理和异常测试
kotlin
@ControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND)
fun handleBookNotFound(ex: BookNotFoundException): ErrorResponse {
return ErrorResponse(
code = "BOOK_NOT_FOUND",
message = ex.message ?: "Book not found"
)
}
@ExceptionHandler(ValidationException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleValidation(ex: ValidationException): ErrorResponse {
return ErrorResponse(
code = "VALIDATION_ERROR",
message = ex.message ?: "Validation failed"
)
}
}
@Test
fun `should return proper error response for validation failure`() {
val invalidBook = Book(null, "", "", -1.0)
assertThat(mockMvc.post().uri("/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidBook)))
.hasFailed()
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson()
.extractingPath("$.code")
.asString()
.isEqualTo("VALIDATION_ERROR")
}
最佳实践和技巧 💡
1. 使用资源文件进行 JSON 比较
kotlin
// 在 src/test/resources/sample/ 目录下创建 hotel-42.json
@Test
fun `should match expected JSON structure`() {
assertThat(mockMvc.get().uri("/hotels/{id}", 42))
.hasStatusOk()
.bodyJson().isLenientlyEqualTo("sample/hotel-42.json")
}
2. 自定义 JSON 比较器
kotlin
@Configuration
class TestConfiguration {
@Bean
fun customJsonComparator(): JsonComparator {
return JsonComparator { expected, actual ->
// 自定义比较逻辑
// 例如:忽略时间戳字段的比较
true
}
}
}
3. 组合断言提高可读性
kotlin
@Test
fun `should validate complete API response`() {
assertThat(mockMvc.get().uri("/api/users/{id}", 1L))
.hasStatusOk()
.hasHeader("Content-Type", "application/json")
.bodyJson().satisfies { json ->
json.extractingPath("$.id").asNumber().isEqualTo(1L)
json.extractingPath("$.name").asString().isNotBlank()
json.extractingPath("$.email").asString().contains("@")
json.extractingPath("$.createdAt").asString().isNotNull()
}
}
时序图:AssertJ 测试流程
总结 🎉
Spring Boot MockMvc 与 AssertJ 的集成为我们提供了一个强大而优雅的测试工具集。它不仅让测试代码更加易读和易维护,还提供了丰富的断言方法来处理各种复杂的测试场景。
TIP
关键优势总结:
- 流畅的 API:链式调用让测试代码更自然
- 强大的 JSON 支持:JSONPath、类型转换、文件比较一应俱全
- 优雅的错误处理:失败断言不抛异常,让测试更健壮
- 丰富的断言方法:覆盖 HTTP 响应的各个方面
通过掌握这些技巧,你可以编写出更加专业、可维护的测试代码,让你的 Spring Boot 应用更加健壮可靠! ✅