交朋友CI,单元测试和数据库

图片

本文是关于在CI中测试与数据库的交互。 我看到了一些使用docker和testcontainer的解决方案,但是我有自己的并且想要共享。

我过去的Java项目与数据库紧密相关。 重试,多线程和锁定锁的长时间处理。 对于此任务,需要更正几个棘手的SQL查询。 我曾经习惯于用测试覆盖我的代码,但是在此之前,所有SQL都简化为原始查询,并且可以在内存的H2基础上运行。 而这里是铁杆。

我可以用手测试简单的SQL,然后胆怯地进行自我测试,以证明自己“我不是某种方式,我会在简单的代码中犯错误。” 实际上,由于测试的可用性,错误出现的频率降低了。 可以将责任推给测试人员-如果我在某个地方犯了一个错误,他们会发现。

图片

根据单元测试的思想,仅对于测试单个模块才需要进行测试,如果模块使用了外部的东西,则应将其替换为存根。 实际上,当实现存根变得太困难时,将简单地忽略该模块。 速度和模块化很重要,但是更重要的是,即使代码未出现在覆盖率指标中,也请不要对其进行测试。 因此,该模块不再被认为是一个单独的类,而是一个连接的组以及配置。 这样的语句的主要目的不是要达到将单元扩展到集群级别的概念。

对于本地测试,每个开发人员都有自己的方案,但是测试是在Jenkins上运行的,目前还没有与数据库的连接。 CI需要单独的电路,这似乎是显而易见的。 但是在一个空的图表上,运行测试不是很正确;在每个测试中创建数据库结构非常耗时,并且在测试和战斗中都存在结构差异。 在预先准备好的基础上运行-我在分支方面遇到很多问题。 您可以在使用liquibase运行所有测试之前准备数据库,首先将所有内容清除为零,然后更新为最新版本。

回滚通常被忘记完成,您必须动手测试环境才能清理基础。 和他一起测试! 算法如下:

  1. 删除根目录下的所有内容(出于实验的纯正目的)
  2. 更新到最新版本
  3. 执行回滚1-2版本
  4. 更新到最新版本(需要在新的数据库结构上进行测试,并检查回滚是否没有忘记删除任何内容,这将阻止更新再次滚动)

某些开发人员在开始每个测试时都不想运行回滚测试。 我们进行切换。

project.ext.doRollbackTest = { Boolean.parseBoolean(localConfig['test.rollback.enabled'] as String) } 

虽然对猫进行了培训,但一切正常。 但是,一个动态开发的项目会自行调整-2个拉动任务,同时进行组装。 一个实例正在测试某些东西,第二个实例是从脚下敲打底座。 它可以通过简单禁止并行装配来解决。

图片

还有一次失败-我决定使用Jenkins帐户进行测试,因为 就个人而言,一切正常,并且由于不清楚的原因,请求池下降。 我们回想起热烈的讨论,即DevOps是一种文化,出于个人目的使用技术帐户是不可接受的。

图片

累计10个请求池。 所有收集的,重新分发的都可以合并。 第一个去了,主要分支改变了-其余的一起排队重建。 您可以随进度进行合并,但是有优先级。 由于代码中的错误,更紧急的pullrequests,较不紧急的请求也挂断了。 通常,并行化。

组装应该很简单,包括简单的步骤,甚至对于昨天的学生来说也是可以理解的。 99%的请求和释放池的组装是顺序的,而不是并行的,这是没有问题的。 如果检查的累积量不超过1-2 PR,则禁止同时装配是足够的。

对于并行启动,我们需要每个启动的测试都具有独占访问权的基础或方案。

选项一是动态分配。 在数据库中创建模式很快。 通过API的云,您可以在那里分配数据库。

如果您不打算删除旧数据库,则可以在测试失败时快速完成磁盘空间,然后“忘记”以释放资源。 它是“何时”,而不是“是否”。

选项二-具有单独管理服务的数据库/模式池。 在API外面伸出手,给时间定基,在学期前收回免费基。 它会返回什么:带有数据库或仅有少量架构的专用服务器,这无关紧要。 最主要的是资源不会永远丢失。

选项三-自我监管的基础/计划库。 需要资源来交换有关锁和数据库池本身的信息。

我选择了后一种选择,因为它对我来说更容易固定,并且不需要特别的支持。 逻辑如下-创建几个电路(例如10个),并将所有关于连接它们的必要信息添加到共享资源中,每个测试实例在开始之前作一个开始标记,在结束后将其删除。 如果测试在完成之前崩溃,则在超时结束时电路将被视为空闲。

阅读设置:

  project.ext.localConfig = new Properties() localConfig.load(file("${rootDir}/local.properties").newReader()) 

从gradle脚本中使用sql需要驱动程序加载:
  configurations { driver } dependencies { driver group: "oracle", name: "ojdbc6", version: "11.+" } task initDriver { doLast { ClassLoader loader = GroovyObject.class.classLoader configurations.driver.each { File file -> loader.addURL(file.toURL()) } } } 

连接方式:

  import groovy.sql.Sql project.ext.createSqlInstance = { return Sql.newInstance( url: localConfig["pool.db.url"], user: localConfig["pool.db.username"], password: localConfig["pool.db.password"], driver: localConfig["pool.db.driverClass"]) } 

可以通过数据库表进行信息交换。 引用表的初始化(应该工作一次,然后该表将一直存在直到时间结束):

  task initDbPool { dependsOn initDriver doLast { Integer poolSize = 10 Sql sql = createSqlInstance() as Sql String tableName = localConfig["pool.db.referenceTable"] String baseName = localConfig["pool.db.baseName"] String basePass = localConfig["pool.db.basePass"] String token = "{id}" List tableExists = sql.rows("select table_name from all_tables where table_name=?", [tableName]) assert tableExists.isEmpty() sql.execute(""" CREATE TABLE ${tableName} ( ID NUMBER(2) NOT NULL PRIMARY KEY, METADATA VARCHAR2(200) NOT NULL, PROCESSED TIMESTAMP NULL, GUID VARCHAR2(36) NULL) """, []) for (Integer i = 0 ; i < poolSize ; i++) { String username = baseName.replace(token, i.toString()) String password = basePass.replace(token, i.toString()) sql.execute(""" CREATE USER ${username} IDENTIFIED BY "${password}" DEFAULT TABLESPACE USERS TEMPORARY TABLESPACE TEMP PROFILE DEFAULT QUOTA UNLIMITED ON USERS """, []) sql.execute("grant connect to ${username}", []) sql.execute("grant create sequence to ${username}", []) sql.execute("grant create session to ${username}", []) sql.execute("grant create table to ${username}", []) String metadata = JsonOutput.toJson([ "app.db.driverClass": localConfig["pool.db.driverClass"], "app.db.url": localConfig["pool.db.url"], "app.db.username": username, "app.db.password": password ]) sql.execute(""" INSERT INTO ${tableName} (id, metadata) values (?, ?) """, [i, metadata]) } } } 

开发人员具有自己的调试和组装方案,因此必须关闭对池的使用:

  project.ext.isCiBuild = { Boolean.parseBoolean(localConfig['pool.db.enabled'] as String) } 

取免费基数:

  task lockDb { dependsOn initDriver onlyIf isCiBuild doLast { project.ext.lockUid = UUID.randomUUID().toString() String tableName = localConfig["pool.db.referenceTable"] Sql sql = createSqlInstance() as Sql sql.executeUpdate("""UPDATE ${tableName} SET GUID = ?, PROCESSED = SYSDATE WHERE ID IN ( SELECT ID FROM ( SELECT ID, ROW_NUMBER() OVER (ORDER BY PROCESSED) AS RN FROM ${tableName} WHERE GUID IS NULL OR PROCESSED < (SYSDATE - NUMTODSINTERVAL(?, 'MINUTE')) ) WHERE RN = 1 ) """, [lockUid, 15]) def meta = sql.firstRow("SELECT METADATA FROM ${tableName} WHERE GUID = ?", [lockUid]) assert meta != null, "No free databases in pool" def slurper = new JsonSlurper() Map metadata = slurper.parseText(meta["METADATA"] as String) as Map localConfig.putAll(metadata) logger.info("Database locked, {}", metadata) } } task unlockDb { dependsOn lockDb // init lockUid onlyIf isCiBuild doLast { try { String tableName = localConfig["pool.db.referenceTable"] Sql sql = createSqlInstance() as Sql sql.executeUpdate("UPDATE ${tableName} SET GUID = NULL WHERE GUID = ?", [lockUid]) logger.info("Database unlocked") } catch (ignored) { logger.error(ignored) } } } 

如果您连续两次完成装配,则可以选择不同的方案,并且装配属性文件时将保留不同的值。 对于本地启动,设置是静态的。

  configure([processResources, processTestResources]) { Task t -> if (project.ext.isCiBuild()) { t.outputs.upToDateWhen { false } } t.filesMatching('**/*.properties') { filter(ReplaceTokens, tokens: localConfig, beginToken: '${', endToken: '}') } } 

测试回滚的任务:

  task restoreAfterRollbackTest(type: LiquibaseTask) { command = 'update' } task rollbackTest(type: LiquibaseTask) { dependsOn lockDb command = 'rollback' requiresValue = true doFirst { project.ext.liquibaseCommandValue = localConfig['test.rollback.tag'] } doLast { project.ext.liquibaseCommandValue = null } } 

并设置执行顺序:

  configure([project]) { tasks.withType(LiquibaseTask.class) { LiquibaseTask t -> logger.info("Liquibase task {} must run after {}", t.getName(), configLiquibase.getName()) (t as Task).dependsOn configLiquibase if (isCiBuild()) { logger.info("Liquibase task {} must run after {}", t.getName(), lockDb.getName()) (t as Task).dependsOn lockDb (t as Task).finalizedBy unlockDb } } //   CI: // 1.    0 (dropAll) // 2.     rollback (update) // 3. ,       (rollback tag) // 4.      (update) // 5.     if (doRollbackTest()) { def setTaskOrdering = { List<Task> lst -> for (int i = 0; i < lst.size() - 1; i++) { logger.info("Task {} must run after {}", lst[i + 1].getName(), lst[i].getName()) lst[i + 1].dependsOn lst[i] } } setTaskOrdering([ lockDb, configLiquibase, dropAll, update, rollbackTest, restoreAfterRollbackTest, processTestResources, test, ]) lockDb.finalizedBy unlockDb test.finalizedBy unlockDb } } 

数据库分配和回滚测试可以放在测试内部。 在完成所有测试之前和之后运行代码的方式:在Junit5中,这是TestNG BeforeSuite中的BeforeAllCallback。

对于“为什么Java程序员应该测试sql”这个问题,答案是应该测试任何代码。 有例外,有些代码在给定时间是不合理的。

我想知道其他程序员如何解决测试与数据库交互的问题? 集装箱是否已到达每个家庭,还是集成测试通过了测试人员的肩膀?

完整清单
 import groovy.json.JsonOutput import groovy.json.JsonSlurper import groovy.sql.Sql import org.apache.tools.ant.filters.ReplaceTokens import org.liquibase.gradle.LiquibaseTask plugins { id 'java' id 'org.liquibase.gradle' version '2.0.1' } configurations { driver } repositories { jcenter() mavenCentral() maven { url = "http://www.datanucleus.org/downloads/maven2/" } } dependencies { implementation 'com.google.guava:guava:27.0.1-jre' implementation 'org.springframework:spring-core:5.1.7.RELEASE' implementation 'org.springframework:spring-context:5.1.7.RELEASE' implementation 'org.springframework:spring-jdbc:5.1.7.RELEASE' testImplementation 'junit:junit:4.12' testImplementation 'org.springframework:spring-test:5.1.7.RELEASE' testRuntime 'oracle:ojdbc6:11.+' liquibaseRuntime 'org.liquibase:liquibase-core:3.6.1' liquibaseRuntime 'oracle:ojdbc6:11.+' liquibaseRuntime 'org.yaml:snakeyaml:1.24' driver group: "oracle", name: "ojdbc6", version: "11.+" } project.ext.localConfig = new Properties() localConfig.load(file("${rootDir}/local.properties").newReader()) project.ext.isCiBuild = { Boolean.parseBoolean(localConfig['pool.db.enabled'] as String) } project.ext.doRollbackTest = { Boolean.parseBoolean(localConfig['test.rollback.enabled'] as String) } task configLiquibase { doLast { liquibase { activities { testdb { changeLogFile 'changelog.yaml' url localConfig['app.db.url'] driver localConfig['app.db.driverClass'] username localConfig['app.db.username'] password localConfig['app.db.password'] logLevel 'debug' classpath "${project.projectDir}/db" contexts 'main' } runList = 'testdb' } } } } task initDriver { doLast { ClassLoader loader = GroovyObject.class.classLoader configurations.driver.each { File file -> loader.addURL(file.toURL()) } } } project.ext.createSqlInstance = { return Sql.newInstance( url: localConfig["pool.db.url"], user: localConfig["pool.db.username"], password: localConfig["pool.db.password"], driver: localConfig["pool.db.driverClass"]) } task initDbPool { dependsOn initDriver doLast { Integer poolSize = 10 Sql sql = createSqlInstance() as Sql String tableName = localConfig["pool.db.referenceTable"] String baseName = localConfig["pool.db.baseName"] String basePass = localConfig["pool.db.basePass"] String token = "{id}" List tableExists = sql.rows("select table_name from all_tables where table_name=?", [tableName]) assert tableExists.isEmpty() sql.execute(""" CREATE TABLE ${tableName} ( ID NUMBER(2) NOT NULL PRIMARY KEY, METADATA VARCHAR2(200) NOT NULL, PROCESSED TIMESTAMP NULL, GUID VARCHAR2(36) NULL) """, []) for (Integer i = 0 ; i < poolSize ; i++) { String username = baseName.replace(token, i.toString()) String password = basePass.replace(token, i.toString()) sql.execute(""" CREATE USER ${username} IDENTIFIED BY "${password}" DEFAULT TABLESPACE USERS TEMPORARY TABLESPACE TEMP PROFILE DEFAULT QUOTA UNLIMITED ON USERS """, []) sql.execute("grant connect to ${username}", []) sql.execute("grant create sequence to ${username}", []) sql.execute("grant create session to ${username}", []) sql.execute("grant create table to ${username}", []) String metadata = JsonOutput.toJson([ "app.db.driverClass": localConfig["pool.db.driverClass"], "app.db.url": localConfig["pool.db.url"], "app.db.username": username, "app.db.password": password ]) sql.execute(""" INSERT INTO ${tableName} (id, metadata) values (?, ?) """, [i, metadata]) } } } task lockDb { dependsOn initDriver onlyIf isCiBuild doLast { project.ext.lockUid = UUID.randomUUID().toString() String tableName = localConfig["pool.db.referenceTable"] Sql sql = createSqlInstance() as Sql sql.executeUpdate("""UPDATE ${tableName} SET GUID = ?, PROCESSED = SYSDATE WHERE ID IN ( SELECT ID FROM ( SELECT ID, ROW_NUMBER() OVER (ORDER BY PROCESSED) AS RN FROM ${tableName} WHERE GUID IS NULL OR PROCESSED < (SYSDATE - NUMTODSINTERVAL(?, 'MINUTE')) ) WHERE RN = 1 ) """, [lockUid, 15]) def meta = sql.firstRow("SELECT METADATA FROM ${tableName} WHERE GUID = ?", [lockUid]) assert meta != null, "No free databases in pool" def slurper = new JsonSlurper() Map metadata = slurper.parseText(meta["METADATA"] as String) as Map localConfig.putAll(metadata) logger.info("Database locked, {}", metadata) } } task unlockDb { dependsOn lockDb // init lockUid onlyIf isCiBuild doLast { try { String tableName = localConfig["pool.db.referenceTable"] Sql sql = createSqlInstance() as Sql sql.executeUpdate("UPDATE ${tableName} SET GUID = NULL WHERE GUID = ?", [lockUid]) logger.info("Database unlocked") } catch (ignored) { logger.error(ignored) } } } configure([processResources, processTestResources]) { Task t -> if (project.ext.isCiBuild()) { t.outputs.upToDateWhen { false } } t.filesMatching('**/*.properties') { filter(ReplaceTokens, tokens: localConfig, beginToken: '${', endToken: '}') } } task restoreAfterRollbackTest(type: LiquibaseTask) { command = 'update' } task rollbackTest(type: LiquibaseTask) { dependsOn lockDb command = 'rollback' requiresValue = true doFirst { project.ext.liquibaseCommandValue = localConfig['test.rollback.tag'] } doLast { project.ext.liquibaseCommandValue = null } } configure([project]) { tasks.withType(LiquibaseTask.class) { LiquibaseTask t -> logger.info("Liquibase task {} must run after {}", t.getName(), configLiquibase.getName()) (t as Task).dependsOn configLiquibase if (isCiBuild()) { logger.info("Liquibase task {} must run after {}", t.getName(), lockDb.getName()) (t as Task).dependsOn lockDb (t as Task).finalizedBy unlockDb } } //   CI: // 1.    0 (dropAll) // 2.     rollback (update) // 3. ,       (rollback tag) // 4.      (update) // 5.     if (doRollbackTest()) { def setTaskOrdering = { List<Task> lst -> for (int i = 0; i < lst.size() - 1; i++) { logger.info("Task {} must run after {}", lst[i + 1].getName(), lst[i].getName()) lst[i + 1].dependsOn lst[i] } } setTaskOrdering([ lockDb, configLiquibase, dropAll, update, rollbackTest, restoreAfterRollbackTest, processTestResources, test, ]) lockDb.finalizedBy unlockDb test.finalizedBy unlockDb } } 

 pool.db.enabled=false test.rollback.enabled=true pool.db.driverClass=oracle.jdbc.driver.OracleDriver pool.db.url=jdbc:oracle:thin:@localhost:1527:ORCLCDB pool.db.username=SYSTEM pool.db.password=Oradoc_db1 pool.db.referenceTable=c##test_user1.REF_TABLE pool.db.baseName=C##CI_SCHEMA_{id} pool.db.basePass=CI_SCHEMA_{id}_PASS app.db.driverClass= app.db.url= app.db.username= app.db.password= test.rollback.tag=version_1 

Source: https://habr.com/ru/post/zh-CN452722/


All Articles