我们在开发项目的过程中遇到了复杂的业务需求,测试同学没有办法帮我们覆盖每一个场景;或者是我们自己在做代码功能升级、技改,而不是业务需求的时候,可能没有测试资源帮我们做测试,那这个时候就需要依靠自己的单元测试来保证产品的质量。
我们的工程一般分为接口层,业务层,仓储层;那每一个模块都需要我们用单元测试来覆盖。
仓储层:这一层,我们一般会连接到真实的数据库,完成仓储层的CRUD,我们可以连到开发库或者测试库,但是仅仅是单元测试就需要对我们资源占用,成本有点高,所以h2基于内存的数据库就很好的帮我们解决这些问题。
业务层:业务层的逻辑比较复杂,我们可以启动整个服务帮助测试,也可以使用mock来覆盖每一个分支,因为用mock的话不需要启动服务,专注我们的业务流程,更快也更方便。
接口层:一般接口层我们会用集成测试的较多,启动整个服务端到端的流程捋下来,采用BDD的思想,给什么入参,期望什么结果,写测试用例的时候只是专注于入参出参就行,测试代码不用做任何改变。
首先junit4和junit5都支持参数化的测试,但我用下来感觉到内置的这些功能不能够满足我的需求,所以我一般会自定义数据类型。
下面以一个controller接口为例完成集成测试:
采用springboot+mybatisplus完成基础功能,代码忽略,只贴一下controller和配置文件
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/query")
public UserDO query(String username) {
LambdaQueryWrapper<UserDO> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(UserDO::getUsername, username);
return userService.getOne(queryWrapper);
}
}
spring:
application:
name: fiat-exchange
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/maple?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=convertToNull
username: root
password: ''
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
首先我们上面说集成测试需要启动整个服务,DB采用h2的基于内存的数据库,同时需要初始化库与表
spring:
profiles:
active: test
application:
name: integration-testing-local-test
datasource:
# 测试用的内存数据库,模拟MSQL
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:test;mode=mysql
username: root
password: test
sql:
init:
schema-locations: classpath:schema.sql
data-locations: classpath:data.sql
mode: always
schema.sql
DROP TABLE `user` IF EXISTS;
CREATE TABLE `user` (
`username` varchar(64) COMMENT 'username',
`password` varchar(64) COMMENT 'password'
) ENGINE=InnoDB DEFAULT CHARSET = utf8 COMMENT='user';
data.sql
INSERT INTO `user` (`username`, `password`) VALUES ('maple', '123456');
INSERT INTO `user` (`username`, `password`) VALUES ('cc', '654321');
IntegrationTestingApplicationTests
package com.maple.integration.testing;
import com.maple.integration.testing.controller.UserController;
import com.maple.integration.testing.entity.UserDO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(classes = IntegrationTestingApplication.class)
class IntegrationTestingApplicationTests {
@Autowired
private UserController userController;
@Test
void contextLoads() {
UserDO userDO = userController.query("maple");
assertThat(userDO.getPassword()).isEqualTo("123456");
}
}
工程结构
单元测试可以跑通了,但是如果要加测试用例的话就需要再加代码加用例,不符合我们的要求,我们要求能有一个地方放入参出参就行,下面我们改造下。
1、BaseTestData
基类,任何测试bean都需要集成它
@Data
public abstract class BaseTestData {
private String testCaseName;
private Object[] expectedResult;
}
2、 UserDTOTestData
@Data
@EqualsAndHashCode(callSuper = true)
public class UserDTOTestData extends BaseTestData {
private String username;
}
3、JsonFileSource
@ParameterizedTest 使用junit5的参数化测试的主键,他内置了一些功能注解,比如:MethodSource、EnumSource、CsvFileSource等,我们参考内置的来自定义JsonFileSource,可以测试单个用例,也可以扫描文件路径测试批量用例
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@API(
status = API.Status.EXPERIMENTAL,
since = "5.0"
)
@ArgumentsSource(JsonFileArgumentsProvider.class)
public @interface JsonFileSource {
/**
* 文件路径:controller.userController.query/
* @return
*/
String directoryPath() default "";
/**
* 具体的文件路径:/controller.userController.query/validCase_QueryUser.json
* @return
*/
String[] resources() default "";
String encoding() default "UTF-8";
Class<?> typeClass();
}
4、JsonFileArgumentsProvider
package com.beet.fiat.config;
import com.alibaba.fastjson.JSON;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.ArgumentsProvider;
import org.junit.jupiter.params.support.AnnotationConsumer;
import org.junit.platform.commons.util.Preconditions;
import org.junit.platform.commons.util.StringUtils;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.function.BiFunction;
import java.util.stream.Stream;
import static java.nio.charset.StandardCharsets.UTF_8;
/**
* @author maple.wang
* @date 2022/11/17 16:24
*/
public class JsonFileArgumentsProvider implements ArgumentsProvider, AnnotationConsumer<JsonFileSource> {
private final BiFunction<Class<?>, String, InputStream> inputStreamProvider;
private String[] resources;
private String directoryPath;
private String encoding;
private Class<?> typeClass;
private static final ClassLoader CLASS_LOADER = JsonFileArgumentsProvider.class.getClassLoader();
public JsonFileArgumentsProvider() {
this(Class::getResourceAsStream);
}
public JsonFileArgumentsProvider(BiFunction<Class<?>, String, InputStream> inputStreamProvider) {
this.inputStreamProvider = inputStreamProvider;
}
@Override
public void accept(JsonFileSource jsonFileSource) {
this.directoryPath = jsonFileSource.directoryPath();
this.resources = jsonFileSource.resources();
this.encoding = jsonFileSource.encoding();
this.typeClass = jsonFileSource.typeClass();
}
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext extensionContext) throws Exception {
String displayName = extensionContext.getDisplayName();
System.out.println(displayName);
if(StringUtils.isNotBlank(directoryPath)){
List<String> resourcesFromDirectoryPath = getResources(directoryPath);
String[] resourcesArrayFromDirectoryPath = Optional.of(resourcesFromDirectoryPath).orElse(null).toArray(String[]::new);
if(Objects.nonNull(resourcesArrayFromDirectoryPath) && resourcesArrayFromDirectoryPath.length > 0){
resources = ArrayUtils.addAll(resourcesArrayFromDirectoryPath, resources);
}
}
return Arrays.stream(resources)
.filter(StringUtils::isNotBlank)
.map(resource -> openInputStream(extensionContext, resource))
.map(this::createObjectFromJson)
.map(str -> JSON.parseObject(str, typeClass))
.map(Arguments::of);
}
private List<String> getResources(String directoryPath) throws IOException{
List<String> testFileNames;
try (InputStream directoryStream = CLASS_LOADER.getResourceAsStream(directoryPath)) {
if (directoryStream == null) {
return List.of();
}
testFileNames = IOUtils.readLines(directoryStream, UTF_8);
}
// for each file found, parse into TestData
List<String> testCases = new ArrayList<>();
for (String fileName : testFileNames) {
Path path = Paths.get(directoryPath, fileName);
testCases.add("/" + path);
}
return testCases;
}
private String createObjectFromJson(InputStream inputStream) {
try {
return StreamUtils.copyToString(inputStream, Charset.forName(encoding));
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private InputStream openInputStream(ExtensionContext context, String resource) {
Preconditions.notBlank(resource, "Classpath resource [" + resource + "] must not be null or blank");
Class<?> testClass = context.getRequiredTestClass();
return Preconditions.notNull(inputStreamProvider.apply(testClass, resource),
() -> "Classpath resource [" + resource + "] does not exist");
}
}
IntegrationTestingApplicationTests 就变为了
@ParameterizedTest
@JsonFileSource(resources = "/controller.userController.query/validCase_QueryUser.json", typeClass = UserDTOTestData.class)
@DisplayName("query user")
void queryUser(UserDTOTestData testData) {
UserDO userDO = userController.query(testData.getUsername());
assertThat(userDO.getPassword()).isEqualTo(testData.getExpectedResult()[0]);
}
@ParameterizedTest
@JsonFileSource(directoryPath = "controller.userController.query/", typeClass = UserDTOTestData.class)
@DisplayName("query user")
void queryUsers(UserDTOTestData testData) {
UserDO userDO = userController.query(testData.getUsername());
assertThat(userDO.getPassword()).isEqualTo(testData.getExpectedResult()[0]);
}