El artículo trata sobre probar la interacción con la base de datos en CI. Vi varias soluciones usando docker y testcontainers, pero tengo la mía y quiero compartirla.
Mi proyecto anterior de Java estaba estrechamente vinculado a una base de datos. Procesamiento largo con reintentos, subprocesos múltiples y bloqueos de bloqueo. Para la tarea se requería corregir un par de consultas SQL difíciles. De alguna manera estaba acostumbrado a cubrir mi código con pruebas, pero antes de eso, todo el SQL se reducía a consultas primitivas y podía ejecutarse en una base H2 en la memoria. Y aquí en hardcore.
Podría probar SQL simple con mis manos y un martillo cobarde en las autocomprobaciones, justificándome "No soy una especie de bagodel, cometeré errores en un código simple". En realidad, los errores aparecen con menos frecuencia debido a la disponibilidad de pruebas. Era posible trasladar la responsabilidad a los evaluadores: si cometía un error en algún lado, lo encontrarían.

De acuerdo con la ideología de las pruebas unitarias, las pruebas son necesarias solo para probar módulos individuales, y si el módulo usa algo del exterior, entonces esto debe reemplazarse con un trozo. En la práctica, cuando se vuelve demasiado difícil implementar un código auxiliar, simplemente se ignora el módulo. La velocidad y la modularidad son importantes, pero es más importante no dejar el código sin probar, incluso si no aparece en las métricas de cobertura. Por lo tanto, el módulo ya no se considera una clase separada, sino un grupo conectado, junto con la configuración. Lo principal con tales declaraciones es no llegar al concepto de unidad que se extiende al nivel de clúster.
Para las pruebas locales, cada desarrollador tiene su propio esquema, pero las pruebas se ejecutan en Jenkins y todavía no hay conexión a la base de datos. CI necesita un circuito separado, parece ser obvio. Pero en un diagrama vacío, no es muy correcto ejecutar pruebas; crear una estructura de base de datos en cada prueba lleva mucho tiempo y está lleno de discrepancias entre la estructura en la prueba y en la batalla. Ejecutar en una base preparada previamente: tengo muchos problemas con las ramas. Puede preparar la base de datos antes de ejecutar todas las pruebas con liquibase, primero borrar todo a cero y luego actualizar a la última versión.
A menudo, se olvida el retroceso para finalizar y debe limpiar las bases con sus manos en entornos de prueba. Pruébalo y él! El algoritmo es el siguiente:
- eliminar todo debajo de la raíz (para la pureza del experimento)
- actualizar a la última versión
- realizar reversión 1-2 versiones atrás
- actualizar a la última versión (las pruebas deben realizarse en la nueva estructura de la base de datos, además de comprobar que la reversión no se olvidó de eliminar nada, lo que evitará que la actualización vuelva a funcionar)
Los demás desarrolladores no desean ejecutar pruebas de reversión al comenzar cada prueba. Nosotros hacemos el cambio.
project.ext.doRollbackTest = { Boolean.parseBoolean(localConfig['test.rollback.enabled'] as String) }
Si bien hay entrenamiento en gatos, todo está bien. Pero un proyecto en desarrollo dinámico realiza sus propios ajustes: 2 misiones de extracción, ensamblaje simultáneo. Una instancia está probando algo, y la segunda está golpeando la base debajo de sus pies. Se resuelve con una simple prohibición de ensamblajes paralelos.

Y nuevamente una caída: decidí ejecutar pruebas usando la cuenta de Jenkins, porque todo está bien en lo personal, y el conjunto de solicitudes cae por razones poco claras. Recordamos la ferviente charla de que DevOps es una cultura y el uso de cuentas técnicas para fines personales es inaceptable.

Acumulado 10 grupo de solicitudes. Todo reunido, redistribuido, puede fusionar. El primero fue, la rama principal cambió: el resto juntos hacen cola para la reconstrucción. Puede fusionarse a medida que avanza, pero hay prioridades. Solicitudes de extracción más urgentes, menos urgentes, también están colgando debido a errores en el código. En general, paralelizar hacia atrás.
La asamblea debería ser simple, consistir en pasos simples y ser comprensible incluso para el estudiante de ayer. En el 99% no hay problema en que el ensamblaje del conjunto de solicitud y liberación sea secuencial y no paralelo. Si la revisión no acumula más de 1-2 PR, entonces la prohibición de ensambles simultáneos es suficiente.
Y para el lanzamiento paralelo necesitamos bases o esquemas a los que cada prueba lanzada tendrá acceso exclusivo.
La opción uno es asignar dinámicamente. Crear un esquema en la base de datos es rápido. Al tener una nube con una API, puede asignar una base de datos allí.
Si no resuelve la eliminación de bases de datos antiguas, puede finalizar rápidamente el espacio en disco cuando caen las pruebas y "olvidar" para liberar recursos. Es "cuándo", no "si".
Opción dos: un grupo de base de datos / esquema con un servicio de administración separado. Fuera de la API sobresale, entregue la Base por el tiempo, recupere la Base libre antes del plazo. Lo que devolverá: un servidor dedicado con una base de datos o simplemente un pequeño esquema, no importa. Lo principal es que el recurso no se perderá para siempre.
Opción tres: un conjunto de bases / esquemas para la autorregulación. Se necesita un recurso para el intercambio de información sobre bloqueos y el grupo de bases de datos en sí.
Me decidí por la última opción, ya que es más fácil para mí sujetarla y no es particularmente necesario para apoyarla. La lógica es la siguiente: se crean varios (10 por ejemplo) circuitos y toda la información necesaria para conectarse a ellos se agrega al recurso compartido, cada instancia de prueba antes del inicio hace una marca de inicio, después del final, la elimina. Si la prueba falla antes de finalizar, el circuito se considerará libre al final del tiempo de espera.
Configuraciones de lectura:
project.ext.localConfig = new Properties() localConfig.load(file("${rootDir}/local.properties").newReader())
Trabajar con sql desde scripts gradle requiere la carga del controlador:
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()) } } }
Conexión:
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"]) }
El intercambio de información puede llevarse a cabo a través de la tabla de la base de datos. Inicialización de la tabla de referencia (debería funcionar una vez, luego la tabla vive hasta el final de los tiempos):
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]) } } }
Los desarrolladores tienen sus propios esquemas para la depuración y el ensamblaje, por lo que el uso del grupo debe estar desactivado:
project.ext.isCiBuild = { Boolean.parseBoolean(localConfig['pool.db.enabled'] as String) }
Toma y base libre:
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
Si completa el ensamblaje 2 veces seguidas, se pueden seleccionar diferentes esquemas y permanecerán diferentes valores al ensamblar los archivos de propiedades. Para lanzamientos locales, la configuración es estática.
configure([processResources, processTestResources]) { Task t -> if (project.ext.isCiBuild()) { t.outputs.upToDateWhen { false } } t.filesMatching('**/*.properties') { filter(ReplaceTokens, tokens: localConfig, beginToken: '${', endToken: '}') } }
Tareas para probar la reversión:
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 } }
Y configure la orden de ejecución:
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 } }
La asignación de base de datos y las pruebas de reversión se pueden colocar dentro de las pruebas. Formas de ejecutar código antes y después de completar todas las pruebas: en Junit5, esto es BeforeAllCallback, en TestNG BeforeSuite.
A la pregunta "¿por qué sql debería ser probado por el programador de Java?", La respuesta es que cualquier código debe ser probado. Hay excepciones y algunos códigos son irracionales para probar en un momento dado.
Me gustaría saber cómo otros programadores resuelven el problema de probar la interacción con la base de datos. ¿Han llegado contenedores a todos los hogares, o las pruebas de integración pasan a los hombros de los evaluadores?
Listado completo 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
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