单元测试是什么?
首先要明确单元测试的一些基本原则,优秀的单元测试具有以下特点:
- 自动的、可重复的
- 容易实现
- 一旦写好,将来都可使用
- 任何人都可运行
- 单击一个按钮就可运行
- 可以快速地运行
单元测试并非是随手写来验证功能的临时代码,而是需要符合 AIR 原则,所以编写起来是需要一定的功力的。
关于 AIR 原则在阿里的 Java 规范中有提及,其他相关的规范也值得学习,我在这里引用方便读者朋友查看,完整的规范可在 Github 中查看,地址:p3c 单元测试。
- 【强制】好的单元测试必须遵守AIR原则。
说明:单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。
- A:Automatic(自动化)
- I:Independent(独立性)
- R:Repeatable(可重复)
-
【强制】单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用System.out来进行人肉验证,必须使用assert来验证。
-
【强制】保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
反例:method2需要依赖method1的执行,将执行结果作为method2的输入。
- 【强制】单元测试是可以重复执行的,不能受到外界环境的影响。
说明:单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
正例:为了不受外界环境影响,要求设计代码时就把SUT的依赖改成注入,在测试时用spring 这样的DI框架注入一个本地(内存)实现或者Mock实现。
- 【强制】对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级别,一般是方法级别。
说明:只有测试粒度小才能在出错时尽快定位到出错位置。单测不负责检查跨类或者跨系统的交互逻辑,那是集成测试的领域。
- 【强制】核心业务、核心应用、核心模块的增量代码确保单元测试通过。
说明:新增代码及时补充单元测试,如果新增代码影响了原有单元测试,请及时修正。
明确这些基本规范后,我们来开始动手编写单元测试。
完善的单元测试,应该对项目的每一层级代码都进行测试,本篇文章我们从 DAO 层开始。
DAO 层单元测试
DAO 层由于依赖数据库,是比较难以测试的,这个章节将为你提供一些 DAO 层的测试方法。
以 MySQL 数据库为例,在运行 DAO 层的单元测试时,我们不能依赖外部的数据库,因为这会破坏 AIR 原则中的 I(独立性) 原则,要解除依赖有几种方式:
- 使用嵌入式数据库:h2、moby 等。
- 使用 Testcontainers 在 docker 中创建专为单元测试使用的数据库,执行完即销毁。
使用嵌入式数据库
我们先看第一种方式,以 h2 为例。
首先,在单元测试的应用配置 application.yml
中修改数据源为 h2。
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:ufo
复制代码
如果我们使用的是 JPA 进行数据持久化,配置这些即可,JPA 会在 h2 数据库中自动创建数据库。
若使用的是 MyBatis,则需要指定数据库的架构,增加以下配置,data
为数据初始化脚本。
spring:
datasource:
schema: classpath:db/schema-h2.sql
data: classpath:db/data-h2.sql
复制代码
假如我们的项目非常小,用这种方式就足够,但当数据库 schema
越来越庞大时,维护 sql 将变成一项耗时的工作,那么我们需要引入数据库的版本控制机制。
数据库版本控制
数据库版本控制可以使用 flyway
或者 liquibase
,关于 liquibase 的用法,可以参考我写的 5 分钟搞定 liquibase 数据库版本控制 。
以 liquibase 为例,引入后,我们在 application.yml
进行如下类似修改,单元测试运行时将首先通过 liquibase 初始化数据库。
spring:
liquibase:
change-log: classpath:liquibase/master.xml
contexts: unit_test
enabled: true
复制代码
注意,此时我们编写的版本控制 sql 是为 MySQL 数据库准备的,若直接用于 h2 数据库,大概率会出现兼容性问题,此时我们有以下几个选择:
- 将用于 MySQL 的 sql 脚本稍作修改,用于 h2 数据库初始化。
- 使用 liquibase 的 xml 语法来定义数据库结构,减少兼容性问题。
- 改用
Testcontainers
辅助测试。
前面两个选择,都需要增加不小的工作量,我们来看看使用 Testcontainers 会如何。
使用 Testcontainers
Testcontainers 是一个支持 JUnit
测试的开源库,可以利用 Docker
容器获得 即用即丢
数据库的能力。
我们跳过了别的选项,直接选择 Testcontainers,因为使用嵌入式数据库还有一些缺点,比如 DAO 层使用了特定数据库才有的语法,那么单元测试根本都无法通过,这在企业级项目中几乎是无法避免的,除非是特别独立单一的服务。
DAO 层单元测试不应该依赖外部数据库,但在执行时也应当使用类生产的环境,这样的测试结果才更准确的。
使用 Testcontainers 并 不符合 AIR 原则
,因为其依赖了 Docker 环境,如果在一台没有 Docker 环境的机器上运行,那么测试就会失败,另外它也不符合可以快速地运行这一点,毕竟初始化 Docker 容器是需要一定的时间的。
我们看下如何在代码中使用 Testcontainers 。
首先定义一个抽象的测试基类,具体的单元测试继承自该类,以下写法可以确保数据库只初始化一次。
@SpringBootTest
@ContextConfiguration(initializers = AbstractUnitTest.DockerMySQLDataSourceInitializer.class)
public abstract class AbstractUnitTest {
private static final MySQLContainer<?> mysql;
static {
mysql = new MySQLContainer<>("mysql:8.0.11")
.withDatabaseName("dbname");
mysql.start();
}
public static class DockerMySQLDataSourceInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(@NotNull ConfigurableApplicationContext applicationContext) {
TestPropertySourceUtils.addInlinedPropertiesToEnvironment(
applicationContext,
"spring.datasource.url=" + mysql.getJdbcUrl(),
"spring.datasource.username=" + mysql.getUsername(),
"spring.datasource.password=" + mysql.getPassword(),
"spring.datasource.driver-class-name=" + mysql.getDriverClassName()
);
}
}
}
复制代码
准备就绪后,在单元测试执行时将首先创建 Docker 容器,然后运行 liquibase 初始化数据库。
来看一个简单的 DAO 层测试的例子。
@SpringBootTest
@RunWith(SpringRunner.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class NavSiteRepositoryTest extends AbstractUnitTest {
@Autowired
NavSiteRepository navSiteRepository;
@BeforeClass
void setUp() {
NavSite navSite = new NavSite();
navSite.setSiteName("");
navSite.setSiteUrl("");
navSite.setIconPath("");
navSite.setSiteType(0);
navSite.setSort(0);
navSite.setCreateTime(new Date());
navSite.setUpdateTime(new Date());
navSiteRepository.save(navSite);
}
@Test
void findBySiteType_UnknownSiteType_ZeroSize() {
List<NavSite> navSites = navSiteRepository.findBySiteType(0);
assertThat(navSites.size()).isEqualTo(1);
}
@Test
void findBySiteType() {
List<NavSite> navSites = navSiteRepository.findBySiteType(0);
assertThat(navSites.size()).isEqualTo(1);
}
}
复制代码
至此,我们就实现了 JUnit5 + Testcontainers + Liquibase
进行 DAO 层测试。
方案取舍
应该在使用嵌入式数据库、Docker和专用测试数据库之间进行取舍,目前的持续集成环境中基本都有 Docker 环境,相信 Testcontainers 也会越来越流行,就算不用在单元测试中,在 集成测试
中进行使用也是非常有用的一大利器。
总结如下:
- 在简单的项目中使用 h2 或其他嵌入式数据库进行 DAO 层测试。
- 在特定的场景下选择 Testcontainers,可以设法令其符合 AIR 原则并能快速运行,比如在 liquibase 项目中用于测试版本控制 sql 就是个不错的选择。
- 集成测试中可尽管选用 Testcontainers 。