Se faire des amis CI, tests unitaires et base de données

image

L'article concerne le test de l'interaction avec la base de données dans CI. J'ai vu plusieurs solutions utilisant docker et testcontainers, mais j'ai la mienne et je veux la partager.

Mon ancien projet Java était étroitement lié à une base de données. Traitement long avec nouvelles tentatives, multithreading et verrouillage des verrous. Pour la tâche, il était nécessaire de corriger quelques requêtes SQL délicates. J'étais en quelque sorte habitué à couvrir mon code avec des tests, mais avant cela, tout le SQL était réduit à des requêtes primitives et il pouvait être exécuté sur une base H2 en mémoire. Et ici en hardcore.

Je pourrais tester le SQL simple de mes propres mains et un lâche marteau sur les auto-tests, me justifiant "Je ne suis pas une sorte de bagodel, il y a des erreurs dans le code simple." En fait, les erreurs apparaissent moins souvent en raison de la disponibilité des tests. Il était possible de pousser la responsabilité des testeurs - si je faisais une erreur quelque part, ils la trouveraient.

image

Selon l'idéologie du test unitaire, les tests ne sont nécessaires que pour tester des modules individuels, et si le module utilise quelque chose de l'extérieur, alors cela doit être remplacé par un talon. En pratique, lorsqu'il devient trop difficile d'implémenter un stub, le module est simplement ignoré. La vitesse et la modularité sont importantes, mais il est plus important de ne pas laisser le code non testé, même s'il n'apparaît pas dans les statistiques de couverture. Par conséquent, le module n'est plus considéré comme une classe distincte, mais comme un groupe connecté, avec la configuration. L'essentiel de ces déclarations n'est pas d'arriver au concept d'étirement des unités au niveau du cluster.

Pour les tests locaux, chaque développeur a son propre schéma, mais les tests sont exécutés sur Jenkins et il n'y a pas encore de connexion à la base de données. CI a besoin d'un circuit séparé, cela semble évident. Mais sur un diagramme vide, il n'est pas très correct d'exécuter des tests; la création d'une structure de base de données dans chaque test prend beaucoup de temps et présente un écart entre la structure dans le test et au combat. Exécutez sur une base pré-préparée - je reçois un tas de problèmes avec les branches. Vous pouvez préparer la base de données avant d'exécuter tous les tests à l'aide de la base de données, tout d'abord effacer tout à zéro, puis mettre à jour vers la dernière version.

La restauration est souvent oubliée pour finaliser et vous devez nettoyer les bases avec vos mains sur des environnements de test. Testez-le et lui! L'algorithme est le suivant:

  1. supprimer tout sous la racine (pour la pureté de l'expérience)
  2. mise à jour vers la dernière version
  3. effectuer des versions de retour en arrière 1-2
  4. mise à jour vers la dernière version (les tests doivent être effectués sur la nouvelle structure de base de données, plus vérifier que la restauration n'a pas oublié de supprimer quoi que ce soit, ce qui empêchera la mise à jour de rouler à nouveau)

Les autres développeurs ne souhaitent pas exécuter de tests de restauration au démarrage de chaque test. Nous faisons le changement.

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

Bien qu'il y ait une formation sur les chats, tout va bien. Mais un projet en développement dynamique fait ses propres ajustements - 2 quêtes de tir, assemblage simultané. Un exemple teste quelque chose, et le second frappe la base sous ses pieds. Il est résolu par une simple interdiction des assemblages parallèles.

image

Et encore une chute - j'ai décidé de faire des tests en utilisant le compte Jenkins, parce que tout va bien sur le plan personnel, et le bassin de demandes tombe pour des raisons peu claires. Nous rappelons le discours ardent que DevOps est une culture et l'utilisation de comptes techniques à des fins personnelles est inacceptable.

image

Cumul de 10 pools de demandes. Tous collectés, redistribués, vous pouvez fusionner. Le premier est allé, la branche principale a changé - les autres ensemble font la queue pour la reconstruction. Vous pouvez fusionner à mesure que vous progressez, mais il y a des priorités. Des demandes de tirage plus urgentes, moins urgentes, sont également bloquées en raison d'erreurs dans le code. En général, parallélisez en arrière.

L'assemblage doit être simple, composé d'étapes simples et compréhensible même pour l'élève d'hier. Dans 99%, il n'y a pas de problème dans la mesure où l'assemblage du pool de demandes et de versions est séquentiel et non parallèle. Si la revue n'accumule pas plus de 1 à 2 RP, alors l'interdiction des assemblages simultanés est tout à fait suffisante.

Et pour un lancement parallèle, nous avons besoin de bases ou de schémas auxquels chaque test lancé aura un accès exclusif.

La première option consiste à allouer dynamiquement. La création d'un schéma dans la base de données est rapide. Ayant un cloud avec une API, vous pouvez y allouer une base de données.

Si vous ne travaillez pas sur la suppression des anciennes bases de données, vous pouvez rapidement terminer l'espace disque lorsque les tests tombent et «oublier» pour libérer des ressources. C'est «quand», pas «si».

Option deux - un pool de bases de données / schémas avec un service de gestion distinct. En dehors de l'API, sortez, donnez la base pour le temps, reprenez la base libre avant le terme. Qu'est-ce qu'il retournera: un serveur dédié avec une base de données ou juste un petit schéma, peu importe. L'essentiel est que la ressource ne soit pas perdue pour toujours.

Troisième option - un ensemble de bases / systèmes d'autorégulation. Une ressource est nécessaire pour l'échange d'informations sur les verrous et le pool de base de données lui-même.

J'ai opté pour cette dernière option, car il est plus facile pour moi de la fixer et il n'est pas particulièrement nécessaire de la supporter. La logique est la suivante - plusieurs circuits (10 par exemple) sont créés et toutes les informations nécessaires pour s'y connecter sont ajoutées à la ressource partagée, chaque instance de test fait une marque de début avant de la démarrer et après la fin, elle la supprime. Si le test se bloque avant la finalisation, le circuit sera considéré comme libre à la fin du timeout.

Paramètres de lecture:

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

Travailler avec SQL à partir de scripts Gradle nécessite le chargement du pilote:
  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()) } } } 

Connexion:

  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"]) } 

L'échange d'informations peut être effectué via la table de base de données. Initialisation de la table de référence (elle devrait fonctionner une fois, puis la table vit jusqu'à la fin des temps):

  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]) } } } 

Les développeurs ont leurs propres schémas de débogage et d'assemblage, donc l'utilisation du pool doit être désactivée:

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

Prise et 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 // 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) } } } 

Si vous terminez l'assemblage 2 fois de suite, différents schémas peuvent être sélectionnés et différentes valeurs resteront lors de l'assemblage des fichiers de propriétés. Pour les lancements locaux, les paramètres sont statiques.

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

Tâches pour tester la restauration:

  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 } } 

Et configurez l'ordre d'exécution:

  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 } } 

Les tests d'allocation et de restauration de la base de données peuvent être placés dans les tests. Façons d'exécuter du code avant et après tous les tests: dans Junit5, c'est BeforeAllCallback, dans TestNG BeforeSuite.

À la question «pourquoi SQL devrait-il être testé par le programmeur Java», la réponse est que tout code doit être testé. Il existe des exceptions et certains codes sont irrationnels à tester à un moment donné.

Je voudrais savoir comment le problème de tester l'interaction avec la base de données est résolu par d'autres programmeurs? Des conteneurs sont-ils arrivés dans chaque maison ou les tests d'intégration sont-ils passés sur les épaules des testeurs?

Liste complète
 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/fr452722/


All Articles