注目イベント!
春の新人向け連載2025開催中!
今年も春の新人向け連載が始動しました!!
現場で役立つ考え方やTipsを丁寧に解説、今日から学びのペースを整えよう。
詳細はこちらから!
event banner

Spring 小故事 - 使用 AssertJ 改写 MockMvc 测试

日本語|English|中国语
| 9 min read
Author: toshio-ogiwara toshio-ogiwaraの画像
Information

为了覆盖更广泛的受众,这篇文章已从日语翻译而来。
您可以在这里找到原始版本。

在针对 Spring 控制器的 MockMvc 测试中,过去我们使用 Hamcrest 进行断言,但从 Spring Framework 6.2(对应的 Spring Boot 3.4)开始,也可以使用 AssertJ
AssertJ 的优点之一是可以以流式方式编写 MockMvc 测试,但对于作者来说,更让人高兴的是,它使得以 given-when-then 风格编写测试变得更加容易。

所以,这次我想通过“解释少、代码多”的方式,介绍将 Hamcrest 测试改为 AssertJ 会是什么样子,以及它们在用法和风格上的差异。

Information

本文已在 Spring Boot 3.4.5 上验证可正常运行。此外,文章中讲解的所有代码都已上传至 GitHub 的 此处

获取 Mock 对象

#
  • Hamcrest 情况下
@WebMvcTest(BookController.class)
class HamcrestBookControllerTest {
    @Autowired
    private MockMvc mockMvc;
    ...

  • AssertJ 情况下
@WebMvcTest(BookController.class)
public class AssertjBookControllerTest {
    @Autowired
    private MockMvcTester mockMvc;
    ...

所使用的 Mock 对象,在 Hamcrest 中是 MockMvc 类,而在 AssertJ 中是从 Spring Framework 6.2 引入的 MockMvcTester 类。

获取 Mock 对象还有多种方式

这里介绍的是最简单的方法,但获取 Mock 对象的方法还有多种。如果想了解详细内容,请参阅 MockMvc :: Spring Framework - 参考文档

发送 GET 请求并验证响应

#

了解了获取 Mock 对象的方法后,让我们立即来看测试实现。首先以常见的以下场景来比较二者的区别。

  • 发送 GET 请求
  • HTTP 状态为 OK
  • 验证响应体中的 JSON

在 Hamcrest 情况下如下:

@Test
// 当
mockMvc.perform(get("/books"))
        // 然后
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.length()").value(3))
        .andExpect(jsonPath("$[0].id").value(1))
        .andExpect(jsonPath("$[0].title").value("燃えよ剣"))
        .andExpect(jsonPath("$[0].author").value("司馬遼太郎"))
        .andExpect(jsonPath("$[0].published").value("1972.06.01")); // 省略第2项及之后的验证

而 AssertJ 如下所示:

// 当
MvcTestResult result = mockMvc
        .get()
        .uri("/books")
        .exchange();
// 然后
assertThat(result)
        // 验证 HTTP 状态
        .hasStatusOk()
        // 将响应体作为 JSON 处理
        .bodyJson()
        // 检查指定的 JSON 路径是否满足条件
        .hasPathSatisfying("$.length()", p -> p.assertThat().isEqualTo(3))
        // 聚焦到第1项路径
        .extractingPath("$[0]")
        // 验证当前聚焦路径下(='$[0].id')的值
        .hasFieldOrPropertyWithValue("id", 1)
        // 其余类似
        .hasFieldOrPropertyWithValue("title", "燃えよ剣")
        .hasFieldOrPropertyWithValue("author", "司馬遼太郎")
        .hasFieldOrPropertyWithValue("published", "1972.06.01"); // 省略第2项及之后的验证

AssertJ 提供的各个 API 通过代码注释就能理解,因此这里略过,但对于 Hamcrest 来说,需要在外层使用大量的 andExpect 包裹,在编写代码时需要频繁地来回移动光标。而 AssertJ 如上所示,可以从头到尾以流式的方式编写想要执行和验证的内容,编写代码时非常顺畅。

需要路径参数或请求参数时

如果需要参数,在 AssertJ 中如下所示。它类似于 RestClient 的调用 API,我个人也很喜欢这种方式。

  • 添加路径参数的例子
MvcTestResult result = mockMvc
        .get()
        .uri("/books/{id}", id)
        .exchange();
  • 添加请求参数的例子
MvcTestResult result = mockMvc
        .get()
        .uri("/books/search")
        .param("title", title)
        .param("author", author)
        .exchange();

发送 POST 请求

#

这次来看 POST 请求。POST 请求需要设置请求体,下面来看看在两者中会如何实现。

在 Hamcrest 情况下如下:

// 给定
Book book = new Book(4, "雪国", "川端康成", "1937.06.12");
String body = mapper.writeValueAsString(book);
// 当
mockMvc.perform(post("/books")
        .contentType(MediaType.APPLICATION_JSON)
        .content(body))
        // 然后
        .andExpect(status().isOk());

而 AssertJ 如下所示:

// 给定
Book book = new Book(4, "雪国", "川端康成", "1937.06.12");
String body = mapper.writeValueAsString(book);
// 当
MvcTestResult result = mockMvc
        .post()
        .uri("/books")
        .contentType(MediaType.APPLICATION_JSON)
        .content(body)
        .exchange();
// 然后
assertThat(result).hasStatusOk();

几乎没有变化。

验证 HTTP 状态和 HTTP 头

#

在看完基本的验证模式后,接下来看看如何验证 HTTP 状态和 HTTP 头。下面以实现以下场景为例来对比二者的写法:

  • HTTP 状态为 BAD_REQUEST
  • 在 EXCEPTION 头中设置了发生的异常类名
  • 响应体中包含一部分错误信息

在 Hamcrest 情况下如下:

// 给定
Book book = new Book(5, "ノルウェイの森", "村上春樹", "1987.09.04");
String body = mapper.writeValueAsString(book);
// 当
mockMvc.perform(post("/books")
        .contentType(MediaType.APPLICATION_JSON)
        .content(body))
        // 然后
        .andExpect(status().isBadRequest())
        .andExpect(header().string("EXCEPTION", DuplicateKeyException.class.getSimpleName()))
        .andExpect(content().string(allOf(
                containsString("既に登録されています"),
                containsString("title"),
                containsString("ノルウェイの森") //
        )));

而 AssertJ 如下所示:

// 给定
Book book = new Book(5, "ノルウェイの森", "村上春樹", "1987.09.04");
String body = mapper.writeValueAsString(book);
// 当
MvcTestResult result = mockMvc
        .post()
        .uri("/books")
        .contentType(MediaType.APPLICATION_JSON)
        .content(body)
        .exchange();
// 然后
assertThat(result)
        .hasStatus(HttpStatus.BAD_REQUEST)
        // 是否存在对应的头?
        .hasHeader("EXCEPTION", DuplicateKeyException.class.getSimpleName())
        // 将响应体作为 JSON 处理
        .bodyText()
        // 验证响应文本中包含的字符串
        .contains("既に登録されています", "title", "ノルウェイの森");

这明显更简洁了。

有条件的 HTTP 头验证

#

以上是对 HTTP 头是否存在的简单验证,但有时需要对头的值添加条件。最后以稍复杂的以下场景为例,看看二者的写法:

  • 登录成功时,响应头中 Authorization 头设置了 Bearer Token
  • 登录失败时,响应头中未设置 Authorization

在 Hamcrest 情况下如下:

@Test
void testLoginSuccess() throws Exception {
    // 给定
    String loginId = "member";
    String password = "password1";
    // 当
    mockMvc.perform(get("/login")
            .param("loginId", loginId)
            .param("password", password))
            // 然后
            .andExpect(status().isOk())
            .andExpect(header().string(
                    AUTHORIZATION,
                    // 以 'Bearer ' 开头,且之后至少有 1 个字符
                    matchesPattern("^Bearer .+$")))
            .andExpect(content().string(equalTo("true")));
}

@Test
void testLoginFail() throws Exception {
    // 给定
    String loginId = "NG_id";
    String password = "NG_password";
    // 当
    mockMvc.perform(get("/login")
            .param("loginId", loginId)
            .param("password", password))
            // 然后
            .andExpect(header().doesNotExist(AUTHORIZATION))
            .andExpect(status().isUnauthorized());
}

而 AssertJ 如下所示:

@Test
void testLoginSuccess() throws Exception {
    // 给定
    String loginId = "member";
    String password = "password1";
    // 当
    MvcTestResult result = mockMvc
            .get()
            .uri("/login")
            .param("loginId", loginId)
            .param("password", password)
            .exchange();
    // 然后
    assertThat(result)
            .hasStatusOk();
    assertThat(result)
            // 切换到专门用于头验证的 Assert
            .headers()
            // 验证是否存在满足条件的头
            .hasEntrySatisfying(AUTHORIZATION, v -> assertThat(v)
                    .element(0) // 获取 value 的第一个元素
                    .asString() // 作为字符串处理
                    .matches("^Bearer .+$")); // 验证是否满足条件
    assertThat(result)
            // 切换到将响应体作为文本(字符串)处理的 Assert
            .bodyText()
            .isEqualTo("true");
}

@Test
void testLoginFail() throws Exception {
    // 给定
    String loginId = "NG_id";
    String password = "NG_password";
    // 当
    MvcTestResult result = mockMvc
            .get()
            .uri("/login")
            .param("loginId", loginId)
            .param("password", password)
            .exchange();
    // 然后
    assertThat(result)
            .hasStatus(HttpStatus.UNAUTHORIZED)
            .doesNotContainHeader(AUTHORIZATION);
}

AssertJ 的代码量略有增加,但可以直观地看出其所做的操作(个人观点)。

结束语

#

比较了双方,不知道大家感觉如何?虽然使用 AssertJ 并不会让代码量急剧减少,也不会让某些功能变得特别易用,但毫无疑问它更加易读、易写。作者今后也打算使用 AssertJ。

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。