一、需求分析
在单元测试UnitTest中经常有这样的需求,需要测试数据与开发数据、生产数据等区分开,同时避免不同测试用例之间的数据污染以及同一测试用例每次运行的数据不一致情况发生。
二、解决方案
内存数据库h2 可以满足我们的需求,但因为项目中采用的mysql,mysql和h2 在某些方面语法上会有些区别。所以这里直接采用了嵌入式mysql的方案。
嵌入式mysql在单元测试中可以解决上述的问题。
可以在每次测试类开始执行前,启动嵌入式mysql,在该测试类跑完后关闭。下一次测试类执行时,再重新开启。这样不需要依赖任何外置资源就可以解决数据污染的问题。
项目中使用的一款嵌入式mysql:https://github.com/wix/wix-embedded-mysql
核心代码如下:
import com.wix.mysql.EmbeddedMysql; import com.wix.mysql.ScriptResolver; import com.wix.mysql.config.Charset; import com.wix.mysql.config.MysqldConfig; import com.wix.mysql.distribution.Version; import java.util.concurrent.TimeUnit; /** * @Description: 嵌入式mysql服务,可用于单元测试,只存在运行于跑测试用例期间 * @Author: huminghao * @Date: 2019-12-12 */ public class EmbeddedMysqlConfig { /** * EmbeddedMysql实例 */ private EmbeddedMysql mysqlServer; public EmbeddedMysql getMysqlServer() { return mysqlServer; } /** * @Description: 启动Embedded Mysql * @Param: sqlFilePath 初始化sql脚本路径 * @Return: void * @Author: huminghao * @Date: 2019/12/27 */ public void startLocalMysql() { // mysql基础配置 MysqldConfig config = MysqldConfig.aMysqldConfig(Version.v5_7_18) // 数据库字符集 .withCharset(Charset.UTF8MB4) // 端口号3373 .withPort(3373) // .withUser("admin", "testdb") //use root with no password // .withTimeZone("Europe/Vilnius") // 连接超时时间 .withTimeout(2, TimeUnit.MINUTES) .withServerVariable("max_connect_errors", 666) .build(); // 启动一个EmbeddedMysql EmbeddedMysql mysql = EmbeddedMysql.anEmbeddedMysql(config) // 指定库名suson,初始化脚本为resources下的“init.sql” // .addSchema("suson", ScriptResolver.classPathScript("sql/init.sql")) .start(); mysqlServer = mysql; System.out.println("Mysql server started!"); // jvm销毁时,关闭mysqlServer,释放资源 Runtime.getRuntime().addShutdownHook( new Thread() { @Override public void run() { mysqlServer.stop(); System.out.println("Mysql server closed in hook!"); } } ); } /** * @Description: 停止Embedded Mysql * @Return: void * @Author: huminghao * @Date: 2019/12/12 */ public void stopMysql() { if (mysqlServer != null) { mysqlServer.stop(); System.out.println("Mysql server closed by stop!"); } } }
可配置启动端口,编码,账户,schema sql等等。
然后在单元测试中类加载开始处(junit @BeforeClass)调用startLocalMysql() 即可,在执行完后(junit @AfterClass)调用stopMysql() 即可。如下:
@ActiveProfiles("test") @SpringBootTest(classes = {InteractionTestApplication.class}) @RunWith(SpringRunner.class) public class CourseServiceTest { private static EmbeddedMysqlConfig embeddedMysqlConfig; /** * @Description: 在测试类类加载时,启动嵌入式mysql * @Return: void * @Author: huminghao * @Date: 2019/12/12 */ @BeforeClass public static void setUp() { embeddedMysqlConfig = new EmbeddedMysqlConfig(); embeddedMysqlConfig.startLocalMysql(); } /** * @Description: 在测试类所有用例跑后关闭嵌入式mysql * @Return: void * @Author: huminghao * @Date: 2019/12/12 */ @AfterClass public static void tearDown() { embeddedMysqlConfig.stopMysql(); } }
单元测试所需要的表结构数据,可以放到一个sql脚本中,sql脚本放到resources目录下即可(maven项目,如果是普通项目,保证在classpath下)
然后 ScriptResolver.classPathScript(“sql/init.sql”) 中可以指定你的脚本路径,相对classpath的相对路径。当然还可以指定库。这样我们的数据库就算创建完成,直接测试正常使用就可以。
三、技术分析及踩坑点
wix-embedded-mysql的大致逻辑:
当依赖了wix-embedded-mysql的包后,在初次启动使用时,会先去下载特定的安装包,然后进行本地MySQL的“安装”,安装完成后,就会正常启动,当下次再启动后,就无需下载了。(embedded-mysql可以理解为,帮我们省去手动配置安装mysql的过程,自动化处理)
安装包可以在你的系统用户目录下找到(windows:C:UsersAdministrator.embedmysql。我当前是Admin用户)。
如果你是maven工程,当mysql启动后,你会在target目录下找到关于mysql的资源文件。当结束后,文件被移除。
embedded-mysql会在单元测试jvm进程外重新新启一个进程,为mysql资源服务。当单元测试结束时,关闭mysql,关闭进程,然后关闭单元测试的jvm进程。
(踩坑点:如果你是在ide中直接run的test类,终止run,就是终止了jvm进程,那么按照代码中的写法,mysql进程也会关闭;如果是在maven test时候,test跑的过程中,mysql启动了,test没有结束之前,ctrl+c 结束maven test并没有结束所有进程,仅仅是结束了maven的进程,而mysql的进程仍在运行)
如果指定了schema加载的sql脚本,那么初次会创建表,在下次再次运行时,不会再去重新创建表,不会重新加载schema(估计除了系统用户目下有mysql安装包外和target下的资源,还有在别的地方存在历史数据,所以下次启动还会加载以往数据)。但这并不是我们想要的,就算我们每次测试用例跑完后,回滚了数据,但表相关的历史操作还在,如自增主键序列值不会回滚。我们希望测试用例每次跑,都是一个新的数据环境。
解决方案:
1、去除上面config代码中 加载schema的部分,换成spring jdbc帮我们进行初始化数据库。脚本文件不变,只需要将config的配置,换成spring配置。如下:
spring.datasource下有两个属性 schme、data,其中schema为表初始化语句,data为数据初始化,默认加载schema.sql与data.sql。脚本位置可以通过spring.datasource.schema 与spring.datasource.data 来改变。
spring.datasource.initialization-mode 初始化模式(springboot2.0),其中有三个值,always为始终执行初始化,embedded只初始化内存数据库(默认值),如h2等,never为不执行初始化。
spring jdbc会在init datasource时,会去执行初始化数据库的过程。配置了always,每次都会执行初始化,这样就可以达到每次测试用例执行,环境都是一样的,新的。
整个过程观察,发现数据库客户端工具中看不到 初始化创建的表,跟config 中 ScriptResolver.classPathScript(“sql/init.sql”) 的方式不一样,客户端工具可以看到创建的表。感觉整个配置成always后,spring jdbc的操作像是控制在一个大“事务”中,测试类程序跑完后,又回滚了,下次再重新初始化创建表。
对spring-jdbc 的处理过程感兴趣的,可以参考这里,有源码分析:https://blog.csdn.net/yinbucheng/article/details/80164395
2、上面第一种方式可以对于采用jdbc、mybatis、mybatis-plus的项目。如果是使用hibernate或者jpa的项目,直接如下配置即可:
spring: jpa: #启动时是否初始化数据库-hibernate generate-ddl: true hibernate: ddl-auto: create
generate-ddl: 为true时,执行schema创建,会检测classpath下的import.sql文件,当然spring.jpa.hibernate.ddl-auto: 必须为create/update/create-drop,none和validate是不行的。
当然,你如果采用的是jpa的话,直接引入jpa注解编码你的entity数据库实体类即可。不需要sql脚本,jpa会自动帮你创建表。
在上述两种方式任意一种完成后,都需要在标注该注解:@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
表明在每个测试方法跑完后,重新加载spring上下文,因为我们的数据库初始化是由spring完成的,所以可以保证每个方法都是独立互不干扰的数据库环境。
(上面这个model为class级别,当然也可以为method级别,只不过那样需要每个方法上进行标记)
发表评论