Machen Sie Freunde CI, Unit-Tests und Datenbank

Bild

In diesem Artikel wird die Interaktion mit der Datenbank in CI getestet. Ich habe mehrere Lösungen mit Docker und Testcontainern gesehen, aber ich habe meine eigene und möchte sie teilen.

Mein früheres Java-Projekt war eng mit einer Datenbank verbunden. Lange Verarbeitung mit Wiederholungsversuchen, Multithreading und Sperren. Für die Aufgabe mussten einige knifflige SQL-Abfragen korrigiert werden. Ich war es irgendwie gewohnt, meinen Code mit Tests zu bedecken, aber vorher wurde das gesamte SQL auf primitive Abfragen reduziert und konnte auf einer H2-Basis im Speicher ausgeführt werden. Und hier auf Hardcore.

Ich könnte einfaches SQL mit meinen Händen testen und feige auf Selbsttests hämmern, was mich rechtfertigt: "Ich bin kein Bagodel, es gibt Fehler im einfachen Code." Tatsächlich treten Fehler aufgrund der Verfügbarkeit von Tests seltener auf. Es war möglich, die Verantwortung auf die Tester zu übertragen - wenn ich irgendwo einen Fehler machte, würden sie ihn finden.

Bild

Nach der Ideologie des Unit-Tests werden Tests nur zum Testen einzelner Module benötigt. Wenn das Modul etwas von außen verwendet, sollte dies durch einen Stub ersetzt werden. In der Praxis wird das Modul einfach ignoriert, wenn es zu schwierig wird, einen Stub zu implementieren. Geschwindigkeit und Modularität sind wichtig, aber es ist wichtiger, den Code nicht ungetestet zu lassen, auch wenn er nicht in den Abdeckungsmetriken angezeigt wird. Daher wird das Modul nicht mehr als separate Klasse betrachtet, sondern zusammen mit der Konfiguration als verbundene Gruppe. Die Hauptsache bei solchen Aussagen ist, nicht zum Konzept der Einheitsausdehnung auf die Clusterebene zu gelangen.

Für lokale Tests hat jeder Entwickler sein eigenes Schema, aber Tests werden auf Jenkins ausgeführt und es besteht dort noch keine Verbindung zur Datenbank. CI braucht einen separaten Stromkreis, das scheint offensichtlich zu sein. In einem leeren Diagramm ist es jedoch nicht sehr korrekt, Tests auszuführen. Das Erstellen einer Datenbankstruktur in jedem Test ist zeitaufwändig und mit einer Diskrepanz zwischen der Struktur im Test und im Kampf behaftet. Laufen Sie auf einer vorbereiteten Basis - ich habe eine Reihe von Problemen mit Zweigen. Sie können die Datenbank vorbereiten, bevor Sie alle Tests mit liquibase ausführen. Zuerst löschen Sie alles auf Null und aktualisieren dann auf die neueste Version.

Das Zurücksetzen des Rollbacks wird oft vergessen, und Sie müssen die Basen in Testumgebungen mit den Händen reinigen. Teste es und ihn! Der Algorithmus ist wie folgt:

  1. Entfernen Sie alles unter der Wurzel (für die Reinheit des Experiments)
  2. Update auf die neueste Version
  3. Führen Sie Rollback 1-2 Versionen zurück
  4. Update auf die neueste Version (Tests müssen für die neue Datenbankstruktur durchgeführt werden, und es muss überprüft werden, ob beim Rollback nichts vergessen wurde, um zu verhindern, dass das Update erneut ausgeführt wird.)

Mitentwickler möchten beim Starten jedes Tests keine Rollback-Tests ausführen. Wir machen den Wechsel.

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

Während Katzen trainiert werden, ist alles in Ordnung. Ein sich dynamisch entwickelndes Projekt nimmt jedoch seine eigenen Anpassungen vor - 2 Pull-Quests, gleichzeitige Montage. Eine Instanz testet etwas und die zweite schlägt die Basis unter ihren Füßen hervor. Es wird durch ein einfaches Verbot paralleler Baugruppen gelöst.

Bild

Und wieder ein Sturz - ich habe beschlossen, Tests mit dem Jenkins-Konto durchzuführen, weil In persönlicher Hinsicht ist alles in Ordnung, und der Pool an Anfragen fällt aus unklaren Gründen. Wir erinnern uns an die leidenschaftliche Rede, dass DevOps eine Kultur ist und die Verwendung technischer Konten für persönliche Zwecke nicht akzeptabel ist.

Bild

Insgesamt 10 Anfragen gesammelt. Alles gesammelt, neu verteilt, können Sie zusammenführen. Der erste ging, der Hauptzweig wechselte - der Rest steht zusammen für den Wiederaufbau. Sie können im Laufe der Zeit zusammenführen, es gibt jedoch Prioritäten. Dringendere und weniger dringende Pull-Anfragen hängen aufgrund von Fehlern im Code ebenfalls auf. Parallelisieren Sie im Allgemeinen zurück.

Die Montage sollte einfach sein, aus einfachen Schritten bestehen und auch für den gestrigen Schüler verständlich sein. Bei 99% besteht kein Problem darin, dass die Zusammenstellung des Anforderungs- und Freigabepools sequentiell und nicht parallel erfolgt. Wenn die Überprüfung nicht mehr als 1-2 PR umfasst, ist das Verbot gleichzeitiger Baugruppen völlig ausreichend.

Und für den parallelen Start benötigen wir Basen oder Schemata, auf die jeder gestartete Test exklusiven Zugriff hat.

Option eins ist die dynamische Zuordnung. Das Erstellen eines Schemas in der Datenbank ist schnell. Mit einer Cloud mit einer API können Sie dort eine Datenbank zuweisen.

Wenn Sie das Löschen alter Datenbanken nicht klären, können Sie den Speicherplatz schnell beenden, wenn die Tests fallen, und „vergessen“, Ressourcen freizugeben. Es ist "wann", nicht "wenn".

Option zwei - ein Datenbank- / Schemapool mit einem separaten Verwaltungsdienst. Die API ragt heraus, gibt die Basis für die Zeit an und nimmt die freie Basis vor der Laufzeit zurück. Was es zurückgibt: Ein dedizierter Server mit einer Datenbank oder nur einem kleinen Schema spielt keine Rolle. Die Hauptsache ist, dass die Ressource nicht für immer verloren geht.

Option drei - ein Pool von Grundlagen / Systemen zur Selbstregulierung. Für den Informationsaustausch über Sperren und den Datenbankpool selbst wird eine Ressource benötigt.

Ich habe mich für die letztere Option entschieden, da es für mich einfacher ist, sie zu befestigen, und es nicht besonders erforderlich ist, sie zu unterstützen. Die Logik lautet wie folgt: Es werden mehrere (z. B. 10) Schaltkreise erstellt und alle erforderlichen Informationen zum Herstellen einer Verbindung werden der gemeinsam genutzten Ressource hinzugefügt. Jede Testinstanz vor dem Start setzt eine Startmarkierung, nach dem Ende löscht sie. Wenn der Test vor dem Abschluss abstürzt, wird die Schaltung am Ende des Timeouts als frei betrachtet.

Leseeinstellungen:

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

Das Arbeiten mit SQL aus Gradle-Skripten erfordert das Laden des Treibers:
  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()) } } } 

Verbindung:

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

Der Informationsaustausch kann über die Datenbanktabelle erfolgen. Initialisierung der Referenztabelle (sollte einmal funktionieren, dann lebt die Tabelle bis zum Ende der Zeit):

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

Entwickler haben ihre eigenen Schemata für das Debuggen und Zusammenstellen, daher muss die Verwendung des Pools deaktiviert werden:

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

Nehmen Sie und freie Basis:

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

Wenn Sie die Assembly zweimal hintereinander abschließen, können unterschiedliche Schemata ausgewählt werden, und beim Zusammenstellen der Eigenschaftendateien bleiben unterschiedliche Werte erhalten. Bei lokalen Starts sind die Einstellungen statisch.

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

Aufgaben zum Testen des Rollbacks:

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

Und richten Sie die Ausführungsreihenfolge ein:

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

Datenbankzuweisung und Rollback-Tests können in Tests platziert werden. Möglichkeiten zum Ausführen von Code vor und nach Abschluss aller Tests: In Junit5 ist dies BeforeAllCallback in TestNG BeforeSuite.

Auf die Frage „Warum sollte SQL vom Java-Programmierer getestet werden?“ Lautet die Antwort, dass jeder Code getestet werden sollte. Es gibt Ausnahmen und das Testen von Code ist zu einem bestimmten Zeitpunkt irrational.

Ich möchte wissen, wie das Problem des Testens der Interaktion mit der Datenbank von anderen Programmierern gelöst wird. Sind Container in jedem Haus angekommen oder werden Integrationstests auf die Schultern von Testern übertragen?

Vollständige Auflistung
 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/de452722/


All Articles