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