目录
背景
方案设计
单元测试指导思想
单层隔离
内部穿透
技术实现
依赖管理
基础架构
封装 Junit5&Jmockit
单元测试配置
TestContainer 封装
官方方案
实际方案
完整类代码
实现实例
总结
背景
之前整理过一篇, 基于 (SpringCloud+Junit5+Mockito+DataMocker) 的框架整理的单元测试. 当时的项目是一个编排层的服务项目, 所以没有涉及到数据库或者其他中间件的复杂问题. 而且是项目刚开始, 代码环境不复杂, 当时的架构基本上能够满足需求.
最近在一个较老项目, 现在希望加强项目的代码质量, 所以开始引入单元测试框架. 于是乎先按照原本的设计引入了 junit5 的整套框架, 同时引入了 h2 用于数据库模拟, 以及 rabbitmq 的 mock 服务. 这个项目使用的是 SpringCloud Alibaba 框架, 服务注册和配置管理使用 nacos, 其他没有太多特别的地方. 但是实际编写的过程中, 发现了一些问题:
Mock 框架使用了 Mockito 和 PowerMock, 开发人员需要同时使用两种框架.
H2 的数据库和实际的 MySQL 数据库相比还是有一些差异, 比如无法支持函数等情况.
单元测试的数据准备相对比较复杂, 如何能够很好的隔离不同单元测试的影响是个问题.
单元测试是为了覆盖率还是为了有强度的质量保证, 如何提高研发人员的单元测试质量.
方案设计
针对上述问题, 我们来一条一条解决.
首先是针对 Mock 框架, 考察之后认为可以选择 Jmockit 框架, 能够直接满足普通方法和静态方法, 但是语法相对不如 Mockito 自然, 学习曲线相对较高. 但最终还是决定尝试以统一框架来做, 降低架构的复杂度.
其次是数据库问题, 有两种方案, 一种是完善 H2 数据库, 可以用自定义的函数来支持缺失的特性, 但缺点也很明确, H2 始终不是真实的 MySQL 数据库. 第二种找到了 TestContainer 方案, 这是一个 Java 操作 Docker 的类库, 可以利用 Java 代码直接生成 Docker 的镜像与容器并且运行, 这样就有办法直接启动一个 MySQL 的容器用于单元测试, 结束后直接完全销毁. 这种方法的缺点在于环境问题, 所有需要运行单元测试的环境都需要安装 Docker 支持, 包含研发自己和 CI 环境. 但是好处在于一个通用的中间件模拟方案, 后续 Redis,MQ 或者其他的中间件都完全可以使用这样的方案来模拟了.
数据准备, 这个问题我们设定了两种数据准备的方式. 第一部分是在初始化数据库的时候, 导入基础脚本, 这部分的脚本包含结构和数据, 是公用的内容所有的单元测试都需要依赖的基础数据, 比如公司, 部门, 员工, 角色, 权限等等. 第二部分是在单元测试单个类初始化时, 引入数据脚本, 这些数据仅仅是为了单个类 / 方法中的单元测试使用, 运行完方法后会回滚, 不会影响到其他单元测试的运行.
最后是单元测试的强度, 主要还是一些规范, 例如要求所有的单元测试都必须要有断言, 并且断言的条件是要对数据内容字段进行合理验证的. 可以参考一下这一篇写有价值的单元测试 https://yq.aliyun.com/articles/54478 .
所以最终落定的框架就是 Junit5 + Jmockit + TestContainer.
单元测试指导思想
在底层框架搭建之前, 可以先讨论一下如何才能写出真正有价值的单元测试, 而不是单纯为了绩效中的单元测试覆盖率?
之前一段中提到的写有价值的单元测试 https://yq.aliyun.com/articles/54478 和阿里 Java 代码规约中有提到一些点
引用阿里规约:
[强制] 好的单元测试必须遵守 AIR 原则.
说明: 单元测试在线上运行时, 感觉像空气 (AIR) 一样并不存在, 但在测试质量的保障上,
却是非常关键的. 好的单元测试宏观上来说, 具有自动化, 独立性, 可重复执行的特点. A:Automatic(自动化) I:Independent(独立性) R:Repeatable(可重复)
[强制] 单元测试应该是全自动执行的, 并且非交互式的. 测试用例通常是被定期执行的, 执
行过程必须完全自动化才有意义. 输出结果需要人工检查的测试不是一个好的单元测试. 单元
测试中不准使用 System.out 来进行人肉验证, 必须使用 assert 来验证.
[强制] 保持单元测试的独立性. 为了保证单元测试稳定可靠且便于维护, 单元测试用例之间
决不能互相调用, 也不能依赖执行的先后次序.
反例: method2 需要依赖 method1 的执行, 将执行结果作为 method2 的输入.
[强制] 单元测试是可以重复执行的, 不能受到外界环境的影响.
说明: 单元测试通常会被放到持续集成中, 每次有代码 check in 时单元测试都会被执行. 如
果单测对外部环境 (网络, 服务, 中间件等) 有依赖, 容易导致持续集成机制的不可用.
正例: 为了不受外界环境影响, 要求设计代码时就把 SUT 的依赖改成注入, 在测试时用 spring
这样的 DI 框架注入一个本地 (内存) 实现或者 Mock 实现.
[强制] 对于单元测试, 要保证测试粒度足够小, 有助于精确定位问题. 单测粒度至多是类级
别, 一般是方法级别.
说明: 只有测试粒度小才能在出错时尽快定位到出错位置. 单测不负责检查跨类或者跨系统的
交互逻辑, 那是集成测试的领域.
其中有一些思想会决定我们在单元测试代码具体的实现方式. 我们尝试了之后, 根据上述的指导思想有两种不同的实现方式.
单层隔离
内部穿透
接下来我们就两种方式来进行说明.
单层隔离
正常代码分层会分为 controller,service,dao 等, 在单层隔离的思想中, 是针对每一层的代码做各自的单元测试, 不向下穿透. 这样的写法主要是保证单层的业务逻辑固化且正确.
实践过程中, 例如针对 controller 层编写的单元测试需要将对应 controller 类代码文件外部所有的调用全部 mock, 包括对应的内部 / 外部的 service. 其他层的代码也是如此.
这样做的优点:
单元测试代码极其轻量, 运行速度快. 由于只保证单个类内部的逻辑正确, 其他全部 mock, 所以可以放弃中间件的 mock, 甚至 Spring 的注入都可以放弃, 专注在单元测试逻辑验证的编写. 这样整套单元测试代码运行完成应该也是轮秒计时, 相对来讲 Spring 容器初始化完成可能都需要 20 秒.
真正符合了单元测试的原则, 可以在断网的情况下进行运行. 单层逻辑中可以屏蔽服务注册和配置管理, 各种中间件的影响.
单元测试质量更高. 针对单层逻辑的验证和断言能够更加清晰, 如果要覆盖多层, 可能会忽略丢失中间的各种验证环节, 如果加上可能条件规模是一个笛卡尔乘积过于复杂.
缺点也是存在:
单元测试的代码量比较大, 因为是针对每层单独编写单元测试, 而且需要 mock 掉的外部依赖也是比较多的.
学习曲线相对较高, 由于程序员的习惯针对单元测试是给定输入验证输出. 所以没有了底层的输出, 单纯验证过程逻辑要存在一个思维上的转变.
对于低复杂度的项目比较不友好. 如果你的项目大部分都是单纯的分层之后的 CRUD, 那单元测试其实可验证的东西不太多. 但是如果是代码当中执行了复杂逻辑, 这样的写法就能够起到比较好的质量保证.
在这个项目中, 最终没有采用这样的方法, 而是采用了穿透的方式. 项目的场景, 人员组成, 复杂度的实际情况, 我觉得用这种方式不算很合适.
内部穿透
穿透, 自然就是从顶层一直调用到底层. 为什么还要加上内部二字? 就是除了项目内的方法可以穿透, 项目外部依赖还是要 mock 掉的.
实践过程中, 就是单元测试针对 controller 层编写, 但是会完整调用 service,dao, 最终对落地结果进行验证.
优点:
代码量相对较小, 由于进行了穿透所以多层代码的覆盖仅需要从顶层的单元测试验证即可.
学习曲线低, 穿透的单元测试更偏向黑盒, 开发人员构造输入条件, 然后从落地结果中 (存储, 例如数据库) 验证预期结果.
缺点:
整体较重, 启动 Spring 容器, 中间件 mock, 整体单元测试运行预计需要是需要分钟级别. 所以基本是要在 CI 的时候来执行.
技术实现
敲定方案之后我们就可以进行技术实现了, 这是一个 Java 项目, 使用 Maven 进行依赖管理. 接下来我们主要分为三部分介绍:
依赖管理
基础架构
实现实例
依赖管理
依赖管理中第一个注意的点, 由于目前 Junit4 还占有较多的市场, 我们要尽量去排除掉一些测试相关的依赖中包含对与 4 的引用.
接下来我先贴出 Pom 文件中和单元测试相关的部分
- <!-- Jmockit -->
- <dependency>
- <groupId>org.jmockit</groupId>
- <artifactId>jmockit</artifactId>
- <version>1.49</version>
- <scope>test</scope>
- </dependency>
- <!-- junit5 框架 -->
- <dependency>
- <groupId>org.junit.jupiter</groupId>
- <artifactId>junit-jupiter</artifactId>
- <version>5.6.1</version>
- <scope>test</scope>
- </dependency>
- <!-- Spring Boot 测试框架 -->
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-test</artifactId>
- <scope>test</scope>
- <!-- exclude junit 4 -->
- <exclusions>
- <exclusion>
- <groupId>junit</groupId>
- <artifactId>junit</artifactId>
- </exclusion>
- </exclusions>
- </dependency>
- <!-- 公司内部封装的一个数据自动 Mock 框架, 来源于 Jmockdata -->
- <dependency>
- <groupId>cn.vv.service.unittest</groupId>
- <artifactId>vv-data-mocker</artifactId>
- <version>0.0.1-SNAPSHOT</version>
- <scope>test</scope>
- </dependency>
- <!-- testcontainers 对于 mysql 的封装包, 当然也可以将 mysql 替换为 testcontainers, 这样直接引入底层容器包 -->
- <dependency>
- <groupId>org.testcontainers</groupId>
- <artifactId>MySQL</artifactId>
- <version>1.12.0</version>
- <scope>test</scope>
- </dependency>
- <!-- testcontainers 容器对于 junit5 的支持 -->
- <dependency>
- <groupId>org.testcontainers</groupId>
- <artifactId>junit-jupiter</artifactId>
- <version>1.12.0</version>
- <scope>test</scope>
- </dependency>
依赖的引入基本就是这些了, 其中还需要注意的是 surefire 的插件配置
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-surefire-plugin</artifactId>
- <version>3.0.0-M4</version>
- <configuration>
- <argLine>-javaagent:${settings.localRepository}/org/jmockit/jmockit/1.49/jmockit-1.49.jar
- -Dfile.encoding=UTF-8 -Xmx1024m
- </argLine>
- <enableAssertions>true</enableAssertions>
- <!-- <useSystemClassLoader>true</useSystemClassLoader>-->
- </configuration>
- <dependencies>
- <dependency>
- <groupId>org.apache.maven.surefire</groupId>
- <artifactId>surefire-API</artifactId>
- <version>3.0.0-M4</version>
- </dependency>
- </dependencies>
- </plugin>
这里的注意点是 Jmockit 需要使用 javaagent 来初始化 JVM 参数.
基础架构
基础架构的部分, 我想分为三点来讲:
单元测试基类, 封装了一些项目使用的基础 Mock 对象和公用方法
单元测试配置相关
TestContainer 的封装
其实这三点都是与单元测试基类相关的, 分开讲各自的实现方式后, 最终会给出完整的代码.
封装 Junit5&Jmockit
首先是注解的部分 Junit4 到 5 注解有调整和变化, 而且我们的项目又是基于 SpringCloud 的, 所以最终的单元测试基类 BaseTest 使用了三个注解
- @SpringBootTest(classes = {
- OaApplication.class
- }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
- @Transactional
- @Slf4j
Junit5 的类头部是不需要什么注解的, 主要还是和 Spring 配合, 我们使用了 Boot Test 提供的 SpringBootTest 注解, 指定了入口的启动类, 为了包含配置文件, 获取 nacos 配置.
事务注解是为了让数据操作方法都能够回滚, 不影响其他单元测试.
最后就是 lombok 的日志注解.
接下来就是 BeforeAll,AfterAll,BeforeEach,AfterEach 几个注解.
这里的思路就是使用 Jmockit, 对待测试业务系统内底层机制进行统一的 Mock 处理, 例如 request 或者 session 中的头部信息. 我这里的代码可能和大家各自的项目中差异比较多, 只是提供一个思路. 利用 Jmockit 来 Mock 我们一些静态方法获取对象时, 直接返回我们设计的结果对象.
- @BeforeAll
- protected static void beforeAll() {
- new MockUp<ShiroUtils>(ShiroUtils.class) {
- @Mock
- public EmployeeVO getEmployee() {
- EmployeeVO employeeVO = new EmployeeVO();
- employeeVO.setUserName("mock.UserName");
- employeeVO.setUserNo("mock.UserNo");
- employeeVO.setCompanyName("mock.CompanyName");
- employeeVO.setDepartmentName("mock.DepartmentName");
- return employeeVO;
- }
- };
- new MockUp<LogAspect>(LogAspect.class) {
- @Mock
- public String getIp() {
- return "mock.ip";
- }
- };
- }
- @AfterAll
- protected static void destroy() {
- }
- @BeforeEach
- protected void beforeEach() {
- new MockUp<WebUtil>(WebUtil.class) {
- @Mock
- public HttpServletRequest getRequest() {
- return getRequest;
- }
- @Mock
- public VvCurrentAccount getCurrentAccount(Boolean isMustLogin) {
- VvCurrentAccount vvCurrentAccount = new VvCurrentAccount();
- vvCurrentAccount.setUserCode("mock.userCode");
- return vvCurrentAccount;
- }
- };
- new MockUp<ServletUtils>(ServletUtils.class) {
- @Mock
- public HttpServletRequest getRequest() {
- return getRequest;
- }
- };
- if (StringUtil.isNotBlank(this.getDbScript())) {
- try {
- ScriptRunner runner = new ScriptRunner(dataSource.getConnection());
- runner.setErrorLogWriter(null);
- runner.setLogWriter(null);
- runner.runScript(new FileReader(this.getClass().getResource(this.getDbScript()).getPath()));
- } catch (Exception e) {
- log.error("ScriptRunner error!", e);
- }
- }
- }
- @AfterEach
- protected void afterEach() {
- }
- protected String getDbScript() {
- return "";
- }
这里有一个设计点可以讨论一下, beforeEach 中调用了 getDbScript, 用于在单元测试方法前构建单个单元测试类中需要的数据. 而且由于类都继承了事务默认回滚, 所以本次操作完的数据在方法结束后都会回滚, 这样把数据的影响降到了最低.
每个单元测试类只要重写一下 getDbScript 方法, 提供自己的数据库脚本即可. 用这样的设计来进行单元测试方法级别的数据隔离.
单元测试配置
由于本项目的框架使用了 Nacos, 其地址和空间都是配置在 Pom 文件中, 在运行时指定 Profile 来调用不同环境的配置. 正常使用时, 中间件的访问地址, 用户密码等信息也是保存在 Nacos 上, 由于运行单元测试需要 Mock 真实中间件, 所以所有信息都需要替换.
第一个版本是使用了 Nacos 的本身特性, 在单元测试头部使用 @ActiveProfile("") 之后, 会读取对应配置文件的 properties 来替换占位符, 例如原本我们的配置是写在 vv-oa.YAML 中, 我们指定了 ActiveProfile("test"), 则会去加载 vv-oa-test.properties 文件, 用于替换 YAML 中的配置.
通过这样的方法来达到仅仅在单元测试中替换中间件连接的目的.
但是由于中间件的 Mock 方法使用了 TestContainer, 容器的地址实际上无法直接固定, 所以这个方案就不是很合适了. 就使用本地配置的形式(AutoConfiguration), 新建一个配置类放在单元测试的包中.
- @Configuration
- @EnableTransactionManagement
- public class JunitDataSource {
- @Bean
- public DataSource dataSource() throws Exception {
- Properties properties = new Properties();
- properties.setProperty("driverClassName", System.getProperty("spring.datasource.driver-class-name"));
- properties.setProperty("url", System.getProperty("spring.datasource.url"));
- properties.setProperty("username", System.getProperty("spring.datasource.username"));
- properties.setProperty("password", System.getProperty("spring.datasource.password"));
- return DruidDataSourceFactory.createDataSource(properties);
- }
- @Bean
- public PlatformTransactionManager transactionManager() throws Exception {
- return new DataSourceTransactionManager(dataSource());
- }
- }
其他中间件也使用相同的方式.
TestContainer 封装
首先给大家提供官方网站 https://www.testcontainers.org/ 和他们的 GitHub 代码示例库, 很多用法都是参考官方的来的. 本文以 MySQL 的容器作为样例给大家简单介绍一下使用.
官方方案
在官方文档中的数据库容器 https://www.testcontainers.org/modules/databases/ 章节中, 介绍了两种数据库容器的使用方式:
代码中启动容器
通过 JDBC url 启动容器
- @Rule
- public MySQLContainer MySQL = new MySQLContainer();
代码中启动就是这么简单, 一个最简单 MySQL 容器就启动了, 默认的配置信息如下:
- public static final String NAME = "mysql";
- public static final String IMAGE = "mysql";
- public static final String DEFAULT_TAG = "5.7.22";
- private static final String MY_CNF_CONFIG_OVERRIDE_PARAM_NAME = "TC_MY_CNF";
- public static final Integer MYSQL_PORT = 3306;
- private String databaseName = "test";
- private String username = "test";
- private String password = "test";
- private static final String MYSQL_ROOT_USER = "root";
接着在 BeforeAll 中调用 MySQL.start(), 容器便启动了.
JDBC 的方式更简单, 无需任何代码, 直接在配置中指定驱动和 url 即可
- spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver
- spring.datasource.url=jdbc:tc:MySQL:5.7.22:///databasename?TC_INITSCRIPT=file:src/main/resources/init_mysql.sql&TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction
这里要注意的几个点
驱动必须使用 tc 提供的
url 中 MySQL 之后跟的时版本号, 对应可以理解为是 dockerhub 中 MySQL 的镜像版本号其实也是 MySQL 的实际版本.
tc 提供了两种数据库初始话方式, 直接指定脚本 TC_INITSCRIPT , 或者指定代码初始化类 TC_INITFUNCTION , 这两种方法是可以同时存在的.
实际方案
项目中使用的时候上面两种使用方式实际都不是很好, MySQLContainer 经过了封装可定制的内容相对较少, JDBC 的方式也是同样的问题例如端口等配置都无法设定.
为了更加灵活, 我们使用了最原始的基础容器类来自己构建一个 MySQL 的容器. 先直接给出代码.
- @ClassRule
- public static GenericContainer MySQL = new VvFixedHostPortGenericContainer(
- new ImageFromDockerfile("mysql-vv-gms")
- .withDockerfileFromBuilder(dockerfileBuilder -> {
- dockerfileBuilder.from("mysql:8.0.0")
- .env("MYSQL_ROOT_PASSWORD", "test")
- .env("MYSQL_DATABASE", "test")
- .env("MYSQL_USER", "test")
- .env("MYSQL_PASSWORD", "test")
- .add("my.cnf", "/etc/mysql/conf.d")
- .add("db-schema.sql", "/docker-entrypoint-initdb.d")
- ;
- })
- .withFileFromClasspath("my.cnf", "my.cnf")
- .withFileFromClasspath("db-schema.sql", "db-schema.sql")
- )
- .withFixedExposedPort(3307, 3306)
- .waitingFor(Wait.forListeningPort());
- package cn.vv.oa.init;
- import lombok.NonNull;
- import org.testcontainers.containers.GenericContainer;
- import org.testcontainers.containers.InternetProtocol;
- import java.util.concurrent.Future;
- public class VvFixedHostPortGenericContainer<SELF extends VvFixedHostPortGenericContainer<SELF>> extends GenericContainer<SELF> {
- public VvFixedHostPortGenericContainer(@NonNull final Future<String> image) {
- super(image);
- }
- /**
- * Bind a fixed TCP port on the docker host to a container port
- *
- * @param hostPort a port on the docker host, which must be available
- * @param containerPort a port in the container
- * @return this container
- */
- public SELF withFixedExposedPort(int hostPort, int containerPort) {
- return withFixedExposedPort(hostPort, containerPort, InternetProtocol.TCP);
- }
- /**
- * Bind a fixed port on the docker host to a container port
- *
- * @param hostPort a port on the docker host, which must be available
- * @param containerPort a port in the container
- * @param protocol an internet protocol (tcp or udp)
- * @return this container
- */
- public SELF withFixedExposedPort(int hostPort, int containerPort, InternetProtocol protocol) {
- super.addFixedExposedPort(hostPort, containerPort, protocol);
- return self();
- }
- }
第二个 VvFixedHostPortGenericContainer 其实可以不用特别关注, 这个类仅仅是为了暴露出基础容器类的指定端口方法, 和通过构建 Dockerfile 生成镜像的构造函数. 关键还是看第一段声明 MySQL 容器的部分.
withDockerfileFromBuilder 这个方法, 实际上就是指定了 Dockerfile 的构造方法, 能够暴露出的方法都是 Dockerfile 能够编写的命令, 如果你了解 docker 这是很好的定制化方式. 其中 add 命令能够添加的文件, 是需要我们后面用 withFileFromClasspath 来映射的.
通过 withFixedExposedPort 方法来指定暴露端口, mysql8 之后会启动两个端口 3306 和 33060, 我们目前只需要 3306 暴露即可.
这里添加的两个文件也是需要了解一下.
my.cnf 文件是为了覆盖 MySQL 的默认配置, 能够解决数据库编码等底层设置问题, 要注意的是 add 命令添加的文件路径 /etc/MySQL/conf.d 这样才能初始化配置.
db-schem.sql 是初始化数据库脚本, 添加在容器中的 /docker-entrypoint-initdb.d 路径中就会自动执行, 不过注意脚本只能添加一个.
顺便吧 my.cnf 也贴上来吧, 可能会影响数据库的中文乱码
- [mysqld]
- user = MySQL
- datadir = /var/lib/MySQL
- port = 3306
- #socket = /tmp/MySQL.sock
- skip-external-locking
- key_buffer_size = 16K
- max_allowed_packet = 1M
- table_open_cache = 4
- sort_buffer_size = 64K
- read_buffer_size = 256K
- read_rnd_buffer_size = 256K
- net_buffer_length = 2K
- skip-host-cache
- skip-name-resolve
- character-set-server = utf8
- collation-server = utf8_general_ci
- # Don't listen on a TCP/IP port at all. This can be a security enhancement,
- # if all processes that need to connect to mysqld run on the same host.
- # All interaction with mysqld must be made via Unix sockets or named pipes.
- # Note that using this option without enabling named pipes on Windows
- # (using the "enable-named-pipe" option) will render mysqld useless!
- #
- #skip-networking
- #server-id = 1
- # Uncomment the following if you want to log updates
- #log-bin=MySQL-bin
- # binary logging format - mixed recommended
- #binlog_format=mixed
- # Causes updates to non-transactional engines using statement format to be
- # written directly to binary log. Before using this option make sure that
- # there are no dependencies between transactional and non-transactional
- # tables such as in the statement INSERT INTO t_myisam SELECT * FROM
- # t_innodb; otherwise, slaves may diverge from the master.
- #binlog_direct_non_transactional_updates=TRUE
- # Uncomment the following if you are using InnoDB tables
- innodb_data_file_path = ibdata1:10M:autoextend
- # You can set .._buffer_pool_size up to 50 - 80 %
- # of RAM but beware of setting memory usage too high
- innodb_buffer_pool_size = 16M
- #innodb_additional_mem_pool_size = 2M
- # Set .._log_file_size to 25 % of buffer pool size
- innodb_log_file_size = 5M
- innodb_log_buffer_size = 8M
- innodb_flush_log_at_trx_commit = 1
- innodb_lock_wait_timeout = 50
- [MySQL.server]
- default-character-set=utf8
- [mysql_safe]
- default-character-set=utf8
- [client]
- default-character-set=utf8
完整类代码
- package cn.vv.oa;
- import cn.vv.OaApplication;
- import cn.vv.fw.common.API.VvCurrentAccount;
- import cn.vv.fw.common.utils.StringUtil;
- import cn.vv.fw.common.utils.WebUtil;
- import cn.vv.oa.API.org.vo.EmployeeVO;
- import cn.vv.oa.common.aspectj.LogAspect;
- import cn.vv.oa.common.filter.TokenAuthorFilters;
- import cn.vv.oa.common.shiro.ShiroUtils;
- import cn.vv.oa.common.utils.ServletUtils;
- import cn.vv.oa.init.VvFixedHostPortGenericContainer;
- import lombok.extern.slf4j.Slf4j;
- import mockit.Mock;
- import mockit.MockUp;
- import mockit.Mocked;
- import org.apache.ibatis.jdbc.ScriptRunner;
- import org.apache.shiro.authz.aop.PermissionAnnotationHandler;
- import org.junit.ClassRule;
- import org.junit.jupiter.API.AfterAll;
- import org.junit.jupiter.API.AfterEach;
- import org.junit.jupiter.API.BeforeAll;
- import org.junit.jupiter.API.BeforeEach;
- import org.springframework.boot.test.context.SpringBootTest;
- import org.springframework.transaction.annotation.Transactional;
- import org.testcontainers.containers.GenericContainer;
- import org.testcontainers.containers.wait.strategy.Wait;
- import org.testcontainers.images.builder.ImageFromDockerfile;
- import javax.annotation.Resource;
- import javax.servlet.http.HttpServletRequest;
- import javax.sql.DataSource;
- import java.io.FileReader;
- @SpringBootTest(classes = {OaApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
- @Transactional
- @Slf4j
- public class BaseTest {
- @ClassRule
- public static GenericContainer MySQL = new VvFixedHostPortGenericContainer(
- new ImageFromDockerfile("mysql-vv-gms")
- .withDockerfileFromBuilder(dockerfileBuilder -> {
- dockerfileBuilder.from("mysql:8.0.0")
- .env("MYSQL_ROOT_PASSWORD", "test")
- .env("MYSQL_DATABASE", "test")
- .env("MYSQL_USER", "test")
- .env("MYSQL_PASSWORD", "test")
- .add("my.cnf", "/etc/mysql/conf.d")
- .add("db-schema.sql", "/docker-entrypoint-initdb.d")
- ;
- })
- .withFileFromClasspath("my.cnf", "my.cnf")
- .withFileFromClasspath("db-schema.sql", "db-schema.sql")
- )
- .withFixedExposedPort(3307, 3306)
- .waitingFor(Wait.forListeningPort());
- @Resource
- protected DataSource dataSource;
- @Mocked
- PermissionAnnotationHandler permissionAnnotationHandler;
- @Mocked
- cn.vv.fw.boot.logger.RequestLogAspect RequestLogAspect;
- @Mocked
- TokenAuthorFilters tokenAuthorFilters;
- @Mocked
- HttpServletRequest getRequest;
- @BeforeAll
- protected static void beforeAll() {
- MySQL.start();
- System.setProperty("spring.datasource.url", "jdbc:mysql://" + MySQL.getContainerIpAddress() + ":3307/test?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT+8");
- System.setProperty("spring.datasource.driver-class-name", "com.mysql.cj.jdbc.Driver");
- System.setProperty("spring.datasource.username", "test");
- System.setProperty("spring.datasource.password", "test");
- new MockUp<ShiroUtils>(ShiroUtils.class) {
- @Mock
- public EmployeeVO getEmployee() {
- EmployeeVO employeeVO = new EmployeeVO();
- employeeVO.setUserName("mock.UserName");
- employeeVO.setUserNo("mock.UserNo");
- employeeVO.setCompanyName("mock.CompanyName");
- employeeVO.setDepartmentName("mock.DepartmentName");
- return employeeVO;
- }
- };
- new MockUp<LogAspect>(LogAspect.class) {
- @Mock
- public String getIp() {
- return "mock.ip";
- }
- };
- }
- @AfterAll
- protected static void destroy() {
- MySQL.stop();
- }
- @BeforeEach
- protected void beforeEach() {
- new MockUp<WebUtil>(WebUtil.class) {
- @Mock
- public HttpServletRequest getRequest() {
- return getRequest;
- }
- @Mock
- public VvCurrentAccount getCurrentAccount(Boolean isMustLogin) {
- VvCurrentAccount vvCurrentAccount = new VvCurrentAccount();
- vvCurrentAccount.setUserCode("mock.userCode");
- return vvCurrentAccount;
- }
- };
- new MockUp<ServletUtils>(ServletUtils.class) {
- @Mock
- public HttpServletRequest getRequest() {
- return getRequest;
- }
- };
- if (StringUtil.isNotBlank(this.getDbScript())) {
- try {
- ScriptRunner runner = new ScriptRunner(dataSource.getConnection());
- runner.setErrorLogWriter(null);
- runner.setLogWriter(null);
- runner.runScript(new FileReader(this.getClass().getResource(this.getDbScript()).getPath()));
- } catch (Exception e) {
- log.error("ScriptRunner error!", e);
- }
- }
- }
- @AfterEach
- protected void afterEach() {
- }
- protected String getDbScript() {
- return "";
- }
- }
实现实例
以实际的公司的接口为例, 我们的单元测试入口从 Controller 方法进入.
- package cn.vv.oa.module.org.controller;
- import cn.vv.fw.common.API.R;
- import cn.vv.oa.BaseTest;
- import cn.vv.oa.API.org.dto.CompanyDTO;
- import cn.vv.oa.module.org.entity.Company;
- import cn.vv.oa.module.org.repository.mapper.CompanyMapper;
- import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
- import org.junit.jupiter.API.Test;
- import javax.annotation.Resource;
- import java.math.BigInteger;
- import java.time.LocalDate;
- import java.util.Arrays;
- import java.util.List;
- import java.util.Map;
- import static org.junit.jupiter.API.Assertions.assertEquals;
- public class CompanyControllerTest extends BaseTest {
- @Resource
- CompanyController companyController;
- @Resource
- CompanyMapper companyMapper;
- @Test
- public void getList() throws Exception {
- List dtos = companyController.getList("100", "").getData();
- assertEquals(((Map) (dtos.get(0))).get("companyName"), "VV 科技集团");
- }
- @Test
- void getAllList() {
- List<Company> list = companyMapper.selectList(new LambdaQueryWrapper<Company>());
- assertEquals(list.size(), 3);
- }
- @Test
- void saveOrUpdate() throws Exception {
- CompanyDTO companyDTO = CompanyDTO.builder()
- .companyName("VV 日本公司")
- .parentId(new BigInteger("100"))
- .companyEmail("vvadmin@vv.co.jp")
- .companyArea(Arrays.asList("Japan"))
- .regTime(LocalDate.now())
- .build();
- R r = companyController.saveOrUpdate(companyDTO);
- List<Company> list = companyMapper.selectList(new LambdaQueryWrapper<Company>());
- assertEquals(list.size(), 4);
- }
- }
这个单元测试会覆盖到 controller,service,dao 的各层代码. 可以看到由 Spring 负责注入的还是使用原本的方式.
这里要注意点在于单元测试的待测试方法调用后, 由于我们需要通过落地数据来验证, 所以还需要注入对应的 Mapper 直接对数据库进行搜索. 这点会有些绕或者不直接.
这是一个穿透的例子. 我们再来看一个隔离的例子.
- @Test
- void save() {
- R<AccountSimpleVO> r = new R<>();
- AccountSimpleVO accountSimpleVO = new AccountSimpleVO();
- accountSimpleVO.setUserCode("usercode");
- r.setCode(ResultCode.SUCCESS.getCode());
- r.setData(accountSimpleVO);
- new Expectations() {{
- userMapper.selectList((Wrapper<User>) any);
- result = null;
- userClient.getUserInfo((AccountDTO) any);
- result = null;
- userClient.registered((AccountDTO) any);
- result = r;
- companyMapper.selectOne((Wrapper<Company>) any);
- Company company = new Company();
- company.setCompanyArea("中国");
- result = company;
- }};
- new MockUp<DictUtil>(DictUtil.class) {
- @Mock
- public Map<String, DictDTO> getDictNameMap(String code) {
- Map<String, DictDTO> r1 = new HashMap<>();
- DictDTO dictDTO = new DictDTO();
- dictDTO.setRemark("30");
- r1.put("美国", dictDTO);
- return r1;
- }
- @Mock
- public Map<String, DictDTO> getDictMap(String code) {
- Map<String, DictDTO> r2 = new HashMap<>();
- DictDTO dictDTO = new DictDTO();
- dictDTO.setRemark("86");
- r2.put("中国", dictDTO);
- return r2;
- }
- };
- Assertions.assertThrows(NullPointerException.class, () -> {
- employeeService.save(new EmployeeDTO());
- });
- }
这个例子就是单独测试了一个 service 方法, 可以看到 mock 了很多内外部的服务, 包括底层的 mapper 都 mock 了, 意味着数据读取返回的内容都已经完全隔离了.
总结
单元测试, 大家都有共识是代码质量的最重要的手段之一, 但是我们需要真正 "有价值" 的单元测试. 有价值意味着真正维护了项目质量, 也能够让研发愿意真正花费精力来编写和维护测试用例. 如果公司只看单元测试覆盖率, 实际上也是很好糊弄的, 这就变成面子而没有价值. 研发去编写单元测试只是为了绩效, 高覆盖率, 没有为项目质量提升贡献力量.
如果正在看这篇文章的你是一个 Leader, 那你一定是要亲身上阵, 带领团队认真的实施, 引导团队真正理解单元测试的写法与价值.
我们的团队也还在进行尝试, 在我们的测试中, 产生有价值的单元测试, 代码量是实际业务代码的 2-3 倍. 而且在业务不稳定的时候, 业务代码的维护同时还引发单元测试代码的修改, 改代码的效率是写代码效率的一半, 成本是很高的.
来源: https://www.cnblogs.com/pluto4596/p/12610333.html