Разрабы выкатили фикс и нужно прогнать регресс на стенде. Открываешь CI, запускаешь тесты и спустя каких-то пару часов можно понять катим мы релиз или нет. Деплой заблокирован, коллеги ждут мердж, а ты — тесты. А если нужно прогонять регресс каждый день или несколько раз в день?
Стандартный совет в таких случаях — “запусти тесты параллельно”.
Окей, а как именно запускать?
В JUnit5 свой механизм распараллеливания. В Gradle — свой. А еще можно комбинировать. И конечно можно просто все сломать, если тесты начнут гонку за общие ресурсы.
Я провёл серию экспериментов: 5 видов сценариев, 4 режима запуска, 3 итерации для верности. Измерял не только скорость, но и корректность, чтобы не получилось “печатаю очень быстро, но получается какая-то ерунда”.
В этой статье покажу:
-
как настроить паралеллизм в JUnit5 и Gradle (с примерами конфигов);
-
что ломается, если включить параллелизм без подготовки;
-
как изолировать БД и файловую систему для параллельных тестов;
-
какой режим дает лучший баланс скорости и надежности.
Только JUnit5 + Gradle. Без Maven, без TestNG, без специфики CI-платформ.
TL;DR
Для самых нетерепеливых — сводная таблица результатов. Прогон 50 тестов по 6 секунд каждый.
|
Сценарий |
Последовательно |
JUnit (1×4) |
Gradle (4×1) |
Hybrid (2×2) |
|---|---|---|---|---|
|
Чистые тесты |
302 с |
79 с (3.80x) |
91 с (3.29x) |
79 с (3.78x) |
|
БД (без транзакций) |
302 с |
7 с (FAIL) |
69 с (FAIL) |
7 с (FAIL) |
|
БД (с транзакциями) |
302 с |
79 с (3.80x) |
92 с (3.29x) |
80 с (3.78x) |
|
Файлы (без синхр.) |
302 с |
79 с (flaky) |
92 с (FAIL) |
79 с (FAIL) |
|
Файлы (с синхр.) |
301 с |
79 с (3.80x) |
92 с (3.28x) |
79 с (3.79x) |

Если коротко:
-
Hybrid 2×2 (2 процесса x 2 потока) — универсальный выбор. Стабильно ~3.8x на всех сценариях.
-
Включить параллелизм без изоляции ресурсов = гарантированные падения или, что хуже, скрытая потеря данных.
-
Gradle “честнее” JUnit в некоторых тестах и чаще падает, там где JUnit может прятать проблему.
Если интересны подробности, то поехали.
Потоки и процессы: два уровня параллелизма
Прежде чем крутить настройки попробуем вспомнить как это устроено в Java-мире. Паралельный запуск тестов может работать на двух уровнях, и они принципиально отличаются.
JUnit 5 Parallel Execution — потоки внутри одной JVM. Тесты живут внутри одного процесса, делят общую кучу (heap) и видят одни и те же статические переменные. Затраты минимальные. Поток создать дёшево, но и изоляция тоже на минимуме.
Gradle maxParallelForks — отдельные JVM-процессы. Gradle запускает n-копий Java-машины и каждая получает свою порцию тестовых классов. Полная изоляция памяти, но запуск каждой JVM будет потреблять ресурсы.
Гибридный режим — совмещение описанных выше вариантов. Запускается несколько процессов и внутри каждого процесса запускаются потоки. Выглядит это примерно вот так:
[Gradle]├── JVM-процесс #1 (maxParallelForks)│ ├── JUnit-поток A ─── Test1.test1()│ └── JUnit-поток B ─── Test1.test2()│└── JVM-процесс #2 ├── JUnit-поток C ─── Test2.test1() └── JUnit-поток D ─── Test2.test2()
Каждый вариант имеет свои плюсы и минусы. Но в целом картина примерно следующая
|
|
JUnit (потоки) |
Gradle (процессы) |
|---|---|---|
|
Изоляция памяти |
Нет |
Полная |
|
Static-поля |
Общие |
Отдельные в каждой JVM |
|
Файловая система |
Общая |
Общая |
|
БД |
Общая |
Общая |
|
Затраты |
Минимальные |
Ресурсы на запуск JVM |
|
Лучше для |
Чистых unit-тестов |
Интеграционных тестов |
Как это работает под капотом
Прежде чем переходить к настройкам попробуем посмотреть как это должно работать в теории на уровне JVM. Без этого можно насобирать грабель.
Потоки в JVM — это потоки операционной системы
Каждый Thread в Java — это настоящий поток в операционной системе (классическая модель 1:1, которая используется в HotSpot). Когда JUnit запускает 4 параллельных теста, то ОС создаёт 4 реальных потока и планировщик ОС распределяет их по ядрам CPU.
Мы действительно получаем параллелизм, а не просто конкурентность. Если конечно не запускаем потоков больше, чем у нас ядер у процессора. Когда потоков все же больше, чем ядер, то ОС переключается между ними. Каждое переключение это сохранение и восстановление состояния. Это и есть наши затраты.
Общая память
Потоки внутри одной JVM делят одну кучу (heap) и одно пространство классов (Metaspace). Из этого вытекает несколько неприятных следствий.

Static-поля общие для всех потоков. Если один тест пишет в static int counter, то другой поток видит изменения. Правда без гарантий. Java Memory Model (JMM) определяет правила happens-before: без volatile, synchronized или Lock один поток может не увидеть изменения, сделанные другим. На x86 это стреляет реже так как архитектура имеет сильную модель памяти, но на ARM — шансы возрастают.
И еще момент, который часто путают: Thread-safe != test-safe. AtomicInteger потокобезопасен, но если два теста пишут в один счетчик, то результат будет непредсказуем. Потокобезопасность класса не означает, что тесты с ним можно запускать параллельно.
Процессы
Когда Gradle запускает 4 форка, то каждый это отдельный java-процесс со своей кучей, GC, JIT-компилятором, static-полями и пространством имён потоков.
Общими остаются файловая система и сеть. Поэтому Gradle-форки не страдают от проблем с static-полями, но конкуренция за доступ к файлам или БД никуда не девается.
Закон Амдала
Теоретический максимум ускорения определяется законом Амдала:
Ускорение = 1 / (a + (1 - a) / p)
Где a — доля последовательного кода, p — количество параллельных потоков.
В моих тестах Thread.sleep — полностью параллелизуемая операция (a = 0), следовательно теоретический максимум 4x на 4 потоках. Реально же получается 3.8x. Оставшаяся часть как раз последовательные операции: инициализация JUnit, синхронизация Gradle, работа GC.
В реальных проектах последовательная часть сильно больше: spring-контекст, миграции БД, прогрев кэшей и т.д. Поэтому на реальных данных даже ускорение в 2-3x на большом тестовом наборе это однозначно победа.
Немного про Thread.sleep
Может показаться, что я выбрал не самый удачный пример в своих тестах. Но на деле большинство тестов в типичном проекте ждут ответа от БД, от HTTP-сервера, от очереди сообщений. Thread.sleep имитирует это ожидание: поток заблокирован, ядро CPU свободно и другой поток может занять его без конкуренции.
Параллелизм в JUnit 5
Настройка JUnit 5 на параллельное выполнение это буквально несколько строк.
Файл src/test/resources/junit-platform.properties:
junit.jupiter.execution.parallel.enabled=truejunit.jupiter.execution.parallel.mode.default=concurrentjunit.jupiter.execution.parallel.config.strategy=fixedjunit.jupiter.execution.parallel.config.fixed.parallelism=4
Четыре параметра и тесты уже можно запускать в 4 потока. mode.default=concurrent означает, что и классы и методы внутри классов будут выполняться параллельно. Если хочется параллелить только классы, то можно написать вот так:
junit.jupiter.execution.parallel.mode.default=same_threadjunit.jupiter.execution.parallel.mode.classes.default=concurrent
Для отдельных тестов или классов можно управлять через аннотацию @Execution(CONCURRENT) или @Execution(SAME_THREAD).
В своем эксперименте настройки JUnit передаю через systemProperties в build.gradle. Так было банально удобнее для запуска разных Gradle-задач с разными настройками, чем хардкодить properties-файлы и подкидывать их.
Параллелизм в Gradle
Gradle предлагает свой механизм maxParallelForks. Он запускает тестовые классы в отдельных JVM-процессах.
tasks.register('testGradleParallel', Test) { useJUnitPlatform() maxParallelForks = 4 // 4 отдельных JVM-процесса systemProperties = [ 'junit.jupiter.execution.parallel.enabled': 'false' // JUnit НЕ параллелит ]}
Главное не путать maxParallelForks с флагом --parallel. Последний параллелит сборку, а не тесты. Для проекта, который состоит из одного модуля флаг --parallel ничего не даст.
Комбинированный режим
Тут просто собираем обе настройки вместе
tasks.register('testHybrid2x2', Test) { useJUnitPlatform() maxParallelForks = 2 // 2 процесса systemProperties = [ 'junit.jupiter.execution.parallel.enabled' : 'true', 'junit.jupiter.execution.parallel.mode.default' : 'concurrent', 'junit.jupiter.execution.parallel.mode.classes.default' : 'concurrent', 'junit.jupiter.execution.parallel.config.strategy' : 'fixed', 'junit.jupiter.execution.parallel.config.fixed.parallelism' : '2' // 2 потока в каждом ] // Итого: 2 процесса x 2 потока = 4 параллельных теста}
Методика испытаний
Что измеряем
-
50 тестов (10 классов x 5 методов), каждый выполняет
Thread.sleep(6000)— имитация 6 секунд работы. -
Базовое время: 50 x 6 = 300 секунд последовательно.
-
3 прогона каждого режима, берём среднее. Смотрим разброс между прогонами.
На чем гоняем
Основная моя машинка это HP ProBook 430 G8.
OS: ALT Workstation K 11.2 (Nemorosa) x86_64Kernel: Linux 6.12.74-6.12-alt1CPU: 11th Gen Intel(R) Core(TM) i7-1165G7 (8) @ 4.70 GHzMemory: 15.31 GiBJava: 21.0.10 (OpenJDK)Gradle: 8.10
5 сценариев
-
base — чистые тесты без общих ресурсов
-
database_broken — тесты с общим БД (Commit + DELETE)
-
database_fixed — тесты с общим БД, но уже есть изоляция транзакций (ThreadLocal + rollback)
-
filesystem_broken — простая запись в общий файл
-
filesystem_fixed — синхронизированная запись (ReentrantLock + FileLock)
4 режима
-
Sequential — 1 процесс, 1 поток
-
JUnit Parallel — 1 процесс, 4 потока
-
Gradle Parallel — 4 процесса, 1 поток
-
Hybrid 2×2 — 2 процесса, 2 потока в каждом

Базовые тесты
Начнем с идеального случая. У тестов нет общих ресурсов. Никаких БД, файлов, устройств, кэшей и т.п.
public abstract class CleanTestTemplate { protected static final long TEST_DURATION_MS = 6000; @Test void test01() throws InterruptedException { Thread.sleep(TEST_DURATION_MS); } // ... test02-05}
Десять классов наследуют этот шаблон. 50 тестов. 300 секунд последовательно.
Результаты
|
Режим |
Время |
Ускорение |
|---|---|---|
|
Sequential |
302 с |
1.00x |
|
JUnit (1×4) |
79 с |
3.80x |
|
Gradle (4×1) |
91 с |
3.29x |
|
Hybrid (2×2) |
79 с |
3.78x |
Ускорение 3.8x из теоретически возможных 4x. Эффективность 95%. Gradle стабильно медленнее JUnit и Гибридного режима на ~12 секунд. Причиной являются затраты на запуск 4 отдельных JVM-процессов.
Вывод здесь можно сделать такой, что если тесты не делят ресурсы, то просто включай JUnit Parallel.
Тесты с БД
Переходим к более интересным вещам.
Что может пойти не так?
Допустим у нас есть проект с тестами написанными для последовательного запуска. Каждый тест:
-
Вставляет запись в БД (INSERT)
-
Проверяет, что в таблице есть его данные (SELECT count(*))
-
Делает полезную работу (в моих тестах на этом месте просто Thread.sleep)
-
Удаляет за собой данные (DELETE в @AfterEach)
Последовательно все будет работать идеально. Потом включаем параллелизм и привет:
@ExtendWith(NaiveDbSetup.class)public abstract class NaiveTestTemplate { private void testUserOperation(int methodId) throws SQLException, InterruptedException { int userId = getClassOffset() + methodId; Connection conn = NaiveDbSetup.getConnection(); // Вставляем пользователя (autoCommit=true -> сразу виден ВСЕМ) insertUser(conn, userId, userName, balance); // Проверяем: "в таблице должна быть только моя запись" int totalUsers = NaiveDbSetup.countAllUsers(conn); assertThat(totalUsers) .as("Ожидаем <= 1 запись, но другие тесты уже вставили свои") .isLessThanOrEqualTo(1); // <-- Ошибка при параллелизме Thread.sleep(TEST_DURATION_MS); // afterEach: DELETE... но уже поздно }}
Основная проблема в autoCommit=true. Это поведение JDBC по умолчанию. Каждый INSERT мгновенно виден всем потокам и процессам. Тест А вставил запись, тест Б делает SELECT count(*) и видит не одну запись, а две. Assert падает.

Результаты broken-сценария
|
Режим |
Время |
Результат |
|---|---|---|
|
Sequential |
302 с |
50/50 passed |
|
JUnit (1×4) |
7 с |
49/50 FAILED |
|
Gradle (4×1) |
69 с |
35-41/50 FAILED |
|
Hybrid (2×2) |
7 с |
49/50 FAILED |
В Gradle количество упавших тестов нестабильно. Чем меньше упало, тем дольше прогон. Это связано с тем, что Gradle-форки имеют полную изоляцию процессов, что снижает вероятность коллизии.
Транзакционная изоляция
Да, БД у нас одна, но мы можем заставить каждый поток работать в своей транзакции. Транзакция будет откатываться после теста. Никто не видит чужих данных, так как они не закоммичены.
public class TransactionalDbSetup implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback { private static final ThreadLocal<Connection> threadLocalConnection = new ThreadLocal<>(); @Override public void beforeEach(ExtensionContext context) throws Exception { Connection conn = DriverManager.getConnection(DB_URL); conn.setAutoCommit(false); // Начало транзакции threadLocalConnection.set(conn); } @Override public void afterEach(ExtensionContext context) throws Exception { Connection conn = threadLocalConnection.get(); if (conn != null && !conn.isClosed()) { conn.rollback(); // Откат всех изменений conn.close(); threadLocalConnection.remove(); } }}
Тут работает связка из ThreadLocal<Connection> и каждый поток получает свое соединение, setAutoCommit(false) и данные не видны другим соединениям до коммита, а коммита и не будет, rollback() после теста все изменения откатываются и БД снова чистая.

Результаты transactional-сценария
|
Режим |
Время |
Ускорение |
|---|---|---|
|
Sequential |
302 с |
1.00x |
|
JUnit (1×4) |
79 с |
3.80x |
|
Gradle (4×1) |
92 с |
3.29x |
|
Hybrid (2×2) |
80 с |
3.78x |
Все 50 тестов, все 3 прогона, все 4 режима — прошли. Ускорение практически такое же, как на чистых тестах.
В реальных проектах вместо ручного ThreadLocal + rollback чаще используют @Transactional от Spring Boot (под капотом тот же setAutoCommit(false) + rollback(), но декларативно) или Testcontainers с отдельной БД на каждый процесс.
Тесты с файлом
С файловой системой всё хуже, чем с БД. Тут нет транзакций, нет rollback, нет уровней изоляции — только файл и несколько потоков, которые хотят в него что-то записать.
Пишем в файл как обычно
public class UnsafeFileWriter { private static final Path BROKEN_FILE = Paths.get("build/test-results/broken_output.json"); public static void write(String content) throws IOException { writesAttempted.incrementAndGet(); try (BufferedWriter writer = Files.newBufferedWriter( BROKEN_FILE, StandardOpenOption.APPEND)) { writer.write(content + System.lineSeparator()); // Race condition } }}
Несколько потоков одновременно открывают файл на дозапись. Результатом будет потеря записей и повреждение данных.
Немного про flaky на ровном месте
Есть статистика filesystem_broken + JUnit за 9 прогонов.
|
Прогон |
JUnit |
Gradle |
Hybrid |
|---|---|---|---|
|
#1 |
2/3 |
0/3 |
0/3 |
|
#2 |
3/3 |
0/3 |
0/3 |
|
#3 |
3/3 |
0/3 |
0/3 |
Gradle и Hybrid падают всегда. JUnit падает один раз из девяти. Статистика создает ложное ощущение стабильности. Тестировщик скорее всего решит, что просто тест сбойнул, ведь до этого все 8 раз он был зеленый.
Выходит что Gradle “честнее” JUnit и выявляет race condition там, где JUnit его маскирует.
Пробуем решить
Так как у нас есть и потоки и процесс и все вперемешку, то придется применять сразу два решения. Для потоков внутри одной JVM это ReentrantLock, а для процессов FileLock.
public class SafeFileWriter { private static final ReentrantLock jvmLock = new ReentrantLock(); public static void write(String content) throws IOException { jvmLock.lock(); // Уровень 1: между потоками try { try (FileChannel channel = FileChannel.open(SAFE_FILE, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) { try (FileLock ignored = channel.lock()) { // Уровень 2: между процессами byte[] bytes = (content + System.lineSeparator()) .getBytes(StandardCharsets.UTF_8); channel.write(ByteBuffer.wrap(bytes)); } } writesAttempted++; } finally { jvmLock.unlock(); } }}

Результаты fixed-сценария
|
Режим |
Время |
Ускорение |
Целостность данных |
|---|---|---|---|
|
Sequential |
301 с |
1.00x |
100% |
|
JUnit (1×4) |
79 с |
3.80x |
100% |
|
Gradle (4×1) |
92 с |
3.28x |
100% |
|
Hybrid (2×2) |
79 с |
3.79x |
100% |
Тут все чётенько.
Итоги испытаний
Сводная таблица (финальный прогон, 60 запусков)
|
Сценарий |
Sequential |
JUnit (1×4) |
Gradle (4×1) |
Hybrid (2×2) |
|---|---|---|---|---|
|
base |
302 с |
79 с / 3.80x |
91 с / 3.29x |
79 с / 3.78x |
|
database_broken |
302 с |
FAIL (49/50) |
FAIL (35-41/50) |
FAIL (49/50) |
|
database_transactional |
302 с |
79 с / 3.80x |
92 с / 3.29x |
80 с / 3.78x |
|
filesystem_broken |
302 с |
79 с / flaky |
92 с / FAIL |
79 с / FAIL |
|
filesystem_fixed |
301 с |
79 с / 3.80x |
92 с / 3.28x |
79 с / 3.79x |

Данные по стабильности
|
Режим |
Прогон #1 |
Прогон #2 |
Прогон #3 |
Разброс |
|---|---|---|---|---|
|
JUnit |
3.58-3.75x |
3.70-3.80x |
3.80x |
<=0.05x |
|
Gradle |
3.25-3.26x |
3.27-3.29x |
3.28-3.29x |
<=0.04x |
|
Hybrid |
3.73-3.75x |
3.78-3.79x |
3.78-3.80x |
<=0.05x |
Результаты в целом сходятся. Разброс между отдельными прогонами внутри одного запуска не значительный.
Какой вариант луше выбрать?
|
Сценарий |
Рекомендация |
Почему |
|---|---|---|
|
Чистые unit-тесты |
JUnit 5 (1×4) |
Минимум конфигурации |
|
Интеграционные тесты |
Gradle (4×1) |
Изоляция через процессы |
|
Смешанный проект |
Hybrid 2×2 |
Баланс изоляции и затрат |
|
Много классов, мало методов |
Gradle (4×1) |
Параллелизм на уровне классов |
|
Мало классов, много методов |
JUnit (1×4) |
Параллелизм на уровне методов |
|
Есть общие ресурсы |
Сначала изолировать |
Без изоляции — только хуже |
Выводы
Гибридный вариант параллелизма
Два процесса по два потока показали стабильный результат по испытаниям на всех сценариях. Быстрый как JUnit, но не маскирует проблемные тесты. Наверное самый универсальный выбор. Если что меня поправят в комментах.
Включить parallel.enabled=true это наверное секунд 10, а вот подготовить тесты к параллельному запуску может отнять тонну времени. Конечно все зависит от проекта, но лучше перепроверить тесты перед переходом.
Спасибо, что дочитали до конца. Если интересно, то могу рассказать как мы насобирали грабель при распараллеливании playwright тестов. Если нужны полные исходники, то тоже маякните — залью в репку.
ссылка на оригинал статьи https://habr.com/ru/articles/1025746/