背景

之前整理过一篇,基于(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或者其他的中间件都完全可以使用这样的方案来模拟了。

数据准备,这个问题我们设定了两种数据准备的方式。第一部分是在初始化数据库的时候,导入基础脚本,这部分的脚本包含结构和数据,是公用的内容所有的单元测试都需要依赖的基础数据,比如公司、部门、员工、角色、权限等等。第二部分是在单元测试单个类初始化时,引入数据脚本,这些数据仅仅是为了单个类/方法中的单元测试使用,运行完方法后会回滚,不会影响到其他单元测试的运行。

最后是单元测试的强度,主要还是一些规范,例如要求所有的单元测试都必须要有断言,并且断言的条件是要对数据内容字段进行合理验证的。可以参考一下这一篇写有价值的单元测试

所以最终落定的框架就是 Junit5 + Jmockit + TestContainer。

单元测试指导思想

在底层框架搭建之前,可以先讨论一下如何才能写出真正有价值的单元测试,而不是单纯为了绩效中的单元测试覆盖率?

之前一段中提到的写有价值的单元测试和阿里Java代码规约中有提到一些点

引用阿里规约:

  1. 【强制】好的单元测试必须遵守 AIR原则。

    说明:单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,

    却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。 A:Automatic(自动化) I:Independent(独立性) R:Repeatable(可重复)
  2. 【强制】单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执

    行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元

    测试中不准使用 System.out来进行人肉验证,必须使用 assert来验证。
  3. 【强制】保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间

    决不能互相调用,也不能依赖执行的先后次序。

    反例:method2需要依赖 method1的执行,将执行结果作为 method2的输入。
  4. 【强制】单元测试是可以重复执行的,不能受到外界环境的影响。

    说明:单元测试通常会被放到持续集成中,每次有代码 check in时单元测试都会被执行。如

    果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。

    正例:为了不受外界环境影响,要求设计代码时就把 SUT的依赖改成注入,在测试时用 spring

    这样的 DI框架注入一个本地(内存)实现或者 Mock实现。
  5. 【强制】对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级

    别,一般是方法级别。

    说明:只有测试粒度小才能在出错时尽快定位到出错位置。单测不负责检查跨类或者跨系统的

    交互逻辑,那是集成测试的领域。

其中有一些思想会决定我们在单元测试代码具体的实现方式。我们尝试了之后,根据上述的指导思想有两种不同的实现方式。

  • 单层隔离
  • 内部穿透

接下来我们就两种方式来进行说明。

单层隔离

正常代码分层会分为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.yml中,我们指定了ActiveProfile("test"),则会去加载vv-oa-test.properties文件,用于替换yml中的配置。

通过这样的方法来达到仅仅在单元测试中替换中间件连接的目的。

但是由于中间件的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封装

首先给大家提供官方网站和他们的Github代码示例库,很多用法都是参考官方的来的。本文以Mysql的容器作为样例给大家简单介绍一下使用。

官方方案

在官方文档中的数据库容器章节中,介绍了两种数据库容器的使用方式:

  • 代码中启动容器
  • 通过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%2b8");
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倍。而且在业务不稳定的时候,业务代码的维护同时还引发单元测试代码的修改,改代码的效率是写代码效率的一半,成本是很高的。

单元测试实践思考(junit5+jmockit+testcontainer)的更多相关文章

  1. 【转】.NET(C#):浅谈程序集清单资源和RESX资源 关于单元测试的思考--Asp.Net Core单元测试最佳实践 封装自己的dapper lambda扩展-设计篇 编写自己的dapper lambda扩展-使用篇 正确理解CAP定理 Quartz.NET的使用(附源码) 整理自己的.net工具库 GC的前世与今生 Visual Studio Package 插件开发之自动生

    [转].NET(C#):浅谈程序集清单资源和RESX资源   目录 程序集清单资源 RESX资源文件 使用ResourceReader和ResourceSet解析二进制资源文件 使用ResourceM ...

  2. 我的Spark SQL单元测试实践

    最近加入一个Spark项目,作为临时的开发人员协助进行开发工作.该项目中不存在测试的概念,开发人员按需求进行编码工作后,直接向生产系统部署,再由需求的提出者在生产系统检验程序运行结果的正确性.在这种原 ...

  3. atitit.jndi的架构与原理以及资源配置and单元测试实践

    atitit.jndi的架构与原理以及资源配置and单元测试实践 1. jndi架构 1 2. jndi实现原理 3 3. jndi资源配置 3 3.1. resin  <database> ...

  4. 单元测试实践(SpringCloud+Junit5+Mockito+DataMocker)

    网上看过一句话,单元测试就像早睡早起,每个人都说好,但是很少有人做到.从这么多年的项目经历亲身证明,是真的. 这次借着项目内实施单元测试的机会,记录实施的过程和一些总结经验. 项目情况 首先是背景,项 ...

  5. 关于单元测试的思考--Asp.Net Core单元测试最佳实践

    在我们码字过程中,单元测试是必不可少的.但在从业过程中,很多开发者却对单元测试望而却步.有些时候并不是不想写,而是常常会碰到下面这些问题,让开发者放下了码字的脚步: 这个类初始数据太麻烦,你看:new ...

  6. 使用NUnit为游戏项目编写高质量单元测试的思考

    0x00 单元测试Pro & Con 最近尝试在我参与的游戏项目中引入TDD(测试驱动开发)的开发模式,因此单元测试便变得十分必要.这篇博客就来聊一聊这段时间的感悟和想法.由于游戏开发和传统软 ...

  7. Android单元测试实践

    为什么要写单元测试 首先要介绍为什么蘑菇街支付金融这边会采用单元测试的实践.说起来比较巧,刚开始的时候,只是我一个人会写单元测试.后来老板们知道了,觉得这是件 很有价值的事情,于是就叫我负责我们组的单 ...

  8. 精彩分享 | 欢乐游戏 Istio 云原生服务网格三年实践思考

    作者 吴连火,腾讯游戏专家开发工程师,负责欢乐游戏大规模分布式服务器架构.有十余年微服务架构经验,擅长分布式系统领域,有丰富的高性能高可用实践经验,目前正带领团队完成云原生技术栈的全面转型. 导语 欢 ...

  9. PMP备考_第五章_项目范围管理_实践思考

    项目范围管理 前言 今天学习项目范围管理的内容,深切的感受到了原单位在项目管理方面存在的问题,今天在这里做一个总结,既相当于对项目范围的一个学习整理,也相当于自己对项目实践过程中存在问题的一个思考. ...

随机推荐

  1. 从Instagram“宁静、规则”的成功 看国内APP发展之路

    看国内APP发展之路" title="从Instagram"宁静.规则"的成功 看国内APP发展之路"> Instagram在全球获得的巨大成功 ...

  2. 【Android TimeCat】 解决cannot resolve symbol R

    莫名其妙出现了,鬼知道怎么来的. 解决方法总结 1. 推荐 解决90%的情况: Build->Clean ProjectBuild->Rebuild Project 2. 不常见 Andr ...

  3. Hello World!(这不是第一篇)

    如题,这不是第一篇blog,但是为了表示这个闲置了1年多的blog现在被我正式启用了,我还是走个过场吧. #include <iostream> using namespace std; ...

  4. Vue.observable()使用方法

    前言 随着组件的细化,就会遇到多组件状态共享的情况, Vuex当然可以解决这类问题,不过就像 Vuex官方文档所说的,如果应用不够大,为避免代码繁琐冗余,最好不要使用它,今天我们介绍的是 vue.js ...

  5. 再谈拍照,OPPO这次拿什么和iPhone7拼?

    ​一年一度的iPhone新机如期而至,双摄像头成为iPhone 7 Plus标配,尽管在这之前,双摄像头已有少数厂商在手机上装备,但苹果一出,市场必定全面跟进.无论各大厂商是否采用双摄像头,在手机拍照 ...

  6. Vue-API之全局配置

    API 全局配置 Vue.config 是一个对象,包含 Vue 的全局配置. 源码位置:util/config.js 搜索config 可以找到其源码地址,其中声明了config的类型和默认参数 下 ...

  7. python中if __name__ == '__main__'是什么?

    __name__和__main__认识 作用:一般用于测试程序的功能,if __name__ == '__main__':下面的代码会被执行,但当前.py文件被当做模块导入的时候,main下面的代码就 ...

  8. JUC常用同步工具类——CountDownLatch,CyclicBarrier,Semaphore

    在 JUC 下包含了一些常用的同步工具类,今天就来详细介绍一下,CountDownLatch,CyclicBarrier,Semaphore 的使用方法以及它们之间的区别. 一.CountDownLa ...

  9. Yuchuan_Linux_C编程之五gdb调试

    一.整体大纲 二.gdb调试 1. 启动gdb start -- 只执行一步    n -- next    s -- step(单步) -- 可以进入到函数体内部    c - continue - ...

  10. CyclicBarrier源码探究 (JDK 1.8)

    CyclicBarrier也叫回环栅栏,能够实现让一组线程运行到栅栏处并阻塞,等到所有线程都到达栅栏时再一起执行的功能."回环"意味着CyclicBarrier可以多次重复使用,相 ...