Представьте проект, в котором уже написано несколько сотен тестов. Каждый тест настраивает базу под себя: добавляет данные, вызывает truncate по окончанию. Хочется запускать тесты параллельно, чтобы ускориться, но если два теста запустить одновременно, они почти наверняка друг другу помешают.

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

Имеющиеся решения

Гугление и LLM-модели предложили следующие решения:

  1. Писать тесты так, чтобы они не зависели от количества элементов в базе и не вызывали truncate. Идея хорошая, но если уже есть легаси, переписывать 800+ тестов (именно столько было на проекте) — нереалистичный вариант.

  2. Запускать тесты в транзакции и откатывать транзакцию после выполнения. С таким решением знаком лично — проблемы возникают, когда проверить надо именно логику транзакций тестируемых функций. Например, иногда логика теста такова, что транзакция должна упасть, а это повлечет закрытие транзакции теста, что нам не нужно. Другой пример: тест может читать данные из разных потоков; пока висит транзакция без коммита, данные будут недоступны для других потоков.

  3. Использовать in-memory db. Это решение не понравилось из-за того, что оно не универсально и подойдет не для любой базы данных. Настоящая база будет работать не так (или вообще не так), как база на тестах.

  4. Поддерживать пул с базами данных. Понадобится изолировать доступ к каждой базе из пула, что наверняка потребует большого рефакторинга — в тестах поход в базу может быть где угодно: и из before/after-Each, и из статики, и внутри кода.

  5. Создавать копию базы на каждый тест. Звучит как усложнение с просадкой по времени на тест, но именно эта идея натолкнула на решение, о котором будет статья.

Подход с распараллеливанием по процессам

Если кто-то создает копию базы данных на тест, почему бы не попробовать создать копию на процесс (операционной системы)?

Пример с конкретными цифрами для упрощения восприятия:

Мы разделим 800 тестов на 4 группы по 200 и запустим каждую группу в отдельном процессе. Каждый процесс будет работать со своей копией базы и запускать тесты последовательно. Параллельность достигается одновременным запуском 4-х процессов.

Когда тесты пройдут, мы запустим задачу на удаление созданных копий.

Пример решения с Gradle и JUnit

3 шага:

  1. Делегируем Gradle запуск нескольких процессов.

  2. Создаем копию базы в каждом процессе.

  3. Делегируем Gradle запуск класса с очисткой базы.

Запуск нескольких процессов

Gradle сам умеет форкать процессы и делить имеющиеся тесты между ними. Нужно в build.gradle модуля в "таске" test указать maxParallelForks.

test {
    useJUnitPlatform() 

    maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1
    // Другой вариант — создавать новый процесс каждые N тестов:
    // forkEvery = 50 
}

Я поигрался с различными вариантами на рабочем проекте, и остановился на maxParallelForks с половиной от имеющихся CPU. Это оказалось быстрее всего и на CI, и локально.

Создание копии базы внутри процесса

Создать копию базы с уникальным именем можно через добавление к имени базы Process id. В доках Gradle можно найти рекомендацию использовать System.getProperty("org.gradle.test.worker") как уникальный ID на процесс.

Другой вариант — использовать библиотеку TestContainers.

Ниже привожу пример без использования сторонних инструментов

CreateDbCommand.kt
const val dbHost = "localhost:5432"
const val dbName = "parallel_tests"
const val dbUser = "parallel_tests"
const val dbPassword = "parallel_tests"

private val pid = ProcessHandle.current().pid()
private val newDbName = "${TestDbSettings.dbName}$pid"

// Возвращает имя созданой базы
fun createDb(): String {
    println("Creating DB with PID: $pid")

    val dbUrl = "jdbc:postgresql://$dbHost/postgres"
    DriverManager.getConnection(dbUrl, dbUser, dbPassword).use { conn ->

        // Если эта функция по ошибке вызовется второй раз,
        // например, мы вызывали это из синглтона, который по какой-то причине почистился GC,
        // мы не хотим упасть на попытке создать базу с тем же именем
        val exists = conn
            .prepareStatement("SELECT FROM pg_database WHERE datname = '${newDbName}';")
            .executeQuery()
            .next()

        if (exists) {
            println("DB $newDbName already exists")
            return newDbName
        }

        // Если мы уже подключены к $DB_NAME, то `TEMPLATE $DB_NAME` ниже не сработает:
        // Вылетит ошибка про то, что template db не может быть использована, пока есть активные соединения
        conn.prepareStatement(
            """
            SELECT pg_terminate_backend(pg_stat_activity.pid)
            FROM pg_stat_activity
            WHERE pg_stat_activity.datname = '${dbName}'
            AND pid <> pg_backend_pid();
            """.trimIndent()
        ).execute()

        conn.prepareStatement(
            "CREATE DATABASE $newDbName WITH TEMPLATE $dbName OWNER ${dbUser};"
        ).execute()
        return newDbName
    }
}


object DB {
    val dbName = createDb()
    val url = "jdbc:postgresql://${dbHost}/$dbName"

    // Через это соединение с базой будем выполнять тестовые запросы
    val connection = DriverManager.getConnection(url, TestDbSettings.dbUser, TestDbSettings.dbPassword)
}

Очистка базы данных от копий

В build.gradle регистрируем “таску”, которая будет запускать Kotlin-класс и привязываем ее к окончанию тестов:

tasks.register('testCleanup', JavaExec) {
    classpath = sourceSets.test.runtimeClasspath
    mainClass.set('setup.CleanDbCopiesCommandKt')
}


test {
    useJUnitPlatform()
    maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1
    finalizedBy testCleanup
}
CleanDbCopiesCommand.kt
import java.sql.DriverManager

const val dbHost = "localhost:5432"
const val dbName = "parallel_tests"
const val dbUser = "parallel_tests"
const val dbPassword = "parallel_tests"

fun main() {
    val dbUrl = "jdbc:postgresql://$dbHost/postgres"
    DriverManager.getConnection(dbUrl, dbUser, dbPassword).use { conn ->
        try {
            conn.prepareStatement(
                """
                SELECT pg_terminate_backend(pg_stat_activity.pid)
                FROM pg_stat_activity WHERE pg_stat_activity.datname = '$dbName'
            """.trimIndent()
            ).execute()
        } catch (e: java.lang.Exception) {
            println("Can't close running connections")
        }

        val stmt = conn.createStatement()
        val commandName = "command"

        // получаем имена копий базы, чтобы затем удалить их:
        val rs = stmt.executeQuery(
            """
            SELECT 'DROP DATABASE IF EXISTS ' || quote_ident(datname) || ';' as $commandName
            FROM pg_database 
            WHERE datname ~ '^$dbName[0-9]+${'$'}';
        """.trimIndent()
        )

        while (rs.next()) {
            val command = rs.getString(commandName)
            try {
                conn.createStatement().use { statement ->
                    statement.execute(command)
                    println("Executed: $command")
                }
            } catch (e: Exception) {
                println("Error executing command: $command. Error: ${e.message}")
            }
        }
        rs.close()
        stmt.close()
    }
}

Show me the code!

Вот проект. Для запуска необходим докер:

# стартуем базу
make docker-up

# запускаем тесты
./gradlew :test 

По логам можно увидеть, что создаются копии базы в разных процессах, прогоняются тесты, копии удаляются:

> Task :test

DbTest2 > test2() STANDARD_OUT
    Creating DB with PID: 22893

DbTest1 > test1() STANDARD_OUT
    Creating DB with PID: 22892

DbTest2 > test2() PASSED

DbTest1 > test1() PASSED

> Task :testCleanup
Executed: DROP DATABASE IF EXISTS parallel_tests22893;
Executed: DROP DATABASE IF EXISTS parallel_tests22892;

Пример решения с другими технологиями

Maven, JMV языки

В maven все то же самое, только используем forkCount вместо maxParallelForks:

<forkCount>3</forkCount>

Leiningen, Clojure

Один вариант — добавлять метадату к тестам:

(deftest ^:db-group-1 testing-database
  (is (= 1 1)))

И запускать тесты с метадатой в разных процессах:

lein test :only :db-group-1 

Резюме

Предложенное решение с запуском тестов в разных процессах ускоряют прогон в несколько раз (в зависимости от ресурсов машины) и при этом практически не требует затрат на рефакторинг. Проект, на котором я это реализовал, локально ускорился в 2 раза, на CI — примерно в 4.

Недостаток подхода — неоптимальная трата ресурсов: один процесс может закончить свою группу тестов быстрее других и висеть без дела.

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

Буду рад, если кто-то дополнит в комментариях.

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


  1. votez
    05.12.2023 10:56

    testcontainers как раз для изоляции тестов и придумали - все другие сценарии только если по каким-то причинам не подходит этот.
    Параллельно тесты запускаются стандартными средствами JUnit 5 со свойством
    junit.jupiter.execution.parallel.mode.classes.default = concurrent

    а для доступа к неразделяемым ресурсам используется аннотация ResourceLock на тестах.


    1. arturdumchev Автор
      05.12.2023 10:56

      По поводу изоляции. Мне попадались примеры с testcontainers, когда делают копию базы на тест. Вы об этом? Я в статье написал вкратце в "Имеющиеся решения", 5* пункт.

      > ResourceLock

      Если всем 800 тестам нужна база (пример из проекта), то это не рановсильно последовательному запуску?


      1. StanislavL
        05.12.2023 10:56

        Нет, конечно. На каждый тест создается докер с базой, накатывается необходимый SQL или как ваариант у нас сразу image с базой со всеми таблицами и начальными данными.
        При CI запускается много тестов одновременно каждый со своей копией


        1. arturdumchev Автор
          05.12.2023 10:56

          На каждый тест создается докер с базой

          Вот, про это я писал в "имеющихся решениях", что создать 800+ копий базы — дорого. У нас NCPU и большей параллельности добиться не получится, поэтому я предложил создавать N процессов и N копии базы.

          N — на моем рабочем маке 4, на CI — 8.


          1. votez
            05.12.2023 10:56
            +1

            у вас задача быстро выкатить решение - вы его и пилите. Это не значит, что оно правильное. Ваш подход пока работает, но нарушает принцип изоляции тестов, что может привести к недетерминированному поведению тестов, за что вас проклянут потомки. Работает - ну и ладно, но если строить решение не оглядываясь на вечер пятницы, то это неверный подход. Но мы все понимаем, что "правильно" оторвано от реальности - если устраивает, то и хорошо.
            Делать 800 копий базы - это канонично. База не должна быть большой для тестов, если это не специализированные тесты "проверим как там прод". Докеры легкие - контейнеры докера в принципе отличаются только данными, запихиваемыми в базу, так как уровни докера (не знаю, как по-русски) это разделяемая память.
            И делать 800 в параллель не надо - жюнит настраивается, сколько тестов гнать в параллель.
            Разделяемые ресурсы с локами - это не базы. Это если у вас недоинтеграционные тесты, например, часть из которых завязана на какой-нибудь внешний (необязательно) UserManagementService - вот эти тесты помечаем как нуждающиеся в локе и они не будут выполняться конкурентно (то есть будут только с теми, что не помечены)


            1. arturdumchev Автор
              05.12.2023 10:56

              но нарушает принцип изоляции тестов, что может привести к недетерминированному поведению тестов, за что вас проклянут потомки

              А потом узнают, что можно добавить одну строчку в gradle:

              forkEvery = 1

              И заберут проклятья обратно.

              Ну а если без шуток, я бы проверил вот это заявление про "Докеры легкие".
              Если каждый тратит 1 секунду на тест, то для 800 тестов — это уже 13 минут. Сейчас с моим решением тесты около того и бегут.


            1. arturdumchev Автор
              05.12.2023 10:56

              А смотрим, изоляция в тестах нужна, по сути, для одного: чтобы выполнение одного теста не аффектило выполнение другого.

              Такое может произойти по двум причинам:

              1. Два теста меняют shared-ресурс (cache, db) одновременно;

              2. Один тест оставляет стейт, который мешает другому (например, из-за мемоизации).

              Testcontainers помогают решить "1", но и подход, который я предложил, это так же решает. Т.к. в рамках одного процесса тесты выполняются последовательно (не будет одновременного доступа к базе).

              Testcontainers не помогут решить "2", а `test.forkEvery = 1`, например, — поможет, т.к. каждый тест в своем процессе. Я не хочу сказать, что предлагаю так делать, просто рассуждаю.

              С Testcontainers придется думать о том, как создаются сущности в приложении. Например, как создается connection pool. Если он уже singlton (так было в проекте, когда я брался за задачу), то придется переписывать. В подходе, который я предложил, нет параллельности в рамках процесса: т.е. не нужны ни логи, ни аннотации куда-то добавлять — вообще ничего. И тащить дополнительную зависимость в проект не нужно.


  1. k-semenenkov
    05.12.2023 10:56
    +1

    Использую в чем-то похожий подход, добавлю свои пять копеек. Мне помогло, для кого-то может это и неприменимо.

    1. В предложенном варианте я так понимаю базы копятся до конца и удаляются все в конце. При большом количестве тестов и ограниченности места это может быть проблемой. Я у себя эти базы удаляю в фоне сразу как они становятся не нужными (удаление старых баз не должно блокировать создание новых).

    2. По возможности тесты запускаются с рам-драйва и базы создаются там же. И скорости прибавляет, особенно создание баз, и насилия на ссд меньше.

    3. Не все тесты мешают друг другу, и когда время выполнения становится критическим - тесты можно группировать, чтоб создать одну базу под несколько кейсов. Как правило создать одну базу с двумя кучками объектов под два теста быстрее чем создать последовательно создать две базы, по кучке на каждый тест (тут внимательный читатель скажет "а как же параллельность" и я должен признать что у меня с ней почти никак из-за того что тестируется GUI, основная схожесть моего подхода с описанным в статье в создании базы на тест)

    4. Просто полезная вещь - иметь выключатель не удаяющий базу для упавшего теста. Чтоб потом подключиться к этой базе и посмотреть почему упало.

    5. Если поддерживаются разные дбмс и для тестов не специфичных для конкретных дбмс: новые тесты пишу вначале под летающий SQLite - быстрее чинятся ошибки в самом тесте, потом портируется под другие дбмс

    6. Тоже не связанное с постгресом - оказывается нельзя просто так взять и получить новую базу через единственный CREATE DATABASE стейтмент в Оракле. Конкретно под Оракл пришлось менять подход на создание временного пользователя/схемы. Вначале я думал что я чего-то просто не знаю, но потом наткнулся на исходники одного из dbfiddle и там товарищи делали так же.


    1. arturdumchev Автор
      05.12.2023 10:56

      1. ... базы копятся до конца
      3. ... тесты можно группировать, чтоб создать одну базу под несколько кейсов. 

      В подходе, что я предлагаю, сразу создается 4 базы (если 8 CPU), и дальше каждый процесс (всего их 4) запускает последовательно свою группу тестов.

      4. Просто полезная вещь - иметь выключатель не удаяющий базу для упавшего теста. Чтоб потом подключиться к этой базе и посмотреть почему упало.

      В примере, что я привел, можно закомментировать (или еще лучше — завязать на флаг) finilizedBy.

      Еще в статье не написал, но в примере проекта так сделал — докер образу с постгресом передал флаг, чтобы печатал все логи по

        postgres:
          image: postgres:11
          command: -E # prints all the statement; slow but useful for debugging
      


  1. gnomeby
    05.12.2023 10:56
    +2

    Пробовали так делать в Wargaming. Тесты в PostgreSQL. В результате пришли к выводу, что усложнение слишком существенно, а прирост скорости в ~ 2-3 раза, а когда у тебя 9 минут все тесты, то игра не стоит свеч. Гораздо проще ускорить запуск тестов, что и было сделано:

    1. Запрофилировали все тесты, нашли самые долгие, ускорили.

    2. Ускорили всю работу с БД, для этого на локальных тачках отключили fsync, а потом и вовсе научились создавать БД под тесты, используя отдельный in-memory template, который клал БД в отдельное место, которое было в tmpfs. Вторую оптимизацию применили и на сервере, который тестировал PR.

      На выходе: Получили тоже ускорение в 2-3 раза, но всё поведение БД было как в реальном проде. И без необходимости подкладывать костыли при изменении тестов.


    1. arturdumchev Автор
      05.12.2023 10:56

      А почему усложение существенное? Там по тестам — 2 файла дополнительно пишутся и несколько строк в build.gradle.

      Я добавил на проект, где сейчас работаю, никто даже не заметил ничего, разве что тесты стали быстрее прогоняться.

      Ускорением медленных тестов тоже скоро займусь. Начал с распараллеливания, потому что показалось, что это самый простой вариант.


      1. gnomeby
        05.12.2023 10:56

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

        А вот ещё. Как-то мы тогда сделали, что разработчики бы заметили, что тесты запустятся параллельно. Проект местами был тяжёлый, использовался PostGIS, и бывали случаи падения по ресурсам от излишней параллельности. 8 лет назад ресурсы ещё были не бесконечны. Сегодня даже на ноутах я думаю мы бы справились.


        1. arturdumchev Автор
          05.12.2023 10:56

          А разбивать её тесты нельзя было, нарушалась удобная навигация по проекту.

          Gradle вроде рандомно выбирает тесты, самому ничего разбивать по папкам не надо.

          бывали случаи падения по ресурсам

          Тут тоже, на CI 16gb ram давалось на тесты. Но это решилось нахождением утечки памяти.


          1. gnomeby
            05.12.2023 10:56

            Gradle вроде рандомно выбирает тесты, самому ничего разбивать по папкам не надо.

            У нас было сложнее. Тесты разбиты на TestSuite`ы и только он может поднять нужную игровую ситуацию. А соответственно есть очень жирные TestSuite, которые дробить нежелательно.


  1. uDezimiro
    05.12.2023 10:56

    А у нас бд snowflake. Никаких контейнеров. Вот буду делать для нее распаралеливанте. Кто сталкивался с такой задачей, поделитесь опытом


    1. arturdumchev Автор
      05.12.2023 10:56

      Я привел код с контейнерами, но по сути, мало что изменилось бы, если бы база была локально установлена. Так же бы создавал копии базы и чистил. Думаю, snowflake в этом плане ничем не отличается: погуглил, попалось Zero Copy Clone Snowflake, можно в эту сторону покопать.