
《JUnit in Action》全新第3版封面截图
写在前面
上篇介绍了 REST API 接口项目的搭建,本以为下篇应该轻松不少,没想到认真梳理下来居然有种在学 CSS 的错觉------知识点间的涟漪效应大大出乎我的意料------可能这也是为什么作者没有详细展开某些细节的原因吧(毕竟还有给第四板块压轴的第 19 章),不能喧宾夺主。但基于实战的需要,必要的深挖还是不能少的,尤其是第一次接触这些知识点,现在不搞懂,拖到后面再搞懂的成本往往十分昂贵。一起来看看吧。
(接 上篇)
18.4 RESTful API 接口的测试
18.4.1 新增 Passenger REST API 接口
前面建立关于 Country 实体的 REST API 接口旨在跑通整个流程,而本章要重点演示的是基于乘客实体 Passenger 的 REST API 接口。它同样涉及 CRUD 基础操作。利用 IDEA 的 Endpoints 工具可以快速查看当前项目的所有 API 接口,工具窗口下方甚至还给出了每个接口的 OpenAPI 规范描述以及 curl 命令的调用格式,非常方便:

为此,需要先改造 Passenger 实体类。根据定义,每个乘客实例都包含三个属性:name、country 和 isRegistered。其中 country 是 Country 类的实例,在考虑乘客数据的持久化时必须明确 Country 和 Passenger 之间的映射关系。这里显然是 一对多 关系:一则国籍信息可以被多个乘客实例引用。因此改造如下:
java
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import java.util.Objects;
@Entity
public class Passenger {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne
private Country country;
private boolean isRegistered;
public Passenger() {
this(null);
}
public Passenger(String name) {
this.name = name;
}
// -- snip --
}
与 Country 不同,Passenger 没有现成的唯一标识做主键,于是新增一个 id 属性;此外,在 country 属性上添加了 @ManyToOne 注解。单击属性左侧的关系图标还可以查看 IDEA 提供的持久层视图,进一步验证两者的对应关系:

接着创建乘客的 DAO 接口 PassengerRepository,注意主键类型必须与实体类保持一致(Long):
java
public interface PassengerRepository extends JpaRepository<Passenger, Long> {
}
然后创建 PassengerController,分别实现 CRUD 各请求的响应逻辑:
java
@RestController
public class PassengerController {
@Autowired
private PassengerRepository repository;
@Autowired
private Map<String, Country> countriesMap;
@GetMapping("/passengers")
List<Passenger> findAll() {
return repository.findAll();
}
@PostMapping("/passengers")
@ResponseStatus(HttpStatus.CREATED)
Passenger createPassenger(@RequestBody Passenger passenger) {
return repository.save(passenger);
}
@GetMapping("/passengers/{id}")
Passenger findPassenger(@PathVariable Long id) {
return repository.findById(id)
.orElseThrow(() -> new PassengerNotFoundException(id));
}
@PatchMapping("/passengers/{id}")
Passenger patchPassenger(@RequestBody Map<String, String> updates, @PathVariable Long id) {
return repository.findById(id)
.map(passenger -> {
String name = updates.get("name");
if (null != name) {
passenger.setName(name);
}
Country country = countriesMap.get(updates.get("country"));
if (null != country) {
passenger.setCountry(country);
}
String isRegistered = updates.get("isRegistered");
if (null != isRegistered) {
passenger.setIsRegistered(isRegistered.equalsIgnoreCase("true"));
}
return repository.save(passenger);
})
.orElseThrow(() -> new PassengerNotFoundException(id));
}
@DeleteMapping("/passengers/{id}")
void deletePassenger(@PathVariable Long id) {
repository.deleteById(id);
}
}
上述代码除了 @PatchMapping 用得较少外,其余都比较常见;部分项目可能处于安全考虑还会拦截除 GET、POST 以外的其他请求类型。这里只演示标准模式下的接口写法。对于需要手动设置响应码的,可像 @ResponseStatus(HttpStatus.CREATED) 这样添加到对应的处理方法上。
为确保项目启动时获取到有效的乘客列表,还需要同步修改启动类 Application,将包含乘客信息的 CSV 文件内容提前存入内存数据库(L20):
java
@SpringBootApplication
@Import(FlightBuilder.class)
public class Application {
@Autowired
private Map<String, Country> countriesMap;
@Autowired
private Flight flight;
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean
CommandLineRunner configureRepository(CountryRepository countryRepository,
PassengerRepository passengerRepository) {
return args -> {
countryRepository.saveAll(countriesMap.values());
passengerRepository.saveAll(flight.getPassengers());
};
}
}
18.4.2 简单测试乘客 API
启动项目,然后使用 curl 命令尝试调用新创建的乘客信息 REST API 接口(浏览器无法直接发送 POST 请求,因此不考虑)。这里顺便梳理一下在 Windows Terminal 终端的 PowerShell 环境下运行 curl 的几个注意事项。
先尝试获取所有乘客信息:
powershell
curl -v localhost:8081/passengers
实测结果(成功):

再查用 id 查看单个乘客信息:
powershell
curl -v localhost:8081/passengers/4
实测结果:

接着尝试修改该乘客信息,调用端点 PATCH /passengers/{id}:
powershell
curl -v -X PATCH localhost:8081/passengers/4 -H "Content-type:application/json" -d '{"name":"Sophia Jones", "country":"AU", "isRegistered":"true"}'
运行结果:

接着删除该乘客:
powershell
curl -v -X DELETE localhost:8081/passengers/4
运行结果:

最后再新增一条乘客记录,使用 POST /passengers 端点:
powershell
curl -v -X POST localhost:8081/passengers -H "Content-type:application/json" -d '{"name":"John Smith"}'

实用技巧:在 PowerShell 中格式化 curl 命令
上述
curl命令虽然和书中效果相同,但都是 单行命令,无法像原书那样对较长命令进行换行或手动缩进:经实测,
PowerShell中的换行主要有以下几种情况:
场景 方法 示例 管道符 后 紧跟管道符,按 Enter 换行 英文逗号 后 紧跟逗号,按 Enter 换行 大括号 / 括号 后 紧跟 左括号 ,按 Enter 换行 点号运算符 后 紧跟点运算符,按 Enter 换行 其他位置 用 反引号 ` + Enter 因此,格式化后的
PATCH请求可以写作:
powershellcurl -v -X PATCH localhost:8081/passengers/4 ` -H "Content-Type: application/json"` -d '{ "name": "Sophia Jones", "country": "AU", "isRegistered": "true" }'实测效果(红框部分为触发换行的地方):
最后,混合使用单引号和双引号 可以避免手动输入
JSON内的大量转移符\,也算一个提速小技巧吧。
18.4.3 REST API 接口的测试
到这里才是全章的重点内容,不过作者是对照大段测试用例逐一解释的,很多地方并没有展开讲,有点小遗憾;我准备按不同的接口进行梳理,并结合实测情况补充必要内容。
1 GET 请求测试:查数据列表
先来看 GET /countries 的测试。创建测试类 RestApplicationTest:
java
@SpringBootTest
@AutoConfigureMockMvc
@Import(FlightBuilder.class)
public class RestApplicationTest {
@Autowired
private MockMvc mvc;
@Autowired
private Map<String, Country> countriesMap;
@MockitoBean
private CountryRepository countryRepository;
@Test
void testGetAllCountries() throws Exception {
when(countryRepository.findAll()).thenReturn(new ArrayList<>(countriesMap.values()));
mvc.perform(get("/countries"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$", hasSize(3)));
verify(countryRepository, times(1)).findAll();
}
}
可以看到,REST API 接口的测试总共分三步:
- 利用依赖注入初始化相关参数;
- 设置期望的状态;
- 模拟执行并断言结果;
其中,MockMvc 型依赖(mvc)是测试逻辑的主入口,其相关对象的自动配置是通过 @AutoConfigureMockMvc 注解启用的,并通过 @MockitoBean 注解具体注入(L7)。
对于 GET /countries 端点,我们要验证的是:
- 状态码是否为
200; Header里的Content-Type是否是JSON格式的;- 总记录数是否正确。
因此,需要先用 when/thenReturn 设置大的流程,再用 mvc 对象模拟调用一次端点,并用 MockMvc 常用的链式写法断言上述三个指标,最后调用 verifiy() 方法严格限定调用次数,正式启动模拟逻辑即可。
这里比较有意思的是断言总记录数的相关写法:jsonPath("$", hasSize(3))。其中,第一个参数 "$" 表示要遍历的根节点(The root element to query)。这是一门用来读取 JSON 内容的 Java 专用 DSL 语言,全称叫 Jayway JsonPath,具体用法详见 GitHub 官方文档。
本地实测结果:

同理可测 GET /passengers 端点:
java
@Autowired
private Flight flight;
@MockitoBean
private PassengerRepository passengerRepository;
@Test
void testGetAllPassengers() throws Exception {
when(passengerRepository.findAll()).thenReturn(new ArrayList<>(flight.getPassengers()));
mvc.perform(get("/passengers"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$", hasSize(20)));
verify(passengerRepository, times(1)).findAll();
}
可以看到,测试逻辑都是声明式的写法,无需死记硬背,真正要用的时候多写几遍就够了。
实测截图:

2 GET 请求测试:查单个记录
由于 spring-boot-starter-data-jpa 中查询单个记录用的是 repository.findById(id).orElseThrow(...),因此除了考虑正常流程还得测一下查不到的异常情况。这里作者省略了太多内容,没有详细展开;本着实战精神,我再补充点内容。
正常情况下,查单条记录应该写成:
java
@Test
void testFindPassengerById() throws Exception {
Passenger passenger = new Passenger("John Smith");
Country country = countriesMap.get("UK");
passenger.setCountry(country);
passenger.setIsRegistered(false);
when(passengerRepository.findById(1L)).thenReturn(Optional.of(passenger));
mvc.perform(get("/passengers/1"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.name", is("John Smith")))
.andExpect(jsonPath("$.registered", is(Boolean.FALSE)))
.andExpect(jsonPath("$.country.name", is("United Kingdom")))
.andExpect(jsonPath("$.country.codeName", is("UK")));
verify(passengerRepository, times(1)).findById(1L);
}
运行肯定也是没问题的:

但可能是后面还有个 PATCH /passenger/{id} 端口,里面也会调用 findById() 方法,因此上述正常情况就跳过了,直接补了一个抛异常的情况:
java
@Test
void testPassengerNotFound() {
Throwable throwable = assertThrows(NestedServletException.class, () ->
mvc.perform(get("/passengers/30")).andExpect(status().isNotFound()));
assertEquals(PassengerNotFoundException.class, throwable.getCause().getClass());
}
// 自定义异常:
public class PassengerNotFoundException extends RuntimeException {
public PassengerNotFoundException(Long id) {
super("Passenger id not found : " + id);
}
}
上述用例写得很简洁,只是有点 过于简洁 了。按照刚才的套路,应该写成这样才对啊:
java
@Test
void testPassengerNotFound() {
Throwable throwable = assertThrows(ServletException.class, () -> {
when(passengerRepository.findById(30L)).thenThrow(PassengerNotFoundException.class);
mvc.perform(get("/passengers/30"))
.andExpect(status().isNotFound());
verify(passengerRepository, times(1)).findById(30L);
});
assertEquals(PassengerNotFoundException.class, throwable.getCause().getClass());
}
为了一探究竟,我用调试模式跟踪了一下,结果发现测试逻辑执行到 L6 就中断返回了:

我这才悟了:注定要抛的异常何必画蛇添足写个 when()?既然抛异常了何必再 verify() 手动验证一次执行?于是,就有了上面的简化版,一切都说得通了。
3 POST 请求测试
有了前面两组用例热身,发送 POST 请求的用例简直不要太简单,标准的 AAA 模式(Arrange-Act-Assert,即准备-执行-验证):
java
@Test
void testPostPassenger() throws Exception {
Passenger passenger = new Passenger("Peter Michelsen");
passenger.setCountry(countriesMap.get("US"));
passenger.setIsRegistered(false);
when(passengerRepository.save(passenger)).thenReturn(passenger);
mvc.perform(post("/passengers")
.content(new ObjectMapper().writeValueAsString(passenger))
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name", is("Peter Michelsen")))
.andExpect(jsonPath("$.country.codeName", is("US")))
.andExpect(jsonPath("$.country.name", is("USA")))
.andExpect(jsonPath("$.registered", is(Boolean.FALSE)));
verify(passengerRepository, times(1)).save(passenger);
}
其实还是有不一样的地方,比如传入 perform() 方法的参数:
java
MockHttpServletRequestBuilder mockRequest = post("/passengers")
.content(new ObjectMapper().writeValueAsString(passenger))
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
其中第 L2 行是利用了第三方库 Jackson 的标准接口将 passenger 转成了 JSON 字符串;而第三行的写法可以避免手动输错 Header 请求头。
另外,示例代码并没有单独声明一个 MockHttpServletRequestBuilder 对象,而是直接放到了 perform() 中,这也是尽量使用声明式编程的考虑。
至于状态码的断言,严格意义上的基于 RESTful 风格的 POST 请求其实不是 200 而是 201(status().isCreated()),该规范的时候还是不要遗漏这些细节。
本地运行结果:

4 PATCH 请求测试
根据 REST API 的说法,PATCH 请求就是打补丁用的,需要修改哪些字段就提交哪些,传统模式下生成的更新语句也是动态的才对,因此演示代码和测试用例都写得比其他端点多一些:
java
@Test
void testPatchPassenger() throws Exception {
Passenger passenger = new Passenger("Sophia Graham");
passenger.setCountry(countriesMap.get("UK"));
passenger.setIsRegistered(false);
when(passengerRepository.findById(1L)).thenReturn(Optional.of(passenger));
when(passengerRepository.save(passenger)).thenReturn(passenger);
String updates = "{\"name\":\"Sophia Jones\", \"country\":\"AU\", \"isRegistered\":\"true\"}";
mvc.perform(patch("/passengers/1")
.content(updates)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
).andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk());
verify(passengerRepository, times(1)).findById(1L);
verify(passengerRepository, times(1)).save(passenger);
}
上述逻辑和 POST 类似,请求的 BODY 正文部分都需要用 content() 方法传 JSON 字符串。整个测试流程也是标准的 AAA 模式(强烈建议手敲一遍加深印象)。最终实测结果:

5 DELETE 请求测试
最后的 DELETE /passengers/{id} 端点反而最简单,因为遵循幂等设计,不必单独考虑删除了不存在的乘客 id 的情况:
java
@Test
public void testDeletePassenger() throws Exception {
mvc.perform(delete("/passengers/4"))
.andExpect(status().isOk());
verify(passengerRepository, times(1)).deleteById(4L);
}
这里用 curl 验证删除一个不存在的乘客更有说服力:

最后,如果实在不放心,还可以在 IDEA 中导出测试报表查看乘客 Controller 中的测试用例的覆盖情况:

后话
本章仅对
REST API接口的测试作了最基本的演示,想要彻底掌握MockMvc和JUnit 5的组合用法,还需要大量实战演练,同时加强API接口开发的理论储备,切莫一曝十寒,贪多求快。






