Buat teman-teman CI, unit test dan database

gambar

Artikel ini tentang menguji interaksi dengan database di CI. Saya melihat beberapa solusi menggunakan docker dan testcontainers, tetapi saya punya solusi sendiri dan saya ingin membagikannya.

Proyek java masa lalu saya terkait erat dengan database. Proses panjang dengan coba ulang, multithreading, dan kunci kunci. Untuk tugas itu diperlukan untuk memperbaiki beberapa pertanyaan SQL yang rumit. Saya entah bagaimana terbiasa menutupi kode saya dengan tes, tetapi sebelum itu semua SQL dikurangi menjadi pertanyaan primitif dan dapat dijalankan pada basis H2 dalam memori. Dan di sini di hardcore.

Saya bisa menguji SQL sederhana dengan tangan saya sendiri dan palu pengecut pada tes diri, membenarkan diri sendiri "Saya bukan semacam bagodel, ada kesalahan dalam kode sederhana." Sebenarnya, kesalahan muncul lebih jarang karena ketersediaan tes. Dimungkinkan untuk mendorong tanggung jawab kepada para penguji - jika saya membuat kesalahan di suatu tempat, mereka akan menemukannya.

gambar

Menurut ideologi pengujian unit, pengujian diperlukan hanya untuk menguji modul individual, dan jika modul menggunakan sesuatu dari luar, maka ini harus diganti dengan rintisan. Dalam praktiknya, ketika menjadi terlalu sulit untuk mengimplementasikan rintisan, modul diabaikan. Kecepatan dan modularitas penting, tetapi lebih penting untuk tidak membiarkan kode tidak teruji, meskipun tidak muncul dalam metrik cakupan. Oleh karena itu, modul tidak lagi dianggap sebagai kelas yang terpisah, tetapi kelompok yang terhubung, bersama dengan konfigurasi. Hal utama dengan pernyataan tersebut adalah tidak sampai pada konsep unit stretching ke level cluster.

Untuk pengujian lokal, setiap pengembang memiliki skema sendiri, tetapi tes dijalankan pada Jenkins dan belum ada koneksi ke database di sana. CI membutuhkan rangkaian terpisah, sepertinya sudah jelas. Tetapi pada diagram kosong, tidak terlalu tepat untuk menjalankan tes, membuat struktur basis data pada setiap tes memakan waktu dan penuh dengan perbedaan antara struktur dalam tes dan dalam pertempuran. Jalankan di pangkalan yang sudah disiapkan - saya mendapatkan banyak masalah dengan cabang. Anda dapat menyiapkan basis data sebelum menjalankan semua tes menggunakan liquibase, pertama menghapus semuanya menjadi nol, dan kemudian memperbarui ke versi terbaru.

Rollback sering dilupakan untuk diselesaikan dan Anda harus membersihkan pangkalan dengan tangan di lingkungan pengujian. Uji itu dan dia! Algoritma adalah sebagai berikut:

  1. hapus semua yang ada di bawah root (untuk kemurnian percobaan)
  2. perbarui ke versi terbaru
  3. melakukan rollback 1-2 versi kembali
  4. perbarui ke versi terbaru (tes perlu didorong pada struktur database baru, ditambah memeriksa bahwa rollback tidak lupa menghapus apa pun, yang akan mencegah pembaruan bergulir lagi)

Rekan pengembang tidak ingin menjalankan pengujian rollback saat memulai setiap pengujian. Kami beralih.

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

Meskipun ada pelatihan tentang kucing, semuanya baik-baik saja. Tapi proyek yang berkembang secara dinamis membuat penyesuaian sendiri - 2 pull-quest, perakitan simultan. Satu contoh sedang menguji sesuatu, dan yang kedua adalah mengetuk dasar dari bawah kakinya. Ini diselesaikan dengan larangan sederhana pada majelis paralel.

gambar

Dan lagi jatuh - saya memutuskan untuk menjalankan tes menggunakan akun Jenkins, karena semuanya baik-baik saja pada pribadi, dan kumpulan permintaan jatuh karena alasan yang tidak jelas. Kami ingat pembicaraan yang keras bahwa DevOps adalah budaya dan penggunaan akun teknis untuk tujuan pribadi tidak dapat diterima.

gambar

Akumulasi 10 kumpulan permintaan. Semua dikumpulkan, didistribusikan, Anda dapat bergabung. Yang pertama pergi, cabang utama berubah - sisanya bersama-sama mengantre untuk membangun kembali. Anda dapat bergabung saat Anda maju, tetapi ada prioritas. Permintaan penarikan lebih mendesak, kurang mendesak, juga ditutup karena kesalahan dalam kode. Secara umum, sejajar kembali.

Perakitan harus sederhana, terdiri dari langkah-langkah sederhana dan dapat dimengerti bahkan untuk siswa kemarin. Dalam 99% tidak ada masalah bahwa perakitan kumpulan permintaan dan rilis berurutan, dan tidak paralel. Jika peninjauan tidak mengumpulkan lebih dari 1-2 PR, maka larangan majelis simultan sudah cukup.

Dan untuk peluncuran paralel, kami membutuhkan pangkalan atau skema di mana setiap pengujian yang diluncurkan akan memiliki akses eksklusif.

Opsi satu adalah mengalokasikan secara dinamis. Membuat skema dalam basis data sangat cepat. Memiliki cloud dengan API, Anda dapat mengalokasikan database di sana.

Jika Anda tidak berhasil menghapus database lama, Anda dapat dengan cepat menyelesaikan ruang disk saat tes jatuh dan "lupa" untuk membebaskan sumber daya. Itu adalah "kapan," bukan "jika."

Opsi dua - kumpulan database / skema dengan layanan manajemen terpisah. Di luar API menonjol, berikan Basis untuk Waktu, ambil kembali Basis Gratis Sebelum Masa Berlaku. Apa yang akan kembali: server khusus dengan database atau skema kecil, tidak masalah. Yang utama adalah bahwa sumber daya tidak akan hilang selamanya.

Opsi tiga - kumpulan basis / skema untuk pengaturan sendiri. Sumber daya diperlukan untuk pertukaran informasi tentang kunci dan kumpulan database itu sendiri.

Saya memilih opsi yang terakhir, karena lebih mudah bagi saya untuk mengikatnya dan tidak terlalu diperlukan untuk mendukungnya. Logikanya adalah sebagai berikut - beberapa (10 misalnya) sirkuit dibuat dan semua informasi yang diperlukan tentang menghubungkan mereka ditambahkan ke sumber daya bersama, setiap contoh uji membuat tanda awal sebelum memulai, dan setelah akhirnya menghapusnya. Jika tes mogok sebelum menyelesaikan, sirkuit akan dianggap gratis di akhir waktu habis.

Pengaturan membaca:

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

Bekerja dengan sql dari skrip gradle memerlukan pemuatan driver:
  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()) } } } 

Koneksi:

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

Pertukaran informasi dapat dilakukan melalui tabel database. Inisialisasi tabel referensi (harus berfungsi satu kali, kemudian tabel hidup hingga akhir waktu):

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

Pengembang memiliki skema sendiri untuk debugging dan perakitan, sehingga penggunaan kumpulan harus dimatikan:

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

Ambil dan pangkalan gratis:

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

Jika Anda menyelesaikan perakitan 2 kali berturut-turut, skema yang berbeda dapat dipilih dan nilai yang berbeda akan tetap saat merakit file properti. Untuk peluncuran lokal, pengaturannya statis.

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

Tugas untuk menguji rollback:

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

Dan mengatur urutan eksekusi:

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

Alokasi basis data dan pengujian rollback dapat ditempatkan di dalam pengujian. Cara menjalankan kode sebelum dan sesudah semua tes selesai: di Junit5, ini adalah BeforeAllCallback, di TestNG BeforeSuite.

Untuk pertanyaan "mengapa sql harus diuji oleh programmer java" jawabannya adalah bahwa kode apa pun harus diuji. Ada pengecualian dan beberapa kode tidak rasional untuk diuji pada waktu tertentu.

Saya ingin tahu bagaimana masalah pengujian interaksi dengan database diselesaikan oleh programmer lain? Sudahkah wadah tiba di setiap rumah, atau apakah pengujian integrasi dilewatkan ke pundak penguji?

Daftar lengkap
 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/id452722/


All Articles