Представьте проект, в котором уже написано несколько сотен тестов. Каждый тест настраивает базу под себя: добавляет данные, вызывает truncate по окончанию. Хочется запускать тесты параллельно, чтобы ускориться, но если два теста запустить одновременно, они почти наверняка друг другу помешают.
В данной статье поделюсь подходом, который позволил решить проблему без рефакторинга.
Имеющиеся решения
Гугление и LLM-модели предложили следующие решения:
Писать тесты так, чтобы они не зависели от количества элементов в базе и не вызывали
truncate. Идея хорошая, но если уже есть легаси, переписывать 800+ тестов (именно столько было на проекте) — нереалистичный вариант.Запускать тесты в транзакции и откатывать транзакцию после выполнения. С таким решением знаком лично — проблемы возникают, когда проверить надо именно логику транзакций тестируемых функций. Например, иногда логика теста такова, что транзакция должна упасть, а это повлечет закрытие транзакции теста, что нам не нужно. Другой пример: тест может читать данные из разных потоков; пока висит транзакция без коммита, данные будут недоступны для других потоков.
Использовать in-memory db. Это решение не понравилось из-за того, что оно не универсально и подойдет не для любой базы данных. Настоящая база будет работать не так (или вообще не так), как база на тестах.
Поддерживать пул с базами данных. Понадобится изолировать доступ к каждой базе из пула, что наверняка потребует большого рефакторинга — в тестах поход в базу может быть где угодно: и из
before/after-Each, и из статики, и внутри кода.Создавать копию базы на каждый тест. Звучит как усложнение с просадкой по времени на тест, но именно эта идея натолкнула на решение, о котором будет статья.
Подход с распараллеливанием по процессам
Если кто-то создает копию базы данных на тест, почему бы не попробовать создать копию на процесс (операционной системы)?
Пример с конкретными цифрами для упрощения восприятия:
Мы разделим 800 тестов на 4 группы по 200 и запустим каждую группу в отдельном процессе. Каждый процесс будет работать со своей копией базы и запускать тесты последовательно. Параллельность достигается одновременным запуском 4-х процессов.
Когда тесты пройдут, мы запустим задачу на удаление созданных копий.
Пример решения с Gradle и JUnit
3 шага:
Делегируем Gradle запуск нескольких процессов.
Создаем копию базы в каждом процессе.
Делегируем 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.
Недостаток подхода — неоптимальная трата ресурсов: один процесс может закончить свою группу тестов быстрее других и висеть без дела.
На мой взгляд, для проекта с большим количеством тестов, которые меняют базу как хотят, предложенный подход является наиболее привлекательным.
Буду рад, если кто-то дополнит в комментариях.
