web单元测试

前面我们用idea提供的http client工具编写http请求脚本,并对用户模块的整个流程进行了手动的测试。但我们并不满足于此,毕竟现在实际开发的项目更多采用cicd的方式进行开发和部署,对开发人员来说,要保证代码质量的前提就是做好各层的单元测试。为此,这一节,我们对web层controller组件进行单元测试。

完善测试依赖

这里我们将lombok依赖也加到测试环境,为我们的测试代码服务:

groovy 复制代码
dependencies {
    ...

    testAnnotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.projectlombok:lombok'

    ...
}

完善单元测试骨架

先对UserController完成web单元测试的骨架,代码如下:

java 复制代码
package com.xiaojuan.boot.web.controller;

import ...

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UserControllerTest {

    @Resource
    private TestRestTemplate restTemplate;

    @Test
    public void testUserModule() {
        assertNotNull(restTemplate);
    }

}

代码说明

这里我们在@SpringBootTest注解上指定了一个web环境的配置,这样就会让单元测试启动时启动一个web服务,端口号是随机的。

然后我们注入了用于连接和发送请求的测试级别的TestRestTemplate对象,作为我们将使用的http client测试工具。

**关于web层的单元测试特别需要强调的是,这里我们会启动一个内置的web服务,然后用RestTemplate工具来调用,这种多个请求进程的调用是无法确保一个单元测试执行后数据库的数据能回滚的。**因此这里为了保证测试的可重复和可持续性,我们采用之前dao单元测试最佳实践的方式,连接内置的h2数据库服务,在执行每个测试用例前先执行ddl和初始话的dml,再测试。我们将其他层的测试也都改成这种形式。

这样,为了让每个层都不写重复的代码,我们抽取一个TestBase基类放在test源码包下:

java 复制代码
package com.xiaojuan.boot;

import ...

@Transactional // 确保每个单元测试后数据会回滚,实现单元测试数据的隔离
@ActiveProfiles({"test"})
@SpringBootTest
public class TestBase {

    @Resource
    private DataSource dataSource;

    protected ScriptRunner runner;

    @PostConstruct
    public void initRunner() throws Exception {
        runner = new ScriptRunner(dataSource.getConnection());
        runner.setAutoCommit(true);
        runner.setStopOnError(true);
        runner.setLogWriter(null); // 不在控制台输出sql
    }

    @BeforeEach
    public void initDDL() throws IOException {
        runner.runScript(Resources.getResourceAsReader("db/schema.sql"));
        initDML();
    }
    
    // 由子类重写
    public void initDML() throws IOException {
        
    }

}

这样我们再来看其他层的测试类,比如CategoryMapperTest的调整:

java 复制代码
package com.xiaojuan.boot.dao.mapper;

import ...

public class CategoryMapperTest extends TestBase {

    @Override
    public void initDML() throws IOException {
        runner.runScript(Resources.getResourceAsReader("db/data.sql"));
    }

    ...
}

ok,这样我们的UserControllerTest也继承TestBase即可。

现在我们启动单元测试,可以看到控制台输出的信息,我们确实启动了一个web服务,端口为51626

第一个web测试用例

看下我们第一个测试用例:

java 复制代码
@Test
public void testUserModule() {
    // 先获取用户信息
    ResponseEntity<Response> resp = restTemplate.getForEntity("/user/profile", Response.class);
    // 断言服务器401状态
    assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    assertThat(resp.getBody().getErrCode()).isEqualTo(BusinessError.NO_LOGIN.getValue());
}

在web服务启动后,我们通过TestRestTemplate工具来发送一个get请求,尝试获取登录的用户信息,显然我们得到了401的反馈。

注意,这里TestRestTemplate在得到响应的json内容后,会用jackson框架将其反序列化为一个Response类型,而我们先前单纯的认为对其实例化的方式就是调用Response类的静态ok或者fail方法,显然这里是调用其无参构造,那我们得为其加一个无参构造,且之前修饰那些重载构造器为private访问级别也没意义了,都调整为public吧。

ok,跑完单元测试,一切如我们所预料的:

前人栽树,后人乘凉

在进一步编写web层单元测试涉及的测试场景前,我们先进一步做一个"栽树"的工作,为了一劳永逸,我们有必要对controller的测试类做进一步分装,让我们的调用变得更加的简单。为此,我们抽出一个WebTestBase基类:

java 复制代码
package com.xiaojuan.boot;

import ...

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebTestBase extends TestBase {

    @Resource
    private TestRestTemplate restTemplate;

    protected  <T> ResponseEntity<Response<T>> get(String url, Class<T> clz) {
        return get(url, clz, null);
    }
    protected <T> ResponseEntity<Response<T>> get(String url, Class<T> clz, Map<String, List<String>> params) {
        return get(url, clz, params, null);
    }
    protected <T> ResponseEntity<Response<T>> get(String url, Class<T> clz, Map<String, List<String>> params, Map<String, String> headerMap) {
        return exchange(url, HttpMethod.GET, clz, MediaType.APPLICATION_FORM_URLENCODED, transformParams(params), headerMap);
    }

    protected <T> ResponseEntity<Response<T>> postForm(String url, Class<T> clz, Map<String, List<String>> params) {
        return postForm(url, clz, params, null);
    }

    protected <T> ResponseEntity<Response<T>> postForm(String url, Class<T> clz, Map<String, List<String>> params, Map<String, String> headerMap) {
        return exchange(url, HttpMethod.POST, clz, MediaType.APPLICATION_FORM_URLENCODED, transformParams(params), headerMap);
    }

    private <T> ResponseEntity<Response<T>> exchange(String url, HttpMethod method, Class<T> clz, MediaType type, Object data, Map<String, String> headerMap) {
        if (type == null) {
            type = MediaType.APPLICATION_JSON;
        }
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(type);
        if (!ObjectUtils.isEmpty(headerMap)) {
            for (Map.Entry<String, String> entry : headerMap.entrySet()) {
                headers.add(entry.getKey(), entry.getValue());
            }
        }
        HttpEntity entity = new HttpEntity<>(data, headers);
        ParameterizedTypeImpl pt = ParameterizedTypeImpl.make(Response.class, new Type[]{clz}, Response.class.getDeclaringClass());
        return restTemplate.exchange(url, method, entity, ParameterizedTypeReference.forType(pt));
    }

    private Object transformParams(Map<String, List<String>> params) {
        MultiValueMap<String, String> paramsMap = null;
        if (!ObjectUtils.isEmpty(params)) {
            paramsMap = new LinkedMultiValueMap<>();
            for (Map.Entry<String, List<String>> entry : params.entrySet()) {
                paramsMap.put(entry.getKey(), entry.getValue());
            }
        }
        return paramsMap;
    }
}

该类提供了许多重载的发送请求方法,以便我们调用时能找到合适的类。这些方法也可以经过单元测试的验证是否设置正确,只不过目前我们只用到其中的部分。我们先确保接下来要编写的测试用例的调用是ok的,则说明上面的封装覆盖到的部分是没有问题的,这就是测试驱动开发(TDD)的开发方式,在这里小卷,极力倡导大家平时的开发也适当的使用这种模式。

完善其他测试用例

接下来我们就一鼓作气,完成其他的测试用例场景吧!这里小卷把测试的一套验证流程贴出来:

java 复制代码
package com.xiaojuan.boot.web.controller;

import ...

public class UserControllerTest extends WebTestBase {

    @SneakyThrows
    @Test
    public void testUserModule() {
        // 先获取用户信息
        ResponseEntity<Response<UserInfoDTO>> resp = get("/user/profile", UserInfoDTO.class);
        // 断言服务器401状态
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        assertThat(resp.getBody().getErrCode()).isEqualTo(BusinessError.NO_LOGIN.getValue());

        // 用户登录失败,没找到叫zhangsan的用户
        Map<String, List<String>> params = new HashMap<>();
        params.put("username", Collections.singletonList("zhangsan"));
        params.put("password", Collections.singletonList("12345"));
        resp = postForm("/user/login", UserInfoDTO.class, params);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
        assertThat(resp.getBody().getMsg()).isEqualTo(UserService.MSG_USERNAME_ERROR);

        // 注册一个zhangsan
        ResponseEntity<Response<Void>> resp2 = postForm("/user/register", Void.class, params);
        assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK);

        // 再次登录
        resp = postForm("/user/login", UserInfoDTO.class, params);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
        // 得到cookie
        String cookie = resp.getHeaders().get("Set-Cookie").get(0);

        // 再获取用户信息
        Map<String, String> headerMap = new HashMap<>();
        headerMap.put("Cookie", cookie);
        resp = get("/user/profile", UserInfoDTO.class, null, headerMap);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(resp.getBody().getData().getUsername()).isEqualTo("zhangsan");

        // 更新用户签名
        params.clear();
        params.put("signature", Collections.singletonList("每天进步一点点"));
        resp2 = postForm("/user/signature", Void.class, params, headerMap);
        assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK);

        // 再获取用户信息
        resp = get("/user/profile", UserInfoDTO.class, null, headerMap);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(resp.getBody().getData().getPersonalSignature()).isEqualTo("每天进步一点点");

        // 退出登录
        resp2 = postForm("/user/logout", Void.class, null, headerMap);
        assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK);

        // 未登录不能获取用户信息
        resp = get("/user/profile", UserInfoDTO.class, null, headerMap);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);

        // 用普通用户登录管理员平台
        params = new HashMap<>();
        params.put("username", Collections.singletonList("zhangsan"));
        params.put("password", Collections.singletonList("12345"));
        resp = postForm("/user/admin/login", UserInfoDTO.class, params);
        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
    }

}

很显然,比起写RestTemplate繁琐的调用,这里的代码调用简洁多了。

最后我们把所有的单元测试都跑一便,ok!都达到了预期,说明我们的用户模块的开发功能是卓有成效的,为自己点个大大的赞吧!

相关推荐
Passion不晚1 小时前
Spring Boot 入门:解锁 Spring 全家桶
spring boot·后端·spring
代码吐槽菌2 小时前
基于SpringBoot的在线点餐系统【附源码】
java·开发语言·spring boot·后端·mysql·计算机专业
AskHarries4 小时前
Spring Boot集成Akka Cluster快速入门Demo
java·spring boot·后端·akka
2402_857589364 小时前
Java免税商品购物商城:Spring Boot实现详解
java·开发语言·spring boot
少喝冰美式4 小时前
【大模型教程】如何在Spring Boot中无缝集成LangChain4j,玩转AI大模型!
人工智能·spring boot·后端·langchain·llm·ai大模型·计算机技术
.生产的驴5 小时前
SpringBoot 消息队列RabbitMQ 交换机模式 Fanout广播 Direct定向 Topic话题
spring boot·rabbitmq·java-rabbitmq
罗政5 小时前
[附源码]SpringBoot+VUE+Java实现人脸识别系统
java·vue.js·spring boot
超级小的大杯柠檬水5 小时前
IDEA中实现springboot热部署
java·spring boot·intellij-idea
ABin-阿斌6 小时前
SpringBoot 整合 Easy_Trans 实现翻译的具体介绍
java·spring boot·后端