المقالة حول اختبار التفاعل مع قاعدة البيانات في CI. لقد رأيت العديد من الحلول التي تستخدم عامل النقل والحاملين للاختبار ، لكن لدي حلمي وأريد مشاركته.
تم ربط مشروع جافا الماضي الخاص بي ارتباطًا وثيقًا بقاعدة بيانات. معالجة طويلة مع إعادة المحاولة ، multithreading ، وأقفال القفل. بالنسبة للمهمة ، كان مطلوبًا تصحيح بضع استعلامات SQL صعبة. كنت معتادًا على تغطية الكود الخاص بي مع الاختبارات ، لكن قبل ذلك تم تحويل كل SQL إلى استعلامات بدائية ويمكن تشغيلها على قاعدة H2 في الذاكرة. وهنا على المتشددين.
يمكنني اختبار لغة الاستعلامات البنيوية البسيطة بيدي ومطرقة الجبان في الاختبارات الذاتية ، مبررًا نفسي "لست نوعًا من الباغودل ، هناك أخطاء في الكود البسيط." في الواقع ، تظهر الأخطاء في كثير من الأحيان أقل بسبب توافر الاختبارات. كان من الممكن دفع المسؤولية إلى المختبرين - إذا ارتكبت خطأ في مكان ما ، فسوف يجدون ذلك.
وفقًا لإيديولوجية اختبار الوحدة ، لا يلزم إجراء اختبارات إلا لاختبار الوحدات الفردية ، وإذا كانت الوحدة تستخدم شيئًا من الخارج ، فيجب استبداله بكعب. في الممارسة العملية ، عندما يصبح من الصعب للغاية تنفيذ روتين ، يتم تجاهل الوحدة ببساطة. تعد السرعة والوحدات النمطية أمران مهمان ، لكن من المهم عدم ترك الكود بدون اختبار ، حتى لو لم يظهر في مقاييس التغطية. لذلك ، لم تعد تعتبر الوحدة النمطية فئة منفصلة ، ولكن مجموعة متصلة ، جنبا إلى جنب مع التكوين. الشيء الرئيسي في مثل هذه العبارات هو عدم الوصول إلى مفهوم الوحدة التي تمتد إلى مستوى الكتلة.
للاختبار المحلي ، لدى كل مطور خطة خاصة به ، ولكن يتم تشغيل الاختبارات على Jenkins ولا يوجد اتصال بقاعدة البيانات هناك حتى الآن. يحتاج CI إلى دائرة منفصلة ، ويبدو أنه واضح. لكن على الرسم التخطيطي الفارغ ، ليس من الصحيح للغاية إجراء الاختبارات ؛ إن إنشاء بنية قاعدة بيانات في كل اختبار يستغرق وقتًا طويلاً وينطوي على تباين بين الهيكل في الاختبار وفي المعركة. ركض على قاعدة معدة مسبقًا - أحصل على مجموعة من المشكلات في الفروع. يمكنك إعداد قاعدة البيانات قبل تشغيل جميع الاختبارات باستخدام قاعدة بيانات تسييل ، ومسح كل شيء أولاً إلى الصفر ، ثم التحديث إلى أحدث إصدار.
غالبًا ما يتم نسيان الاستعادة ويجب عليك تنظيف القواعد بيديك على بيئات الاختبار. اختباره وله! الخوارزمية هي كما يلي:
- إزالة كل شيء تحت الجذر (لنقاء التجربة)
- التحديث إلى أحدث إصدار
- أداء التراجع 1-2 الإصدارات مرة أخرى
- التحديث إلى أحدث إصدار (يلزم إجراء الاختبارات على بنية قاعدة البيانات الجديدة ، بالإضافة إلى التحقق من أن الاستعادة لم تنس حذف أي شيء ، مما سيمنع التحديث من العودة مرة أخرى)
لا يريد المطورون الزميلون تشغيل اختبار الاستعادة عند بدء كل اختبار. نحن جعل التبديل.
project.ext.doRollbackTest = { Boolean.parseBoolean(localConfig['test.rollback.enabled'] as String) }
بينما هناك تدريب على القطط ، كل شيء على ما يرام. لكن أحد المشروعات التي يتم تطويرها ديناميكيًا يجعل التعديلات الخاصة به - عمليتان للسحب ، تجميع متزامن. مثال واحد هو اختبار شيء ما ، والثاني هو ضرب القاعدة من تحت قدميها. يتم حلها عن طريق فرض حظر بسيط على الجمعيات الموازية.

ومرة أخرى السقوط - قررت إجراء اختبارات باستخدام حساب جينكينز ، لأنه كل شيء على ما يرام على واحد الشخصية ، ومجموعة من الطلبات يقع لأسباب غير واضحة. نحن نتذكر الحديث الحاد بأن DevOps هي ثقافة وأن استخدام الحسابات التقنية لأغراض شخصية أمر غير مقبول.

تراكمت 10 مجموعة من الطلبات. جميع جمعها ، وإعادة توزيعها ، يمكنك دمج. أول واحد ذهب ، تغير الفرع الرئيسي - والباقي يقف في طابور لإعادة البناء. يمكنك دمج كلما تقدمت ، لكن هناك أولويات. عمليات سحب أكثر إلحاحًا ، أقل إلحاحًا ، يتم تعليقها أيضًا بسبب أخطاء في الكود. بشكل عام ، موازاة الظهر.
يجب أن يكون التجميع بسيطًا ، ويتألف من خطوات بسيطة ويكون مفهوما حتى بالنسبة لطالب الأمس. في 99 ٪ لا توجد مشكلة في أن تجميع تجمع الطلب والإصدار متسلسل وليس متوازيًا. إذا لم تتراكم المراجعة أكثر من 1-2 العلاقات العامة ، فإن حظر التجميعات المتزامنة يكون كافياً.
ولأغراض الإطلاق المتوازي ، نحتاج إلى قواعد أو مخططات يكون لكل اختبار تم تشغيله وصولاً حصريًا إليها.
الخيار الأول هو تخصيص ديناميكي. إنشاء مخطط في قاعدة البيانات بسرعة. وجود سحابة مع API ، يمكنك تخصيص قاعدة بيانات هناك.
إذا لم تنجح في حذف حذف قواعد البيانات القديمة ، يمكنك إنهاء مساحة القرص بسرعة عندما تسقط الاختبارات و "تنس" لتحرير الموارد. إنه "متى" وليس "إذا".
الخيار الثاني - تجمع قاعدة بيانات / مخطط مع خدمة إدارة منفصلة. في الخارج ، تتمسك واجهة برمجة التطبيقات (API) وتعطي القاعدة للوقت وتسترد القاعدة الحرة قبل المصطلح. ما سيعود: خادم مخصص مع قاعدة بيانات أو مجرد مخطط قليلا ، لا يهم. الشيء الرئيسي هو أن المورد لن تضيع إلى الأبد.
الخيار الثالث - مجموعة من القواعد / المخططات للتنظيم الذاتي. هناك حاجة إلى مورد لتبادل المعلومات حول الأقفال وتجمع قاعدة البيانات نفسها.
لقد استقرت على الخيار الأخير ، لأنه من الأسهل بالنسبة لي أن أربطه وليس هناك حاجة إلى دعمه بشكل خاص. المنطق على النحو التالي - يتم إنشاء عدة (10 دوائر على سبيل المثال) ويتم إضافة جميع المعلومات اللازمة حول الاتصال بها إلى المورد المشترك ، كل مثيل اختبار قبل البدء يجعل علامة البدء ، بعد النهاية - يحذفها. إذا تعطل الاختبار قبل الانتهاء ، فسيتم اعتبار الدائرة مجانية في نهاية المهلة.
إعدادات القراءة:
project.ext.localConfig = new Properties() localConfig.load(file("${rootDir}/local.properties").newReader())
العمل مع 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
إذا قمت بإكمال التجميع مرتين على التوالي ، يمكن تحديد أنظمة مختلفة وستظل قيم مختلفة عند تجميع ملفات الخصائص. بالنسبة لعمليات الإطلاق المحلية ، تكون الإعدادات ثابتة.
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 } }
تخصيص قاعدة البيانات واختبار الاستعادة يمكن وضعها داخل الاختبارات. طرق تشغيل التعليمات البرمجية قبل وبعد الانتهاء من جميع الاختبارات: في Junit5 ، هذا هو BeforeAllCallback ، في TestNG BeforeSuite.
بالنسبة إلى السؤال "لماذا يجب اختبار sql بواسطة مبرمج java" ، فإن الإجابة هي أنه يجب اختبار أي كود. هناك استثناءات وبعض الكود غير منطقي للاختبار في وقت معين.
أود أن أعرف كيف يتم حل مشكلة اختبار التفاعل مع قاعدة البيانات بواسطة مبرمجين آخرين؟ هل وصلت الحاويات إلى كل منزل ، أم أن اختبار الاندماج ينتقل إلى أكتاف المختبرين؟
قائمة كاملة 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