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

от автора

Представьте проект, в котором уже написано несколько сотен тестов. Каждый тест настраивает базу под себя: добавляет данные, вызывает 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.

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/778580/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *