Как распараллелить тесты с базой данных

3c2c51fce7dc3baaeb6aa7339bc007fa.png

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

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

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

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

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

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

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

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

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

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

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

Мы разделим 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.

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

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:

3

Leiningen, Clojure

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

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

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

lein test :only :db-group-1 

Резюме

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

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

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

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

© Habrahabr.ru