Pull to refresh

Подружить CI, unit-тесты и базу данных

Reading time 11 min
Views 4.9K
image

Статья про тестирование взаимодействия с БД в CI. Я видел несколько решений использующих docker и testcontainers, но у меня есть своё и им я хочу поделиться.

Мой прошлый java проект был тесно завязан на базу данных. Длительная обработка с повторами попыток, многопоточность и выборки с блокировками. Для задачи требовалось поправить парочку хитрых SQL запросов. Я как-то привык покрывать тестами свой код, но до этого весь SQL сводился с примитивным запросам и его можно было погонять на H2 базе в памяти. А тут хардкор на оракле.

Простой SQL я бы мог протестировать руками и малодушно забить на автотесты, оправдываясь перед собой «я же не какой-нибудь багодел, ошибки в простом коде допускать». Собственно, ошибки и появляются реже по причине наличия тестов. Можно было спихнуть ответственность на тестировщиков — если я где-то ошибся, они найдут.

image

Согласно идеологии unit-тестирования, тесты нужны только для тестирования отдельных модулей, а если модуль использует что-то извне, то это надо заменить на заглушку. На практике, когда заглушку реализовать становится слишком сложно, модуль просто игнорируется. Скорость и модульность важны, но важнее не оставлять не проверенным код, пусть он и не отображается в метриках покрытия. Потому модулем начинают считать уже не отдельный класс, а связную группу, вместе с конфигурацией. Главное, с такими утверждениями не дойти до растягивания понятия unit до уровня кластера.

Для локального тестирования у каждого разработчика есть своя схема, но тесты запускаются на Jenkins и там подключения к БД пока нет. Для CI нужна отдельная схема, это вроде очевидно. Но на пустой схеме тесты запускать не очень правильно, создавать структуру БД в каждом тесте и по времени накладно и чревато расхождением структуры в тесте и в бою. Запускать на предварительно подготовленной базе — получу ворох проблем с ветками. Подготавливать БД перед запуском всех тестов можно с помощью liquibase, сначала очищая все под ноль, а затем обновляя до последней версии.

Rollback часто забывают доработать и приходится на тестовых средах руками чистить базы. Протестируем-ка и его! Алгоритм получается следующий:

  1. удалить все под корень (для чистоты эксперимента)
  2. обновить до последней версии
  3. выполнить rollback на 1-2 версии назад
  4. обновить до последней версии (тесты надо на новой структуре БД гонять, плюс проверка, что rollback не забыл удалить ничего, что помешает повторному накату обновления)

Коллеги разработчики не хотят при запуске каждого теста запускать тестирование rollback. Делаем рубильник.

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

Пока идет тренировка на кошечках все ок. Но динамично развивающийся проект вносит свои коррективы — 2 пулреквеста, одновременная сборка. Один инстанс что-то тестирует, а второй выбивает у него из под ног базу. Решается простым запретом параллельных сборок.

image

И снова падение — решил прогнать тесты с использованием учетки Jenkins, т.к. на личной все ок, а пул реквесты падают по не ясным причинам. Вспоминаем пылкие речи о том, что DevOps — это культура и недопустимо использование технических учеток в личных целях.

image

Накопилось 10 пул реквестов. Все собраны, отревьювлены, можно сливать. Первый пошел, изменилась основная ветка — остальные дружно встают в очередь на пересборку. Можно сливать по мере прохождения, но есть же приоритеты. Более срочные пулреквесты, менее срочные, есть и зависающие из-за ошибок в коде. В общем — распараллелить обратно.

Сборка должна быть простой, состоять из простых шагов и быть понятна даже вчерашнему студенту. В 99% нет проблем в том, что сборки пул реквестов и релизов идут последовательно, а не параллельно. Если на ревью не накапливается больше 1-2 PR, то вполне достаточно запрета одновременных сборок.

А для параллельного запуска нам нужны базы или схемы, к которым у каждого запущенного теста будет монопольный доступ.

Вариант первый — выделять динамически. Создание схемы в БД происходит быстро. Имея облако с API можно выделять БД там.

Если не проработать удаление старых баз, можно быстро прикончить место на дисках, когда тесты будут падать и «забывать» освободить ресурсы. Именно «когда», а не «если».

Вариант второй — пул баз / схем с отдельным сервисом по управлению. Наружу торчит API дайБазуНаВремя, забериОбратноСвободнуюБазуРаньшеСрока. Что оно будет возвращать: выделенный сервер с БД или только схемку, не важно. Главное, что ресурс не будет безвовратно утрачен.

Вариант третий — пул баз / схем на саморегуляции. Необходимы ресурс для обмена информацией о блокировках и сам пул баз.

Я остановился на последнем варианте, поскольку мне его проще прикрутить и поддерживать особенно не требуется. Логика следующая — создается несколько (10 например) схем и на общем ресурсе складывается вся необходимая информация о подключении к ним, каждый инстанс теста перед запуском делает отметку старта, после окончания — удаляет её. Если тест упал, не успев финализироваться, схема будет считаться свободной по окончанию таймаута.

Чтение настроек:

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

Работа с sql из gradle скриптов требует загрузку драйвера:
    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 // 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)
            }
        }
    }

Если выполнить сборку 2 раза подряд, могут быть выделены разные схемы и при сборке property-файлов останутся разные подставленные значения. Для локальных запусков настройки статичны.

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

Таски для тестирования 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
        }
    }

И настроить порядок выполнения:

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

Выделение базы и тестирование rollback можно поместить внутрь тестов. Способы запуска кода перед и после выполнения всех тестов: в 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 // 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

Tags:
Hubs:
+2
Comments 10
Comments Comments 10

Articles