Skip to content

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 应用更加健壮可靠! ✅