测试代码是负担还是抓手?如何编写测试代码?

你好,我是猿java。

在日常工作中,看过很多开发人员不写代码测试,大部分理由是“太忙“或者”没必要”,更严重的是很多开发人员甚至不知道如何写测试代码,本文我们总结了一位腾讯后端对 Controller层代码优秀的测试经验,希望对你有帮助。

img.png

如何编写优雅的 Controller代码? 这篇文章中,我们分析了 Controller层的 6个主要职责,本文将分析如何对 Controller层进行360度无死角测试。

首先回顾下 Controller层的6个主要职责:

  1. 接收 HTTP(s)请求
  2. 解析请求参数
  3. 校验请求参数
  4. 调用业务方法
  5. 组织返回数据
  6. 统一异常处理

理解了 Controller的职责,我们才能更有目的性对它进行测试。如下代码,包含了 Controller的 6个主要职责,我们需要如何对它进行测试?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

@RestController
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}

@PostMapping("/user/register")
public String getGradeById(@Validated @RequestBody User user) {
// 调用注册的业务方法
String userId = userService.register(user);
return userId;
}
}

public class User {
@NotBlank(message = "Nickname is required.")
private String nickname;
private Integer age;
// getters and setters and constructors
}

为什么要测试?

作为一名程序员,代码质量是我们必须守住的底线,对于自己编写的代码一定需要进过测试,而不是把测试工作好不负责的全部交给测试人员,这样才能保证代码的可靠性。

对于开发人员,保证代码质量最有效的方式是测试,最常见的测试方式有 2种:单元测试(Unit Testing)和 集成测试(Integration Testing)。

单元测试的目的是验证单个功能单元的行为是否正确,这里的功能单元通常是一个类或方法,单元测试最大的优点是可以在不依赖于其他部分的前提下测试某个功能单元。

集成测试的目的是验证多个功能单元之间的交互是否正确,可能涉及数据库、文件系统、网络等外部系统,如果需要排除外部系统的干扰,可以对他们进行 mock。

如何测试?

对于 Controller层的代码测试,因为单元测试的局限性,所以我们需要采用集成测试,本文将使用SpringBootTestMockito两个主流的测试框架进行分析,下面先说明几个重要的组件:

@WebMvcTest

@WebMvcTest是 Spring Boot提供的一个注解,用于测试 SpringMVC的 Controller,它能够启动一个最小化的 Spring应用上下文,包含仅与 Web层相关的 bean,从而快速且高效地测试 Controller的功能。

@WebMvcTest主要功能如下:

  • 加载 Web层相关的 Bean: @WebMvcTest只加载与 Web层相关的 bean,例如 Controller、过滤器、拦截器等,这使得测试环境更加轻量级。
  • 自动配置 MockMvc: @WebMvcTest 自动配置 MockMvc,使得测试控制器的 HTTP请求处理变得简单。
  • 支持 @MockBean注解: 可以使用 @MockBean注解来模拟服务层的 bean,以隔离控制器测试,不依赖实际的服务层实现。

MockMvc:

MockMvc是 Spring框架中用于测试 Spring MVC控制器的主要工具,它允许开发者在不启动整个 HTTP服务器的情况下,测试控制器的请求处理方法。MockMvc 提供了一种方式来模拟 HTTP请求并验证响应,确保控制器行为符合预期。

MockMvc的主要功能如下:

  • 模拟 HTTP 请求: MockMvc 可以模拟各种 HTTP 请求,如 GET、POST、PUT、DELETE 等。
  • 验证响应: MockMvc 提供了一组丰富的断言来验证响应的状态码、内容类型、内容、头信息等。
  • 测试控制器逻辑: 通过模拟请求并验证响应,可以测试控制器的业务逻辑、路径变量、请求参数、请求体等。
  • 集成测试: 可以与 Spring 的其他测试工具结合,进行更复杂的集成测试。

Mockito

Mockito 是一个流行的 Java测试框架,用于创建和配置 mock 对象。它主要用于单元测试中,通过模拟依赖对象的行为,使得测试目标对象能够独立于其依赖项进行测试。

Mockito主要功能如下:

  • 创建 Mock 对象: 使用 @Mock注解或 Mockito.mock()方法创建 mock对象。
  • 定义行为: 使用 when 和 thenReturn 等方法定义 mock 对象的方法行为。
  • 验证行为: 使用 verify 方法验证 mock 对象的方法是否被调用,以及调用的次数和参数。
  • 参数匹配: 使用 any、eq 等参数匹配器来验证方法调用时传入的参数。
  • 模拟异常: 使用 thenThrow 方法模拟方法抛出异常的行为。

ObjectMapper

它是 Jackson库中的核心类之一,用于将 Java 对象转换为 JSON 字符串,或者将 JSON 字符串转换为 Java 对象。在 Spring 应用中,ObjectMapper 被广泛用于处理 JSON 数据。

ObjectMapper的主要功能如下:

  • 序列化(Serialization): 将 Java 对象转换为 JSON 字符串。
  • 反序列化(Deserialization): 将 JSON 字符串转换为 Java 对象。
  • 读取和写入 JSON 文件或流: 直接从文件、输入流读取 JSON 数据,或将 JSON 数据写入文件、输出流。

所以,一个包含上述所有组件的完整代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@WebMvcTest(controllers = RegisterRestController.class)
class RegisterRestControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private XXX xxx; // 用户业务的bean

@Test
void test() throws Exception {
mockMvc.perform(...);
}
}

介绍了上面几个集成测试的组件之后,接下来我们就可以详解的分析他们是如何应用到 Controller的集成测试中。

测试详解

我们按照 Controller的 6个主要职责来分别讲解他们是如何进行测试的。

测试接收 HTTP(s)请求

Controller是接收请求的入口,因此对于 Controller接收 HTTP(s)请求验证 Controller是否侦听某个 HTTP 请求非常简单,我们只需调用 MockMvc.perform() 方法,返回 200代表成功,返回非200就代表异常,示例代码如下:

1
2
3
4
5
6
@Test
void whenReceiveHttpRqe_thenReturns200() throws Exception {
mockMvc.perform(post("/user/register")
.contentType("application/json"))
.andExpect(status().isOk());
}

测试解析请求参数

Controller是通过@PathVariable@RequestBody@RequestParam 3种方式来接收参数的,因此,下面的示例代码分别模拟这 3种方式是如何进行参数传递的。

1
2
3
4
5
6
7
8
9
10
11
12
@Autowired
private ObjectMapper objectMapper;
@Test
void whenValidInput_thenReturns200() throws Exception {
User user = new User("zhangsan", 21);

mockMvc.perform(post("/user/register/{id}", 1111) // 模拟通过 @PathVariable传递参数
.contentType("application/json")
.param("name", "张三") // 模拟通过 @RequestParam传递参数
.content(objectMapper.writeValueAsString(user))) // 模拟通过 @RequestBody传递参数
.andExpect(status().isOk());
}

通过上面的方式,我们模拟了一个正常的 HTTP请求,并且使用 3种方式进行参数传递。

测试校验请求参数

测试参数的校验,主要是为了检查代码逻辑有没有对参数进行有效的验证,比如,必填字段判空,字符串最大长度限制,数字最大值和最小值校验,手机号或邮箱格式校验等。

参数校验测试一般分正常测试和按预期失败的异常测试,特别需要边界的测试。如下示例,给出了测试通过和符合预期并返回 400的 BadRequest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class User {
@NotBlank(message = "Nickname is required.")
private String nickname;
private Integer age;
// getters and setters and constructors
}

@Test
void whenNicknameNull_thenReturns400() throws Exception {
User user = new User(null, 21); // 当 nickname为空时,会抛出 400的 BadRequest异常
mockMvc.perform(post("/user/register")
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isOk());
}
@Test
void testReturns200() throws Exception {
User user = new User("zhangsan", 21);
mockMvc.perform(post("/user/register")
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isBadRequest());
}

测试调用业务方法

对于业务方法调用的测试,通常我们会使用Mockito.mock来模拟业务方法的返回值,而业务方式的真实运行逻辑会在 Server的单元测试中完成,如下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 调用注册的业务方法
String userId = userService.register(user);

@Test
void whenLogic_thenReturnsExceptData() throws Exception {
User user = new User(null, 21);
// mock userService.register(user)返回值为 userId
Mockito.when(userService.register(user)).thenReturn("userId");

mockMvc.perform(post("/user/register")
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", is("userId")));
}

测试组织返回数据

在业务代码执行完之后,我们需要对 HTTP对应相应,因此可以使用 andReturn()方法将 HTTP交互的结果存储在 MvcResult类型的变量中,然后从响应正文中读取 JSON字符串,并使用 isEqualToIgnoringWhitespace()将其与预期字符串进行比较,如下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 调用注册的业务方法
String userId = userService.register(user);

@Test
void whenLogic_thenReturnsExceptData() throws Exception {
User user = new User("zhangsan", 21);
// mock userService.register(user)返回值为 userId
Mockito.when(userService.register(user)).thenReturn("userId");

MvcResult mvcResult = mockMvc.perform(post("/user/register")
.content(objectMapper.writeValueAsString(user)))
.andReturn(); //将结果返回
String expectedResponse = "userId";
String actualResponseBody = mvcResult.getResponse().getContentAsString();
assertThat(actualResponseBody).isEqualToIgnoringWhitespace(
objectMapper.writeValueAsString(expectedResponse));
}

测试统一异常处理

测试异是指如果发生异常,Controller应返回特定的 HTTP状态,比如 200,400,500等等,
默认情况下,Spring 会处理其中的大多数情况。但是,如果我们有一个自定义异常处理,我们想要测试它。假设我们要返回一个结构化的 JSON 错误响应,其中包含请求中每个无效字段的字段名称和错误消息。我们会创建一个这样的@ControllerAdvice:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
void whenNullValue_thenReturns400AndErrorResult() throws Exception {
User user = new User("zhangsan", 21);

MvcResult mvcResult = mockMvc.perform(post("/user/register")
.contentType("application/json")
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isBadRequest())
.andReturn();

ErrorResult expectedErrorResponse = new ErrorResult("Nickname", "Nickname is required.");
String actualResponse = mvcResult.getResponse().getContentAsString();
String expectedResponseBody = objectMapper.writeValueAsString(expectedErrorResponse);
assertThat(actualResponse).isEqualToIgnoringWhitespace(expectedErrorResponse);
}

总结

本文结合 SpringBootTestMockito两个主流的测试框架,对 Controller各个职责进行全面的测试,并且给出了比较详解的示例代码,通过本文的分析,我们不仅可以学会对 Controller的测试,同时我们还应该触类旁通,将里面优秀的思维应用到其他层级代码的测试。

代码质量是开发人员必须守住的底线,所以在日常的开发中一定要秉着对自己负责的态度,对自己的代码进行测试。

测试是一个看似简单其实很难的问题,很多人工作了很多年,其实都不能写出很有效的测试代码。

对于开发人员,使用最多的测试方式是单元测试和集成测试。

学习交流

如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。

drawing