Kotlin、JUnit5、Database Rider数据库动态测试实践

时间:2025-11-04 00:11:41来源:极客码头作者:系统运维
来龙去脉

因为项目组一些应用系统需要将Oracle数据库更换为国产分布式数据库,数据试实特地基于Kotlin、库动Junit5、态测Database Rider等开发了一套可配置的数据试实SQL测试工具,以在规模性测试之前,库动快速了解目标数据库对当前应用中各种SQL的态测兼容程度和默认行为,从而尽快对目标数据库进行评测,数据试实并据此在项目初期对迁移改造工作量有更准确的库动估计。

由于项目基于Kotlin语言,态测又用到了JUnit5动态测试特性,数据试实以及Database Rider数据库测试框架,库动特此作一分享,态测方便需要的数据试实同学围观和参考。

当然,库动数据库厂商或行业肯定有更为专业的态测工具对数据库的SQL逻辑和兼容性进行测试,至少有SQLsmith、Sqllogictest、SQLancer等,不在本文讨论范围之内,感兴趣的同学可以自行了解,也请专家老师多多批评指正。

JUnit5简介

JUnit 5是JUnit的下一代,于2017年9月首次GA,目标是为JVM上的云南idc服务商开发测试建立一个新的基础,包括专注于Java 8及更高版本,以及启用许多不同风格的测试。

JUnit5提供了名为JUnit Platform的可运行不同测试引擎的平台,以及JUnit Jupiter引擎供编写和运行JUnit5风格的测试,还有用于运行JUnit4、JUnit3测试的JUnit Vintage引擎。

JUnit5架构

JUnit5除了提供更为强大的断言工具之外,还提供了嵌套测试、重复测试、参数化测试、模板测试和动态测试等很多强大的测试编写工具,非常的方便。

Database Rider简介

Database Rider形象图

Database Rider是基于数据库进行测试的新工具,旨在使DBUnit更接近您的JUnit测试,让数据库测试更为简单易用。相较于已经不再维护的DbUnit,接棒的Database Rider在测试配置、测试数据集定义、b2b供应网多数据库支持、JUnit5支持、Cucumber支持等很多方面进行了增强。

以下是Database Rider文档中的一个示例:

复制@RunWith(JUnit4.class

)

public class DatabaseRiderCoreTest

{

@Rule public EntityManagerProvider emProvider = EntityManagerProvider.instance("riderDB"

);

@Rule public DBUnitRule dbUnitRule = DBUnitRule.instance(emProvider.connection

());

@Test @DataSet({"users.yml","empty-tweets.yml"

})

public void shouldListUsers

() {

List<User> users = em

().

createQuery("select u from User u"

).

getResultList

();

assertThat(users

).

isNotNull

().

isNotEmpty

().

hasSize(2

);

}

}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22. 概览

好了,对相关工具有了简单的了解之后,下面就开始进入正题吧。

首先,我们先看一下最终的运行效果。

Html测试报告

以上就是目前运行完成后生成的Html格式的报告。可以看到目前共有48个测试案例,都运行通过了,耗费17s多。有一点遗憾是,表格中的Method name列显示的内容和预想的不同,暂时还没有找到解决的方法。

下面是在IntellJ IDEA中显示的运行结果。同样地,容器级别未能按照动态测试代码指定的DisplayName去显示。香港云服务器

IntellJ IDEA运行效果

为了实现该测试,使用了如下方式:

设置依赖

首先,我们在build.gradle.kts文件中添加了对Junit5、Database Rider、数据库驱动、Yaml解析、Jexl表达式等的依赖。

复制dependencies

{

testImplementation(kotlin("test"

))

//junit5 testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.0-M1"

)

testImplementation("org.junit.jupiter:junit-jupiter-params:5.9.0-M1"

)

//database unit test testImplementation("com.oracle.database.jdbc:ojdbc6:11.2.0.4"

)

testImplementation("mysql:mysql-connector-java:8.0.29"

)

testImplementation("com.github.database-rider:rider-core:1.32.3"

)

testImplementation("com.github.dbunit-rules:core:0.15.1"

)

testImplementation("com.github.dbunit-rules:junit5:0.15.1"

)

testImplementation("org.hibernate:hibernate-core:5.6.9.Final"

)

testImplementation("org.hibernate:hibernate-entitymanager:5.6.9.Final"

)

//Yaml parse testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.3"

)

testImplementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.13.3"

)

// jexl parse testImplementation("org.apache.commons:commons-jexl3:3.2.1"

)

}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21. 数据库连接和初始化配置

数据库的连接和初始化使用了JPA持久化配置,这是是Database Rider所要求的。

复制<?xml version="1.0" encoding="UTF-8"?><persistence version="2.0" xmlns="http://java.sun.com/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"> <persistence-unit name="oracleDb1" transaction-type="RESOURCE_LOCAL"> <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider> <properties> <property name="hibernate.dialect" value="org.hibernate.dialect.Oracle10gDialect" /> <property name="javax.persistence.jdbc.driver" value="oracle.jdbc.driver.OracleDriver" /> <property name="javax.persistence.jdbc.url" value="jdbc:oracle:thin:@your_host:1521:your_dbname" /> <property name="javax.persistence.jdbc.user" value="your_usernbame" /> <property name="javax.persistence.jdbc.password" value="your_password" /> <property name="hibernate.hbm2ddl.auto" value="create-drop" /> <property name="hibernate.hbm2ddl.charset_name" value="utf-8"/> <property name="hibernate.hbm2ddl.halt_on_error" value="false"/> <property name="hibernate.hbm2ddl.delimiter" value=";"/> <property name="hibernate.show_sql" value="true" /> <property name="javax.persistence.schema-generation.database.action" value="@javax.persistence.schema_generation.database.action@"/> <property name="javax.persistence.schema-generation.create-source" value="script"/> <property name="javax.persistence.schema-generation.create-script-source" value="META-INF/sql/oracleDb1/ot_schema.sql"/> <property name="javax.persistence.schema-generation.drop-source" value="script"/> <property name="javax.persistence.schema-generation.drop-script-source" value="META-INF/sql/oracleDb1/ot_drop.sql"/> <property name="javax.persistence.sql-load-script-source" value="META-INF/sql/oracleDb1/ot_data.sql"/> </properties> </persistence-unit></persistence>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.

@

javax.persistence.schema_generation.database.action@是一个占位符,利用了Gradle编译的替换机制,可以根据运行需要决定是否重新初始化数据库。

数据库结构和数据初始化脚本文件参考如下,来自某个Oracle教程的示例数据库,稍微作了一些改造。

需要注意的是,这里的每个SQL语句只能占用一个文本行,否则JPA运行时会报错。

复制-- disable FK constraintsALTER TABLE countries DISABLE CONSTRAINT fk_countries_regions

;

ALTER TABLE locations DISABLE CONSTRAINT fk_locations_countries

;

ALTER TABLE warehouses DISABLE CONSTRAINT fk_warehouses_locations

;

ALTER TABLE employees DISABLE CONSTRAINT fk_employees_manager

;

ALTER TABLE products DISABLE CONSTRAINT fk_products_categories

;

ALTER TABLE contacts DISABLE CONSTRAINT fk_contacts_customers

;

---------------------------------------------------------- OT-------------------------------------------------------- -- REM INSERTING into REGIONS--SET DEFINE OFF

;

Insert into REGIONS (REGION_ID,REGION_NAME) values (1,Europe

);

Insert into REGIONS (REGION_ID,REGION_NAME) values (2,Americas

);

Insert into REGIONS (REGION_ID,REGION_NAME) values (3,Asia

);

Insert into REGIONS (REGION_ID,REGION_NAME) values (4,Middle East and Africa

);

-- REM INSERTING into COUNTRIES--SET DEFINE OFF

;

Insert into COUNTRIES (COUNTRY_ID,COUNTRY_NAME,REGION_ID) values (AR,Argentina,2

);

Insert into COUNTRIES (COUNTRY_ID,COUNTRY_NAME,REGION_ID) values (AU,Australia,3);1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21. 测试案例、测试容器对象

为了方便组织测试,使用了测试容器和测试案例两个概念,允许有多个测试案例配置文件,每个配置文件反序列化为一个测试容器,文件里的每个配置项反序列化为一个测试。

下面分别是测试和容器的数据类定义:

复制data class ConfigurableTest

(

val name: String

,

val description: String?

,

val dbName: String?

,

val setup: Iterable<String>?

,

val testSql: String

,

/** * 先于assert执行,打印这些jxel表达式,用于debug */ val debugs: Iterable<String>?

,

val asserts: Iterable<String>?

,

var teardown: Iterable<String>?

) {

}

data class ConfigurableTestContainer

(

val name: String

,

val dbName: String?

,

val tests: List<ConfigurableTest>)1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21. 测试案例配置

测试案例使用Yaml文件来配置,下面是一个示例:

复制name: "可配置测试样例"dbName: "oracleDb1"tests

:

- name: "简单sql和断言演示" description: "" dbName: "" setup

: []

testSql: "select 1 from dual" debugs

:

- result[0] - result.0 - result.200 asserts: [ true, result[0] == "1"

]

teardown: []1.2.3.4.5.6.7.8.9.10.11.12.13.14. 可以看到,两级对象上都有dbName属性,方便内层使用不同于外层的数据库,对应到JPA持久化配置里persistence-unit的name。testSql为测试要运行的核心SQL,DDL、DML均被支持。asserts可以为多个JXEL表达式(值为Boolean型),用于对testSql运行后返回的result列表对象进行断言。setup可以配置多个SQL,用于在运行testSql前做一些初始化工作。teardown也支持多条SQL,用于在测试结束后的清理。debugs同样可以配置多个JEXL表达式,运行过程中将打印其结果到控制台,用于debug。

以下是其中一个测试集(用于测试各种查询语法)的配置的片段:

复制name: "查询语法测试"dbName: "oracleDb1"tests

:

- name: "测试!=消除null" testSql: |- SELECT t.STATE FROM locations t WHERE t.STATE != BE asserts: [ result.size() == 16

]

- name: "测试=消除null" testSql: |- SELECT t.STATE FROM locations t WHERE t.STATE = null asserts: [ result.size() == 0

]

- name: "测试<>运算符" testSql: |- SELECT t.STATE FROM locations t WHERE t.STATE <> BE asserts: [ result.size() == 16

]

- name: "测试类型隐式转换" testSql: |- SELECT t.POSTAL_CODE FROM locations t WHERE t.location_id = 1 asserts: [ result.0.equals("00989")

]

- name: "测试||拼接字符串" testSql: |- SELECT t.COUNTRY_ID || . || t.CITY FROM locations t WHERE t.LOCATION_ID = 1 asserts: [ result.0.equals("IT.Roma") ]1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.

目前共使用了4个配置文件,分别用于测试DDL、字典表相关查询、函数相关查询和各种SELECT语句:

复制test-ddl.ymltest-dict.ymltest-function.ymltest-select.yml1.2.3.4. 测试配置的反序列化

以下是测试配置的反序列化代码,将某个资源目录下所有yml扩展名的文件最终构造为MutableMap。

由于对Kotlin不是很熟悉,代码有些啰嗦,还可以简化一下。

返回文件路径是为了利用Junit5的Test Resource特性,让IDE可以将测试对应到代码或资源,但实际没有生效,未作深入研究。

复制private const val DEFAULT_TEST_CONFIG_PATH = "/tests/"private fun getTestSuits(): MutableMap<Path, ConfigurableTestContainer>

{

val mapper = ObjectMapper(YAMLFactory

())

mapper.findAndRegisterModules

()

val resourcePath = Path.of(javaClass.getResource(DEFAULT_TEST_CONFIG_PATH).toURI

())

return Files.walk(resourcePath, 1

)

.filter { path -> path.name.endsWith(".yml", true

)

}

.map { path -> path to mapper.readValue<ConfigurableTestContainer>(path.toFile

())

}.toList

()

.associate

{

Pair(it.first, it.second

)

}

.toMutableMap

()

}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20. 动态测试

接下来就进入重头戏环节了,首先定义了一个名为SqlDynamicTest的类,用于接受测试配置,再返回动态测试框架所需要的对象。

入参两个对象应该可以再简化一下。可以看到Kotlin比Java方便了很多,代码写起来比较优雅,又容易规避问题。主函数是asDynamicTest,返回动态测试对象。代码应该还可以写得更好一些,部分地方还比较生硬,带着Java的痕迹。这里暂时没有把事务处理添加进来,实际上Database Rider提供了对事务处理很好的封装,可以很方便地加入事务支持。 复制import com.github.database.rider.core.util.EntityManagerProviderimport org.apache.commons.jexl3.JexlBuilderimport org.apache.commons.jexl3.JexlEngineimport org.apache.commons.jexl3.MapContextimport org.junit.jupiter.api.DynamicTestimport javax.persistence.EntityManagerimport kotlin.test.assertTrueclass SqlDynamicTest

(

private val config: ConfigurableTest

,

private val suit: ConfigurableTestContainer

) {

private val jexl: JexlEngine = JexlBuilder().cache(512).strict(true).silent(false).create

()

private val dbName: String by lazy

{

if (config.dbName?.isNotBlank() == true

) {

config.dbName} else if (suit.dbName?.isNotBlank() == true

) {

suit.dbName} else

{

""

}

}

private val emProvider: EntityManagerProvider by lazy

{

EntityManagerProvider.instance(dbName

)

}

private val em: EntityManager get() = emProvider.em fun asDynamicTest(): DynamicTest

{

if (dbName.isBlank

()) {

throw RuntimeException("数据库名称不能为空,请配置到test或suit上"

)

}

if (config.testSql.isBlank

()) {

throw RuntimeException("testSql不能为空"

)

}

return DynamicTest.dynamicTest(config.name

) {

exeSql(config.setup

)

val result = exeSql(config.testSql

)

val context = MapContext

()

context.set("result", result

)

doDebug(config.debugs, context

)

doAssert(config.asserts, context

)

exeSql(config.teardown

)

}

}

private fun exeSql(sqls: Iterable<String>?

) {

sqls?.forEach { sql -> val statement = emProvider.connection().prepareStatement(sql

)

statement.use { me -> me.execute

()

me.close

()

}

}

}

private fun exeSql(sql: String): MutableList<Any?>

{

val cleanSql = sql.trimStart

()

return if (mutableListOf("create", "drop", "grant", "insert", "update", "delete", "declare"

)

.any { keyword -> cleanSql.startsWith(keyword, true

) }

) {

exeSql(mutableListOf(sql

))

mutableListOf

()

} else

{

val query = em.createNativeQuery(sql

)

query.resultList

}

}

private fun doAssert(asserts: Iterable<String>?, context: MapContext

) {

asserts?.forEach { assert -> val expression = jexl.createExpression(assert

)

val eval = expression.evaluate(context

)

assertTrue(eval is Boolean && eval, assert

)

}

}

private fun doDebug(debugs: Iterable<String>?, context: MapContext

) {

debugs?.forEachSafely { debug -> print("$debug: "

)

val expression = jexl.createExpression(debug

)

val eval = expression.evaluate(context

)

printlnInGreen(eval

)

}

}

}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.

下面这段代码,就是动态测试的入口了,怎么样,是不是很简单?

@TestFactory就是用来生成动态测试的注解了。整个动态测试可以是多层级的,有DynamicContainer、DynamicNode、DynamicTest等对象组合而成。 复制class DynamicTests

{

@TestFactory fun `可配置sql测试`(): Stream<DynamicContainer>

{

val suits = getTestSuits

()

return suits.map { entry -> val file = entry.key val suit = entry.value DynamicContainer.dynamicContainer

(

suit.name

,

file.toUri

(),

suit.tests.map { testConfig -> SqlDynamicTest(testConfig, suit).asDynamicTest

()

}.stream

()

)

}.stream

()

}

}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18. 几个辅助函数

以下是几个辅助性质的函数,部分用到了Kotlin的扩展语法,用于支持对控制台输出的文字进行着色,以及用forEach遍历时不抛出异常。

复制const val ANSI_RED = "\u001B[31m"const val ANSI_GREEN = "\u001B[32m"const val ANSI_RESET = "\u001B[0m"fun printlnInRed(message: Any?) = println("$ANSI_RED$message$ANSI_RESET"

)

fun printlnInGreen(message: Any?) = println("$ANSI_GREEN$message$ANSI_RESET"

)

inline fun <T> Iterable<T>.forEachSafely(action: (T) -> Unit

) {

for (element in this

) {

try

{

action(element

)

} catch (e: Exception

) {

printlnInRed(e.message

)

}

}

}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17. 收官

好了,到这里就分享结束了,基本上把核心的代码都一一作了介绍,怎么样,有没有对Kotlin、JUnit5、Database Rider等多了一些认识?希望本文可以在工作中帮助到你,感谢你的耐心阅读!

相关内容