【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 接口开发的理论储备,切莫一曝十寒,贪多求快。

相关推荐
程序员小远4 小时前
快速定位bug,编写测试用例
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·bug
oh-pinpin5 小时前
【jmeter】-安装-单机安装部署(Windows和Linux)
测试工具·jmeter·压力测试
慧都小项5 小时前
Parasoft C/C++test如何使用桩函数替代MFC窗口类顺利执行单元测试
单元测试·parasoft·桩函数·mfc窗口类
慧都小项15 小时前
Parasoft C/C++test中Trace32调试器的配置与单元测试执行
单元测试·parasoft·trace32调试器
newxtc1 天前
【湖北政务服务网-注册_登录安全分析报告】
人工智能·selenium·测试工具·安全·政务
软件测试小仙女1 天前
简单但好用:4种Selenium截图方法
自动化测试·软件测试·selenium·测试工具·测试用例·接口测试·selenium截图
程序员杰哥1 天前
软件测试之压力测试详解
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·压力测试