Всем привет, меня зовут Сергей Прощаев. В этой статье я расскажу про обратную сторону медали параллельного запуска автотестов.

Мы любим скорость. Заказчик хочет получать обратную связь быстрее, 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.

При этом можно пройти через множество подходов:

  1. Чистка данных перед тестом — не помогает, если тесты стартуют одновременно. Один почистил, второй тут же создал, первый упал.

  2. Уникальные суффиксы — если генерировать имена пользователей и заказы с UUIDто это может помочь процентов на 50. Перестали летать дубликаты, но тесты стали хуже читаться.

  3. Изоляция тестовых данных через окружения — дорого и сложно.

Самый эффективный метод — это сочетание уникальных идентификаторов и логического разделения данных по потокам.

Реальный кейс

Задача: Команде нужно было обеспечить параллельный прогон 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.

Рисунок 1: Изоляция контекста через ThreadLocal и MDC позволяет различать потоки в логах и не смешивать данные.
Рисунок 1: Изоляция контекста через ThreadLocal и MDC позволяет различать потоки в логах и не смешивать данные.

Лучшие практики команд, у которых это работает

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

  • Практика «Изоляция через контейнеры» (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 (Джава). Асинхронные методы». Записаться

Еще больше бесплатных уроков от преподавателей курсов можно посмотреть в календаре мероприятий.