likes
comments
collection
share

基于 MockMvc 的 Spring[- Boot] Web 接口单元测试抽象

作者站长头像
站长
· 阅读数 28

基于 MockMvcSpring[ Boot] Web 接口单元测试抽象

项目 spring-boot-mock-tester

示例工程 spring-boot-mock-tester-examples

0.动机

0.1.描述

在做传统 Spring[ Boot] 项目时,对接口的 单元测试 是一个非常重要的环节。

  • 提前发现自己的书写错误;
  • 提前发现自己的验证错误;
  • 提前发现由于项目迭代修改引入的隐式错误;
    • 参数个数变动
    • 参数名变动
    • 校验规则变动

上述种种问题都表明 单元测试 是非常有需要且有必要的。

也就是在分支合并之前必须保证基本的测试必须要通过,这个提出了一个新的要求就是我们的 CI 以及编译工具(比如: mvn) 不能跳过测试。

疑问:

怎么来简化和减少开发者的工作量呢?这正是本项目和本文章的核心要义。

0.2.实现

由于 Spring [-Boot] 内部已经提供了 MockMvc 这样的框架,那我们就基于它来做更高层次的抽象,让接口测试更简单。

0.3.共识

0.3.1.请求头

  • 我们通常称之为 Headerheader 表示
    • Authorization
      • 认证请求头
    • Tenant
      • 自定义请求头

0.3.2.请求体

  • 我们通常称之为 PayloadXxxPayload 命名
    • OrderPayload

0.3.3.查询参数

  • 我们称之为 QueryXxxQuery 命名
    • 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
评论
请登录