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

Комментарии (10)


  1. tbl
    21.05.2019 08:40

    Для Oracle Database нет решения, позволяющего поднять локальную базу на том агенте, где гоняются юнит-тесты без всяких контейнеров? Просто для mysql и postgresql есть такие решения, подключаешь в виде библиотеки к проекту и все готово:
    https://github.com/vorburger/MariaDB4j
    https://github.com/opentable/otj-pg-embedded
    https://github.com/yandex-qatools/postgresql-embedded


    1. tbl
      21.05.2019 08:51

      Кстати, нет смысла поднимать схему на каждый тест, достаточно поднять один раз при запуске юнит-тестов, а потом транкейтить таблицы между тестами. Если база для тестов поднята на диске, смапленном в память (tmpfs, например), то truncate table… cascade по всем таблицам выполняется моментально.


      1. kotbajan Автор
        21.05.2019 08:56

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


        1. kotbajan Автор
          21.05.2019 09:01

          Я описывал решение проблемы, когда тесты запускаются на jenkins параллельно (проверяют pull-реквесты) и каждому нужна своя база.


          1. tbl
            21.05.2019 09:37

            Если это возможно, то проще не завязываться на внешний пул баз/схем, и, если для oracle не подходит embedded-решение, то да, приведенное в статье решение будет одним из оптимальных.


    1. kotbajan Автор
      21.05.2019 08:54

      Embedded решений я ни 1 не видел. Есть H2 поддерживающая диалект оракла, но сильно не полноценно.


  1. alexprey
    21.05.2019 11:25

    Очень радует, что тестирование слоя взаимодействия с БД приобретает все большую популярность. Было дело и мы задались этим вопросом для связки C# + MySql. Но подход в инициализации данных использовали другой, хотелось обеспечить не только возможность параллельных сборок, но и параллельного запуска тестов. Да и кол-во схем баз данных, очень далеко не равно 1. Поэтому мы подошли к этому процессу несколько иначе. Можно тут почитать статью: https://habr.com/ru/company/arcadia/blog/304322/
    Очень порадовал момент с тестированием апдейтов и роллбеков.
    Интересует вопрос производительности еще


    1. kotbajan Автор
      21.05.2019 12:34

      Запускать параллельно тесты в рамках одной сборки — потратить время на создание структуры через liquibase, это накладнее по производительности. Сейчас по производительности бьет халатное отношение к ней. Если мне надо протестировать запрос на 1000 записей, у меня будет 1000 операций вставки. Объединение в batch сократило воемя выполнения одного из таких тестов с 17 сек до 1. Так же с ростом changelog-а будет увеличиваться время первого наката структуры.


      1. tbl
        21.05.2019 14:04

        Тесты можно в батчи склеивать, которые уже гонять параллельно


    1. kotbajan Автор
      21.05.2019 12:42

      В статье количество схем в пуле 10, без проблем сделать больше, просто этого хватает. В бою (не для статьи) используется не по 1 схеме на тест, а по несклько — в метаданных перечисляются схемы с разными префиксами, но одинаковыми id-суффиксами: Схема на заполнение БД и схема "пользователя без таблиц", куда только гранты на чтение выдаются.