【JUnit实战3_30】第十八章:REST API 接口测试(下)—— REST API 接口的 MockMvc + JUnit 5 测试实战

《JUnit in Action》全新第3版封面截图

写在前面

上篇介绍了 REST API 接口项目的搭建,本以为下篇应该轻松不少,没想到认真梳理下来居然有种在学 CSS 的错觉------知识点间的涟漪效应大大出乎我的意料------可能这也是为什么作者没有详细展开某些细节的原因吧(毕竟还有给第四板块压轴的第 19 章),不能喧宾夺主。但基于实战的需要,必要的深挖还是不能少的,尤其是第一次接触这些知识点,现在不搞懂,拖到后面再搞懂的成本往往十分昂贵。一起来看看吧。

(接 上篇

18.4 RESTful API 接口的测试

18.4.1 新增 Passenger REST API 接口

前面建立关于 Country 实体的 REST API 接口旨在跑通整个流程,而本章要重点演示的是基于乘客实体 PassengerREST API 接口。它同样涉及 CRUD 基础操作。利用 IDEAEndpoints 工具可以快速查看当前项目的所有 API 接口,工具窗口下方甚至还给出了每个接口的 OpenAPI 规范描述以及 curl 命令的调用格式,非常方便:

为此,需要先改造 Passenger 实体类。根据定义,每个乘客实例都包含三个属性:namecountryisRegistered。其中 countryCountry 类的实例,在考虑乘客数据的持久化时必须明确 CountryPassenger 之间的映射关系。这里显然是 一对多 关系:一则国籍信息可以被多个乘客实例引用。因此改造如下:

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 用得较少外,其余都比较常见;部分项目可能处于安全考虑还会拦截除 GETPOST 以外的其他请求类型。这里只演示标准模式下的接口写法。对于需要手动设置响应码的,可像 @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 请求可以写作:

powershell 复制代码
curl -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 端点,我们要验证的是:

  1. 状态码是否为 200
  2. Header 里的 Content-Type 是否是 JSON 格式的;
  3. 总记录数是否正确。

因此,需要先用 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 而是 201status().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 接口的测试作了最基本的演示,想要彻底掌握 MockMvcJUnit 5 的组合用法,还需要大量实战演练,同时加强 API 接口开发的理论储备,切莫一曝十寒,贪多求快。

相关推荐
测试老哥2 小时前
Postman获取token并设置token依赖步骤
自动化测试·软件测试·python·测试工具·测试用例·接口测试·postman
我的xiaodoujiao3 小时前
Django 基础知识详细图文教程 4-Django 视图定义与使用
开发语言·数据库·后端·测试工具·django·sqlite
呆呆敲代码的小Y5 小时前
Lua 上值(UpValue)
开发语言·junit·lua·lua上值·upvalue
呆呆敲代码的小Y5 小时前
【游戏开发】xLua 性能优化技巧
junit·性能优化·lua·xlua
JZMSYYQ1 天前
电输运性质测试系统主要测试哪些方面
测试工具·材料测试·电性能·电性能测试·电输运测试·测试设备
中国软件测试质量协会1 天前
国产UI自动化测试工具的“技术代际“演进与自主可控之路
测试工具·ui·自动化
zzzzzzzz'2 天前
浅谈自动化测试2——selenium库中的常用函数
selenium·测试工具·文件上传·导航栏·弹窗·定位元素·查找元素
大雷神2 天前
HarmonyOS AR Engine 物体形状实战:把实体书识别为 RECTANGLE,再放进地面 AR 盒子
ar·restful·harmonyos
边吃番茄边敲代码2 天前
从本地工具到 MCP:给 Agent 接入独立工具服务,并补齐自动化测试
人工智能·python·功能测试·ai·单元测试·pytest
qq_452396232 天前
第十三篇:《测试策略:单元测试、组件测试、E2E 测试的落地实践》
单元测试