前面我们用idea提供的http client工具编写http请求脚本,并对用户模块的整个流程进行了手动的测试。但我们并不满足于此,毕竟现在实际开发的项目更多采用cicd的方式进行开发和部署,对开发人员来说,要保证代码质量的前提就是做好各层的单元测试。为此,这一节,我们对web层controller组件进行单元测试。
完善测试依赖
这里我们将lombok
依赖也加到测试环境,为我们的测试代码服务:
dependencies {
...
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.projectlombok:lombok'
...
}
完善单元测试骨架
先对UserController
完成web单元测试的骨架,代码如下:
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源码包下:
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
的调整:
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测试用例
看下我们第一个测试用例:
@Test
public void testUserModule() {
// 先获取用户信息
ResponseEntity 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
基类:
package com.xiaojuan.boot;
import ...
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class WebTestBase extends TestBase {
@Resource
private TestRestTemplate restTemplate;
protected ResponseEntity get(String url, Class clz) {
return get(url, clz, null);
}
protected ResponseEntity get(String url, Class clz, Map params) {
return get(url, clz, params, null);
}
protected ResponseEntity get(String url, Class clz, Map params, Map headerMap) {
return exchange(url, HttpMethod.GET, clz, MediaType.APPLICATION_FORM_URLENCODED, transformParams(params), headerMap);
}
protected ResponseEntity postForm(String url, Class clz, Map params) {
return postForm(url, clz, params, null);
}
protected ResponseEntity postForm(String url, Class clz, Map params, Map headerMap) {
return exchange(url, HttpMethod.POST, clz, MediaType.APPLICATION_FORM_URLENCODED, transformParams(params), headerMap);
}
private ResponseEntity exchange(String url, HttpMethod method, Class clz, MediaType type, Object data, Map headerMap) {
if (type == null) {
type = MediaType.APPLICATION_JSON;
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(type);
if (!ObjectUtils.isEmpty(headerMap)) {
for (Map.Entry 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 params) {
MultiValueMap paramsMap = null;
if (!ObjectUtils.isEmpty(params)) {
paramsMap = new LinkedMultiValueMap();
for (Map.Entry entry : params.entrySet()) {
paramsMap.put(entry.getKey(), entry.getValue());
}
}
return paramsMap;
}
}
该类提供了许多重载的发送请求方法,以便我们调用时能找到合适的类。这些方法也可以经过单元测试的验证是否设置正确,只不过目前我们只用到其中的部分。我们先确保接下来要编写的测试用例的调用是ok的,则说明上面的封装覆盖到的部分是没有问题的,这就是测试驱动开发(TDD)的开发方式,在这里小卷,极力倡导大家平时的开发也适当的使用这种模式。
完善其他测试用例
接下来我们就一鼓作气,完成其他的测试用例场景吧!这里小卷把测试的一套验证流程贴出来:
package com.xiaojuan.boot.web.controller;
import ...
public class UserControllerTest extends WebTestBase {
@SneakyThrows
@Test
public void testUserModule() {
// 先获取用户信息
ResponseEntity resp = get("/user/profile", UserInfoDTO.class);
// 断言服务器401状态
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(resp.getBody().getErrCode()).isEqualTo(BusinessError.NO_LOGIN.getValue());
// 用户登录失败,没找到叫zhangsan的用户
Map 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 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 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!都达到了预期,说明我们的用户模块的开发功能是卓有成效的,为自己点个大大的赞吧!