我们在开发项目的过程中遇到了复杂的业务需求,测试同学没有办法帮我们覆盖每一个场景;或者是我们自己在做代码功能升级、技改,而不是业务需求的时候,可能没有测试资源帮我们做测试,那这个时候就需要依靠自己的单元测试来保证产品的质量。
我们的工程一般分为接口层,业务层,仓储层;那每一个模块都需要我们用单元测试来覆盖。
仓储层:这一层,我们一般会连接到真实的数据库,完成仓储层的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 queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(UserDO::getUsername, username);return userService.getOne(queryWrapper);}
}
spring:application:name: fiat-exchangedatasource:type: com.zaxxer.hikari.HikariDataSourcedriver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/maple?useSSL=false&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=convertToNullusername: rootpassword: ''mybatis-plus:configuration:log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
首先我们上面说集成测试需要启动整个服务,DB采用h2的基于内存的数据库,同时需要初始化库与表
spring:profiles:active: testapplication:name: integration-testing-local-testdatasource:# 测试用的内存数据库,模拟MSQLdriver-class-name: org.h2.Driverurl: jdbc:h2:mem:test;mode=mysqlusername: rootpassword: testsql:init:schema-locations: classpath:schema.sqldata-locations: classpath:data.sqlmode: always
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';
INSERT INTO `user` (`username`, `password`) VALUES ('maple', '123456');
INSERT INTO `user` (`username`, `password`) VALUES ('cc', '654321');
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 {@Autowiredprivate UserController userController;@Testvoid contextLoads() {UserDO userDO = userController.query("maple");assertThat(userDO.getPassword()).isEqualTo("123456");}
}
工程结构
单元测试可以跑通了,但是如果要加测试用例的话就需要再加代码加用例,不符合我们的要求,我们要求能有一个地方放入参出参就行,下面我们改造下。
基类,任何测试bean都需要集成它
@Data
public abstract class BaseTestData {private String testCaseName;private Object[] expectedResult;
}
@Data
@EqualsAndHashCode(callSuper = true)
public class UserDTOTestData extends BaseTestData {private String username;
}
@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();
}
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 {private final BiFunction, 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, String, InputStream> inputStreamProvider) {this.inputStreamProvider = inputStreamProvider;}@Overridepublic void accept(JsonFileSource jsonFileSource) {this.directoryPath = jsonFileSource.directoryPath();this.resources = jsonFileSource.resources();this.encoding = jsonFileSource.encoding();this.typeClass = jsonFileSource.typeClass();}@Overridepublic Stream extends Arguments> provideArguments(ExtensionContext extensionContext) throws Exception {String displayName = extensionContext.getDisplayName();System.out.println(displayName);if(StringUtils.isNotBlank(directoryPath)){List 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 getResources(String directoryPath) throws IOException{List 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 TestDataList 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]);}