基于 MockMvc 的 Spring[- Boot] Web 接口单元测试抽象
基于 MockMvc
的 Spring[ Boot]
Web
接口单元测试抽象
示例工程 spring-boot-mock-tester-examples
0.动机
0.1.描述
在做传统 Spring[ Boot]
项目时,对接口的 单元测试 是一个非常重要的环节。
- 提前发现自己的书写错误;
- 提前发现自己的验证错误;
- 提前发现由于项目迭代修改引入的隐式错误;
- 参数个数变动
- 参数名变动
- 校验规则变动
- …
- …
上述种种问题都表明 单元测试 是非常有需要且有必要的。
也就是在分支合并之前必须保证基本的测试必须要通过,这个提出了一个新的要求就是我们的 CI
以及编译工具(比如: mvn) 不能跳过测试。
疑问:
怎么来简化和减少开发者的工作量呢?这正是本项目和本文章的核心要义。
0.2.实现
由于 Spring [-Boot]
内部已经提供了 MockMvc
这样的框架,那我们就基于它来做更高层次的抽象,让接口测试更简单。
0.3.共识
0.3.1.请求头
- 我们通常称之为
Header
用header
表示Authorization
- 认证请求头
Tenant
- 自定义请求头
- …
0.3.2.请求体
- 我们通常称之为
Payload
用XxxPayload
命名OrderPayload
0.3.3.查询参数
- 我们称之为
Query
用XxxQuery
命名OrderQuery
0.3.4.路径参数
- 我们称之为
Path
参数
接下来我们就动手实操吧, Let's Go
1.使用
1.1.版本号
https://central.sonatype.com/artifact/io.github.photowey/spring-boot-mock-tester/versions
1.2.依赖
<!-- ${spring-boot-mock-tester.version} == ${latest.version} -->
<dependency>
<groupId>io.github.photowey</groupId>
<artifactId>spring-boot-mock-tester</artifactId>
<version>${spring-boot-mock-tester.version}</version>
<scope>test</scope>
</dependency>
<!-- 如果单元测试的目标接口集需要认证 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
2.接口
在接口测试之前需要让框架知道一些基本信息
- 是否启用
Spring Security
的保护?
- 默认: false
- 响应成功的响应码值是什么?
- 默认: 000000
- 响应成功的响应码属性是什么?
- 默认: code
可以建立一个本地单元测试抽象来统一实现
- 见
LocalTest
2.1.单元测试抽象
public abstract class LocalTest extends AbstractAPITester {
// 是否启用 Spring Security
@Override
protected boolean securityEnabled() {
// The servlet Filter named "springSecurityFilterChain".
return true;
}
// 告诉框架->标致接口响应成功的业务响应码
// 默认: 000000
// 可自行修改 -> 200
@Override
protected String apiOk() {
// Default: 000000
return "200";
}
// 告诉框架->接口响应码的属性名
// 默认: code
/**
* 响应数据结构示例:
* 单对象示例:
* <pre>
* {
* "code": "000000;",
* "message": "ok",
* "data": {
* "hello": "world"
* }
* }
* </pre>
* <p>
* 列表示例:
* <pre>
* {
* "code": "000000;",
* "message": "ok",
* "data": [
* {
* "hello": "world"
* }
* ]
* }
* </pre>
*/
@Override
protected String okPattern() {
return "$.code";
}
// 告诉框架->健康检查的接口
// 默认: /healthz
@Override
protected String healthApi() {
return "/healthz";
}
}
2.2.示例测试
@SpringBootTest(classes = App.class)
class ApiTest extends LocalTest {
// 执行我们在控制器的 @RequestMapping 定义的控制器命名空间(基路径)
private static final String METHODS_BASE_API = "/api/v1";
// /api/v1/order POST
// /api/v1/order/89757 GET
// ...
}
2.3.健康检测
2.3.1.GET
请求
通常用于内部服务监控触发的服务健康检查
@Test
void testHealthGet() throws Exception {
super.tryGetHealth();
}
2.3.2.HEAD
请求
通常用于云服务器环境触发的服务健康检查。基于云服务的健康检查机制来监控应用实例是否还 存活。
@Test
void testHealthHead() throws Exception {
super.tryHeadHealth();
}
2.4.GET
GET
共有 8 种场景抽象实现
2.4.1.无 Query
参数,采用基础断言
2.4.1.1.封装
// 这种请求即使是有参数,也需要开发者手动拼接到 URI 上面的,框架也认为是没有参数
// 框架只认: Query 参数
// 开发者也可以将其手动封装为 `Query` 参数
private void get_1(String name) throws Exception {
this.doGetRequest(METHODS_BASE_API + "/get?name=" + name);
}
2.4.1.2.调用
@Test
void testGet_1() throws Exception {
this.get_1("photowey");
}
2.4.2.无 Query
参数,自定义断言
2.4.2.1.封装
private void get_2(String name, Consumer<ResultActions> fx) throws Exception {
this.doGetRequest(METHODS_BASE_API + "/get?name=" + name, fx);
}
2.4.2.2.调用
@Test
void testGet_2() throws Exception {
this.get_2("photowey", (actions) -> {
try {
actions.andExpect(MockMvcResultMatchers.jsonPath(this.okPattern()).value(this.apiOk()))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.greeting").value("Hello get.photowey"));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
2.4.3….
2.4.8.有 Query
,自定义请求头和断言
2.4.8.1.封装
// query: 除自定义在 URI 之外的请求参数
// fn: 请求回调函数 -> 自定义添加请求头等
// fx: 断言回调函数
private void get_8(
HelloQuery query,
Consumer<MockHttpServletRequestBuilder> fn,
Consumer<ResultActions> fx) throws Exception {
this.doGetRequest(query, METHODS_BASE_API + "/get", fn, fx);
}
2.4.8.2.调用
@Test
void testGet_8() throws Exception {
HelloQuery query = new HelloQuery("photowey");
String token = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
this.get_8(query, (builder) -> {
// 自定义添加请求头
builder.header("Tenant", "web");
builder.header("Authorization", token);
}, (actions) -> {
try {
// 自定义断言 API ok
// 自定义断言响应结果
actions.andExpect(MockMvcResultMatchers.jsonPath(this.okPattern()).value(this.apiOk()))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.greeting").value("Hello get.photowey"));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
2.5.POST
POST
共有 8 种场景抽象实现
2.5.1.无 Payload
请求体,采用基础断言
2.5.1.1.封装
private void post_1(Long userId) throws Exception {
this.doPostRequest(METHODS_BASE_API + "/post/empty/" + userId);
}
2.5.1.2.调用
@Test
void testPost_1() throws Exception {
Long userId = 1711185600000L;
this.post_1(userId);
}
2.5.2.无 Payload
请求体,自定义断言
2.5.2.1.封装
// 路径参数: 自行添加到 URI 上
// 请求参数: xx?name=9527 自行添加到 URI 上
private void post_2(Long userId, Consumer<ResultActions> fx) throws Exception {
this.doPostRequest(METHODS_BASE_API + "/post/empty/" + userId, fx);
}
2.5.2.2.调用
@Test
void testPost_2() throws Exception {
Long userId = 1711185600000L;
this.post_2(userId, (actions) -> {
try {
actions.andExpect(MockMvcResultMatchers.jsonPath(this.okPattern()).value(this.apiOk()))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.greeting").value("Hello post.empty.1711185600000"));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
2.5.3….
2.5.8.有 Payload
,自定义请求头和断言
2.5.8.1.封装
// payload: 请求体
// fn: 请求回调函数 -> 自定义添加请求头等
// fx: 断言回调函数
private void post_8(
HelloPayload payload,
Consumer<MockHttpServletRequestBuilder> fn,
Consumer<ResultActions> fx) throws Exception {
this.doPostRequest(payload, METHODS_BASE_API + "/post", fn, fx);
}
2.5.8.2.调用
@Test
void testPost_8() throws Exception {
HelloPayload payload = new HelloPayload("photowey");
String token = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
this.post_8(payload, (builder) -> {
// 自定义添加请求头
builder.header("Tenant", "web");
builder.header("Authorization", token);
}, (actions) -> {
try {
// 自定义断言 API ok
// 自定义断言响应结果
actions.andExpect(MockMvcResultMatchers.jsonPath(this.okPattern()).value(this.apiOk()))
.andExpect(MockMvcResultMatchers.jsonPath("$.data.greeting").value("Hello post.photowey"));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
2.6.PUT
同
POST
2.7.PATCH
同
POST
2.8.DELETE
同
POST
3.实例
3.1.健康检查接口
/**
* {@code HealthController}
*
* @author photowey
* @date 2024/03/22
* @since 1.0.0
*/
@RestController
public class HealthController {
/**
* Health check.
* http://localhost:7923/healthz
*
* @return {@link StatusDTO}
*/
@GetMapping("/healthz")
public ResponseEntity<StatusDTO> get() {
return new ResponseEntity<>(StatusDTO.up(), HttpStatus.OK);
}
@RequestMapping(value = "/healthz", method = RequestMethod.HEAD)
public void head() {
}
}
3.2业务接口
/**
* {@code ApiController}
*
* @author photowey
* @date 2024/03/22
* @since 1.0.0
*/
@RestController
@RequestMapping("/api/v1")
public class ApiController {
/**
* GET :/get
* <p>
* curl -X GET "http://localhost:7923/api/v1/get?name=photowey"
*
* @param query {@link HelloQuery}
* @return {@link GreetingDTO}
*/
@GetMapping("/get")
public ApiResult<GreetingDTO> get(HelloQuery query) {
return ApiResult.ok(new GreetingDTO(String.format("Hello get.%s", query.getName())));
}
/**
* POST :/post
* <p>
* curl -X POST -H "Content-Type:application/json" -d '{"name":"photowey"}' http://localhost:7923/api/v1/post
*
* @param payload {@link HelloPayload}
* @return {@link GreetingDTO}
*/
@PostMapping("/post")
public ApiResult<GreetingDTO> post(@RequestBody HelloPayload payload) {
return ApiResult.ok(new GreetingDTO(String.format("Hello post.%s", payload.getName())));
}
/**
* POST :/post/empty/{id}
* <p>
* curl -X POST http://localhost:7923/api/v1/post/empty/1711185600000
*
* @param userId {@code userId}
* @return {@link GreetingDTO}
*/
@PostMapping("/post/empty/{userId}")
public ApiResult<GreetingDTO> postEmpty(@PathVariable("userId") Long userId) {
return ApiResult.ok(new GreetingDTO(String.format("Hello post.empty.%s", userId)));
}
/**
* PUT :/put
* <p>
* curl -X PUT -H "Content-Type:application/json" -d '{"name":"photowey"}' http://localhost:7923/api/v1/put
*
* @param payload {@link HelloPayload}
* @return {@link GreetingDTO}
*/
@PutMapping("/put")
public ApiResult<GreetingDTO> put(@RequestBody HelloPayload payload) {
return ApiResult.ok(new GreetingDTO(String.format("Hello put.%s", payload.getName())));
}
/**
* PUT :/put/empty/{id}
* <p>
* curl -X PUT http://localhost:7923/api/v1/post/empty/1711185600000
*
* @param userId {@code userId}
* @return {@link GreetingDTO}
*/
@PutMapping("/put/empty/{userId}")
public ApiResult<GreetingDTO> putEmpty(@PathVariable("userId") Long userId) {
return ApiResult.ok(new GreetingDTO(String.format("Hello put.empty.%s", userId)));
}
/**
* PATCH :/patch
* <p>
* curl -X PATCH -H "Content-Type:application/json" -d '{"name":"photowey"}' http://localhost:7923/api/v1/patch
*
* @param payload {@link HelloPayload}
* @return {@link GreetingDTO}
*/
@PatchMapping("/patch")
public ApiResult<GreetingDTO> patch(@RequestBody HelloPayload payload) {
return ApiResult.ok(new GreetingDTO(String.format("Hello patch.%s", payload.getName())));
}
/**
* PATCH :/patch/empty/{id}
* <p>
* curl -X PATCH http://localhost:7923/api/v1/post/empty/1711185600000
*
* @param userId {@code userId}
* @return {@link GreetingDTO}
*/
@PatchMapping("/patch/empty/{userId}")
public ApiResult<GreetingDTO> patchEmpty(@PathVariable("userId") Long userId) {
return ApiResult.ok(new GreetingDTO(String.format("Hello patch.empty.%s", userId)));
}
/**
* DELETE :/delete
* <p>
* curl -X DELETE -H "Content-Type:application/json" -d '{"name":"photowey"}' http://localhost:7923/api/v1/delete
*
* @param payload {@link HelloPayload}
* @return {@link GreetingDTO}
*/
@DeleteMapping("/delete")
public ApiResult<GreetingDTO> delete(@RequestBody HelloPayload payload) {
return ApiResult.ok(new GreetingDTO(String.format("Hello delete.%s", payload.getName())));
}
/**
* DELETE :/delete/empty/{userId}
* <p>
* curl -X DELETE http://localhost:7923/api/v1/delete/empty/1711120980000
*
* @param userId {@code userId}
* @return {@link GreetingDTO}
*/
@DeleteMapping("/delete/empty/{userId}")
public ApiResult<GreetingDTO> deleteEmpty(@PathVariable("userId") Long userId) {
return ApiResult.ok(new GreetingDTO(String.format("Hello delete.empty.%d", userId)));
}
}
至此,我们已经大致了解了 mock tester
的使用方法,赶紧动手 Run
起来。
转载自:https://juejin.cn/post/7363556508603269132