Всем привет, меня зовут Сергей Прощаев. В этой статье я расскажу про обратную сторону медали параллельного запуска автотестов.
Мы любим скорость. Заказчик хочет получать обратную связь быстрее, Dev‑команда не хочет ждать прогона регресса по 3 часа, и мы, инженеры, тоже не горим желанием сидеть и ждать. Логичное решение — взять и запустить тесты в несколько потоков. Класс! Прогон теперь занимает 30 минут вместо двух часов. Все счастливы ровно до того момента, пока тесты не начинают падать в случайном порядке.
В практике видел сотни таких падений. Тест падает, ты перезапускаешь его — он зеленый. Команда пожимает плечами и списывает это на «глюки среды». Но рано или поздно наступает момент, когда «глюки среды» превращаются в необъяснимые наложения данных, которые пропускают баги в прод. И тут уже не до смеха.
Сегодня я хочу погрузить вас в эту кухню. Разобрать не очевидные проблемы параллельных тестов на Java, которые мы годами коллекционировали в FinTech, и показать, как современные инструменты и подходы помогают превратить хаос в стабильность.
Тестовое задание для QA
Представьте себе типичного кандидата на позицию Java QA. В резюме у него всё красиво: Selenium, TestNG, Allure, параллельный запуск. На собеседовании он бойко рассказывает, как настраивает thread-count в testng.xml.
Даю простое тестовое задание: есть небольшой сервис личного кабинета. Нужно покрыть его API‑тестами на REST Assured и настроить параллельный прогон, чтобы было быстро и надежно. Кандидат присылает код, запускаю его на CI... и вижу классическую картину: 10 запусков — 10 разных результатов. В одном запуске упал тест на создание пользователя, в другом — на проверку профиля, в третьем — всё ок.
Это и есть главная ловушка. Все хотят скорости, но никто не хочет разбираться с ThreadLocal, состоянием тестовых данных и прокси‑серверами. Все бегут за красивыми графиками в Allure, забывая, что многопоточность — это не про ускорение, а про управление сложностью.
Проблема 1: Общие ресурсы и «гонка за данными»
Самая частая проблема — статические поля. Кто из нас не хранил static WebDriver или static HttpClient в базовом классе, чтобы удобно было доставать его из любого места?
// Антипаттерн public class BaseTest { protected static RestAssuredClient client; protected static String authToken; } public class UserTest extends BaseTest { @Test public void testCreateUser() { // Поток 1 записывает свой токен authToken = loginAndGetToken("user1"); // Поток 2 в этот момент перезаписывает его на "user2" client.createUser(authToken); // Пойдет не тот токен } }
Казалось бы, мелочь. Но именно из‑за таких мелочей мы однажды потеряли целый день на debugging. Тест проверял, что пользователь не может удалить чужой аккаунт. В однопоточном режиме он работал идеально. В многопоточном — падал, так как два потока «путали» свои JWT‑токены, и один пользователь внезапно получал права другого. Хорошо, что это были тесты, а не прод.
Решение: Забудьте про static, если это не константа. Используйте ThreadLocal. Да, он многословен, но он решает эту проблему.
public class TestContext { private static ThreadLocal<String> authToken = new ThreadLocal<>(); private static ThreadLocal<User> currentUser = new ThreadLocal<>(); public static void setAuthToken(String token) { authToken.set(token); } public static String getAuthToken() { return authToken.get(); } }
В каждом потоке теперь свой «карман» для данных. Потоки перестают конфликтовать друг с другом, и тесты обретают предсказуемость.
Проблема 2: Атомарность тестовых данных и «эффект бабочки»
Допустим, у вас есть тест, который создает заказ. В параллельном режиме запускаются 5 копий этого теста. Они одновременно дергают API и пытаются создать заказ с одним и тем же ID или именем. База данных начинает «искрить»: constraint violation, duplicate key, и тесты валятся с 500-й ошибкой, хотя функциональность работает.
Это одна из самых сложных проблем. Она не решается просто добавлением synchronized.
При этом можно пройти через множество подходов:
Чистка данных перед тестом — не помогает, если тесты стартуют одновременно. Один почистил, второй тут же создал, первый упал.
Уникальные суффиксы — если генерировать имена пользователей и заказы с
UUIDто это может помочь процентов на 50. Перестали летать дубликаты, но тесты стали хуже читаться.Изоляция тестовых данных через окружения — дорого и сложно.
Самый эффективный метод — это сочетание уникальных идентификаторов и логического разделения данных по потокам.
Реальный кейс
Задача: Команде нужно было обеспечить параллельный прогон 500+ интеграционных тестов для платежного шлюза. Тесты крутились на Jenkins.
Проблема: Примерно 10% тестов падали с ошибкой «Invalid session». Причем падали разные тесты. И было не понятно — то ли таймауты, то ли сеть была причиной.
В результате мозгового штурма пришли к решению написать простой скрипт, который логировал время создания сессии и время запроса в каждом тесте. Выяснилось, что тесты использовали пул соединений к базе. В конце теста один поток закрывал соединение через метод close(), которое в этот момент использовал другой поток. И это приводило к тому, что сессия сразу инвалидировалась для всех.
Решение: Использовать пул соединений с валидацией (validationQuery) перед каждым использованием и поставить запрет на закрытие соединений в коде тестов (передав это управление пулу). Дополнительно был внедрен прокси‑сервер для БД p6spy, который логировал все запросы с ID потока.
Это была не проблема кода тестов, а проблема архитектуры взаимодействия с ресурсами. Поэтому, если тесты падают хаотично — ищите проблему в shared ресурсах: connection pools, кэши, файловая система.
Инструментарий Java QA: от наивного к профессиональному
Когда речь заходит о Java‑инструментах, большинство останавливается на настройках фреймворка. Давайте посмотрим, какие механизмы действительно нужны, чтобы не просто «запускать в несколько потоков», а делать это надежно.
1. CountDownLatch и CyclicBarrier для синхронизации
Иногда нужно, чтобы все потоки стартовали одновременно или дождались друг друга в определенной точке. Это критично для тестов производительности или для проверки состояния гонки.
// Пример: 10 потоков должны одновременно вызвать метод регистрации public void testConcurrentRegistration() throws InterruptedException { int threadCount = 10; CountDownLatch readyLatch = new CountDownLatch(threadCount); CountDownLatch startLatch = new CountDownLatch(1); CountDownLatch finishLatch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { new Thread(() -> { try { readyLatch.countDown(); // Я готов startLatch.await(); // Жду сигнала к старту registrationService.register("user_" + System.nanoTime()); finishLatch.countDown(); } catch (InterruptedException e) { /* ... */ } }).start(); } readyLatch.await(); // Ждем, пока все приготовятся startLatch.countDown(); // СТАРТ! finishLatch.await(); // Ждем финиша // Проверяем, что создалось 10 уникальных пользователей }
2. Semaphore для контроля доступа к дефицитным ресурсам
Допустим, тестируемое приложение может одновременно обрабатывать только 5 запросов (иначе ляжет). Мы пишем нагрузочный тест. Вместо того чтобы просто ограничить пул потоков, мы можем использовать семафор прямо в коде теста, имитируя реальное поведение клиента.
3. ConcurrentHashMap и Atomic‑переменные для сбора статистики
Если вы в параллельных тестах собираете метрики (сколько запросов упало, среднее время ответа), забудьте про обычные HashMap и int count++. Используйте ConcurrentHashMap и AtomicLong. Это убережет вас от артефактов в отчетах.
4. Логирование с MDC (Mapped Diagnostic Context)
Логи в параллельных тестах превращаются в кашу. Чтобы понять, какой поток какую операцию выполнял, нужно в каждый лог добавлять ID потока. SLF4J с MDC позволяет сделать это элегантно:
MDC.put("threadId", String.valueOf(Thread.currentThread().getId()));
А в паттерне лога (например, в logback.xml) достаточно указать:
<configuration> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <!-- Ключевой момент: добавляем [%X{threadId}] в паттерн --> <pattern>%d{HH:mm:ss.SSS} [%thread] [%X{threadId}] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> </root> </configuration>
Теперь, если в коде теста мы написали:
MDC.put("threadId", String.valueOf(Thread.currentThread().getId())); log.info("Запрос выполнен");
то в логах увидим что‑то вроде:
14:25:36.123 [main] [1] INFO ru.otus.parallel.SomeTest - Запрос выполнен 14:25:36.124 [pool-1-thread-2] [12] INFO ru.otus.parallel.SomeTest - Запрос выполнен
Благодаря этому можно легко отфильтровать логи конкретного потока и понять, в каком порядке выполнялись действия.
Такой подход особенно спасает, когда в параллельных тестах участвуют десятки потоков, а стандартный [%thread] часто выдаёт неинформативные имена вроде TestNG‑test=.... С MDC мы сами контролируем идентификатор!
Если изобразить это используя Mermaid Live Editor, написав простой код, то получим следующую картинку, изображенную на рис. 1.

Лучшие практики команд, у которых это работает
От коллег, которые вместе со мной преподают в Отус, я собрал перечень полезных практик.
Практика «Изоляция через контейнеры» (Team Topologies approach): Для каждого параллельного теста можно поднимать легковесный Testcontainer с базой данных. Тесты будут выполнятся немного дольше (из‑за запуска контейнера), но падения из‑за грязных данных исчезнут на 100%. Для критичного функционала это оправдано.
Практика «Динамический пул данных»: Можно написать простую утилиту‑пулер. При старте тестов она создает пул уникальных email/телефонов/карт. Каждый поток забирает себе уникальный набор данных из пула, использует их и по окончании возвращает обратно (или помечает как «грязный»). Это решает проблему конкуренции за тестовые данные без UUID‑каши в коде.
Инновация: Property‑Based Testing с генерацией данных: В Java для этого есть библиотеки типа jqwik. Вместо того чтобы писать 10 тестов с разными данными, вы пишете один тест, а фреймворк генерирует тысячи комбинаций параметров и гоняет их в многопоточном режиме, пытаясь найти состояние гонки. Это как фаззинг, но для логики приложения.
Заключение
Системный аналитик смотрит на требования и видит риски. Хороший QA‑инженер смотрит на код тестов и видит, как этот код поведет себя под нагрузкой в 50 потоков. Параллельный запуск — это не магия одной опции в CI. Это архитектура вашего тестового фреймворка. Игнорирование этого факта ведет к нестабильности, потере доверия к тестам и, как следствие, к багам на проде.
Мы разобрали лишь вершину айсберга. На практике вопросов еще больше: как тестировать асинхронные вызовы? Как работать с моками в многопоточке? Как профайлить тесты, чтобы найти узкие места?

Если вы хотите комплексно подойти к этим вопросам, научиться проектировать надежные тестовые фреймворки (каркасы) и разбирать реальные примеры из индустрии — обратите внимание на курс «Инженер по автоматизации тестирования на Java (Джава). Профессиональный уровень». Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.
Если хотите понять формат обучения — записывайтесь на бесплатные уроки от преподавателей курса:
11 марта в 20:00. «Управление автотестами с помощью Ansible (инструмент автоматизации) и Docker (контейнеризация)». Записаться
19 марта в 20:00. «Основы многопоточности в Java (Джава). Асинхронные методы». Записаться
Еще больше бесплатных уроков от преподавателей курсов можно посмотреть в календаре мероприятий.
