За годы в разработке я видел проекты, где тесты запускались быстрее, чем закипал чайник, и проекты, где можно было сходить за кофе, выпить его, вернуться - а тесты всё ещё бежали.
Поговорим про второй тип проектов.

В этой статье расскажу как из backend-модуля с почти 800 тестами, которые выполнялись 10 минут 15 секунд получить время прогона 1 минута 41 секунда. Никакой магии - только системный подход, понимание инструментов и немного упорства.
Погнали!
Контекст
Стек: Scala, Java, SBT, ScalaTest. Backend-модуль - главный монолит системы. Тесты - смесь unit и интеграционных. Интеграционные работают с PostgreSQL через Testcontainers, миграция БД выполняется через Liquibase при старте контейнера. Отдельная история - компилятор пользовательских скриптов на базе GraalVM polyglot engine.
Исходная картина: 615 секунд (10 минут 15 секунд) на полный прогон. Каждый пуш в feature-ветку - это 10 минут ожидания на CI. Умножьте на количество пушей в день, на количество разработчиков - и получите впечатляющую цифру потерянного времени.
Диагностика: где болит?
Прежде чем лечить, нужно поставить диагноз. Я сгенерировал отчёт по таймингу всех тестов, отсортированный по длительности, и получил baseline. Вот что бросилось в глаза:
Все тесты выполняются последовательно - даже при наличии нескольких ядер CPU.
Каждый агрегирующий suite поднимает свой собственный PostgreSQL-контейнер + прогоняет Liquibase-миграцию. Это ~16 секунд на каждый.
Десятки тестов с hardcoded таймаутами в 10–11 секунд - там, где операция реально занимает миллисекунды.
В одном тесте конкурентности - 100 000 итераций с паузами
Thread.sleep(Random.between(350, 450)), хотя для проверки хватило бы в десять раз меньше.Timer-система в тестах тикает с продовыми интервалами (1 секунда = 1 секунда), хотя для тестов это бессмысленно.
компилятор пользовательских скриптов GraalVM инициализируется «вхолодную» каждый раз - а это тяжёлая операция.
Когда я увидел, что один тест честно ждёт 11 секунд, я понял: основная проблема - не архитектурная, а «историческая». Таймауты подбирались «с запасом» при написании и пересмотреть их ни у кого руки не доходили.
Разберём каждый этап оптимизации.
Этап 1: Снижение таймаутов и прогрев тяжёлых движков
Результат: 615с → 198с (–68%)
Эпидемия timeoutInSeconds = 10
Большинство интеграционных тестов так или иначе включали в себя бизнес-сценарии ожидания некоторого действия пользователя и автоматического действия по истечении заданного в системе таймера. Таймаут стоял 5–10 секунд. И тест честно ждал:
Код: таймаут ожидания
val timeoutInSeconds = 10 val expectNoActionTimeout = timeoutInSeconds + 1
Тест проверяет, что после окончания активности пользователя больше никаких действий не происходит. Для этого нужно подождать timeoutInSeconds + 1 секунда. Одиннадцать секунд. Только на ожидание. При этом timeoutInSeconds - это конфигурационный параметр, в тестах его можно безопасно ставить в 1 секунду.
Это изменение - 10 → 1 - пришлось внести примерно в 15 тестовых классах. Каждое изменение тривиальное, но суммарно они убрали минуты ожидания.
Thread.sleep и раздутые итерации
Классическая болезнь тестов конкурентности. Один тест проверял корректность чтения данных при параллельной записи:
Код: тест конкурентности с Thread.sleep
val updateF = Future { (0 to 25).map { i => dao.update(entity) Thread.sleep(Random.between(350, 450)) } } val readF = Future { (0 to 100).map { i => dao.findById(entity.id) Thread.sleep(Random.between(50, 150)) } } Await.result(for { _ <- readF; _ <- updateF } yield (), 60.seconds)
25 обновлений × 400ms = 10 секунд только на запись. 100 чтений × 100ms = 10 секунд на чтение. Для проверки race condition достаточно 10 обновлений × 50ms и 30 чтений × 20ms. А Await с 60-секундным таймаутом - это просто вишенка на торте.
В другом тесте - 100 000 итераций создания таймеров с паузами, где хватило бы 10 000.
Ускорение timer-системы в тестах
В нашей системе есть внутренний планировщик задач на базе Akka-таймеров. Каждый «тик» в проде занимает delay * 1000ms - логично, это реальное время. Но тесты использовали те же интервалы.
Я переопределил timer-сервис в тестовом окружении:
Код: переопределение timer-сервиса
new TimerServiceImpl(system) { override def scheduleTask(event: TimerEvent): Cancellable = system.scheduler.scheduleOnce( FiniteDuration(event.delay * 250, MILLISECONDS), targetActor, event ) }
Все таймерные задачи теперь выполняются быстрее. Первая попытка была с множителем * 250 (вместо * 1000), но на «холодном» JVM тесты начали флейкать - GraalVM и Liquibase ещё не прогрелись, а таймеры уже тикали. Поднял до * 500, стабильность вернулась.
Урок: ускорение таймеров - мощный рычаг, но нижний предел определяется самой медленной операцией в системе. Подбирается экспериментально.
Прогрев GraalVM
GraalVM polyglot engine - мощный инструмент, но при первом вызове выполняет тяжёлую инициализацию: загружает движок, парсит грамматику языка, JIT-компилирует базовые конструкции. Это 2–3 секунды. Каждый тестовый класс, использующий компилятор пользовательских скриптов, платил за «холодный старт».
Решение - warmup в builder'е тестового окружения. При первом создании SystemUnderTest в данном JVM-процессе движок прогревается минимальным скриптом. Все последующие вызовы используют уже инициализированный Context.
В Scala companion object инициализируется при первом обращении - JVM гарантирует потокобезопасность через synchronized + double-checked locking. Warmup происходит ровно один раз, независимо от количества потоков.
Polling вместо фиксированного ожидания
В нескольких тестах проверка статуса делалась через Thread.sleep(5000) — мол, подождём 5 секунд, точно успеет. Я написал простой polling-хелпер:
Код: polling-хелпер
def awaitStatus(expected: StatusType, maxWaitMs: Long = 3000): Unit = { val start = System.currentTimeMillis() while (System.currentTimeMillis() - start < maxWaitMs) { if (getCurrentStatus == expected) return Thread.sleep(50) } fail(s"Expected $expected after ${maxWaitMs}ms") }
Вместо гарантированных 5 секунд ожидания тест завершается за 50–200ms - как только статус меняется.
Итого по первому этапу: точечные изменения в ~25 файлах. Суммарно - 68% ускорения. Три четверти всего результата - до любых архитектурных изменений.
Этап 2: Параллельное выполнение вложенных suite'ов
Результат: 198с → 117с (–41%)
После устранения «тяжёлых» тестов пришло время для архитектурных изменений. Все тесты по-прежнему бежали последовательно — а ведь у нас многоядерный CPU.
Как устроен параллелизм в ScalaTest
В ScalaTest параллелизм — вещь неочевидная. Есть два уровня:
SBT-уровень — параллельное выполнение top-level suite'ов. SBT сам распределяет suite'ы по потокам. Работает из коробки.
ScalaTest-уровень — параллельное выполнение nested suite'ов внутри класса
Suites. Управляется флагом-Pи трейтомParallelTestExecution.
Ключевой момент: флаг -P создаёт ConcurrentDistributor, но он работает только если suite помечен трейтом ParallelTestExecution. Без этого трейта nested suite'ы всегда выполняются через SequentialDistributor, даже если вы передали -P100.
Наша структура:
IntegrationTestSuite (Suites) — 20+ вложенных спек IntegrationTestSuite2 (Suites) — 15+ вложенных спек
SBT мог запускать эти два suite параллельно, но все 20+ спек внутри каждого бежали последовательно.
Решение
Два изменения:
Код: включение параллелизма
// build.sbt Test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-P3")
class IntegrationTestSuite extends Suites( new ServiceASpec, new ServiceBSpec, // ... 20+ вложенных спек ) with SharedContainerSupport with ParallelTestExecution
Подводный камень №1: HikariCP connection pool exhaustion
При параллельном запуске 10 вложенных suite'ов каждый из них пытался получить connection из пула HikariCP. Дефолтный размер — 5 соединений. 10 потоков, 5 соединений — классический deadlock.
HikariPool-1 - Connection is not available, request timed out after 30000ms
Решение - увеличить пул:
config.setMaximumPoolSize(20)
Формула: maxPoolSize >= parallelThreads × connectionsPerThread. При 10 потоках и 2 соединениях на поток — минимум 20. Но без фанатизма: PostgreSQL тоже имеет лимит (max_connections, по умолчанию 100).
Подводный камень №2: Преждевременное закрытие контейнера
Коварный баг. Lifecycle-метод afterAll() вызывался, когда runNestedSuites возвращал Status. Но при параллельном выполнении Status возвращается сразу - до того, как nested suite'ы завершились. Контейнер закрывался, а тесты ещё работали.
Результат - случайные PSQLException: Connection refused.
Решение - дождаться завершения:
Код: ожидание завершения nested suite'ов
abstract override def runNestedSuites(args: Args): Status = { val status = super.runNestedSuites(args) status.waitUntilCompleted() status }
ScalaTest использует Status как Future-подобную абстракцию. При параллельном выполнении runNestedSuites возвращает CompositeStatus с дочерними статусами. Без waitUntilCompleted() lifecycle-хуки отрабатывают, не дождавшись завершения параллельных задач — аналог executor.shutdown() без awaitTermination().
Подводный камень №3: Flaky tests при высокой параллелизации
Первая попытка была с -P10. Тесты стали flaky — иногда проходили, иногда падали с таймаутами. Причина — CPU contention. 10 тестов одновременно выполняют Liquibase-миграцию, GraalVM-компиляцию, работу с БД — ядра перегружены, context switch'и растут, реальное время выполнения скачет.
Снижение до -P3 устранило проблему. Три потока — оптимальный баланс для нашей рабочей нагрузки.
Закон Амдала говорит, что ускорение ограничено долей последовательного кода. Но на практике есть ещё overhead на координацию: конкуренция за CPU, I/O, connection pool. После определённого порога добавление потоков замедляет выполнение.
Этап 3: Общий PostgreSQL-контейнер
Результат: 117с → 101с (–14%)
Два top-level suite — каждый поднимал собственный PostgreSQL-контейнер с Liquibase-миграцией. Два контейнера — ~32 секунды н�� инфраструктуру.
Попытка 1: Один гигантский suite (неудачная)
Объединить все 42 вложенных spec'а в один. Результат: ScalaTest'овский SuiteSortingReporter начал терять события. Вместо 800 тестов отчёт показывал 612. Тесты выполнялись, но reporter не мог их отследить при таком уровне параллелизма. Тупик.
Попытка 2: Lazy singleton (успешная)
Вынести контейнер в object — singleton в Scala:
Код: SharedTestContainer — lazy singleton
object SharedTestContainer { lazy val container: PostgreSQLContainer = { val c = PostgreSQLContainer() c.start() c } lazy val datasource: HikariDataSource = { val config = new HikariConfig() config.setJdbcUrl(container.jdbcUrl) config.setUsername(container.username) config.setPassword(container.password) config.setMaximumPoolSize(20) config.setAutoCommit(true) new HikariDataSource(config) } lazy val migrated: Boolean = { Class.forName(container.driverClassName) dbMigrationService.updateDb(changelogPath, contexts) true } }
Оба suite'а обращаются к одному экземпляру. Первое обращение запускает контейнер, второе получает уже работающий.
lazy val в Scala гарантирует потокобезопасную инициализацию — компилятор генерирует synchronized + double-checked locking. При конкурентном доступе из нескольких потоков инициализация произойдёт ровно один раз. Это делает lazy val в object идеальным примитивом для shared test infrastructure.
Один контейнер вместо двух — минус ~16 секунд. Не так впечатляюще, как первые два этапа, но каждая секунда на CI считается.
Защита от регрессий
Оптимизировать тесты — половина дела. Вторая половина — не дать им снова деградировать. Нет ничего хуже, чем потратить неделю на оптимизацию, а через месяц обнаружить, что кто-то добавил тест с Thread.sleep(30000).
Кастомный ScalaTest Reporter
Reporter, который проверяет время каждого теста:
Код: TestTimingReporter
class TestTimingReporter extends Reporter { override def apply(event: Event): Unit = event match { case ts: TestSucceeded => val duration = ts.duration.getOrElse(0L) val timeout = calcTimeout(ts.suiteId, ts.testName) if (duration > timeout) { if (collectMode) System.err.println(s"SLOW_TEST|${ts.testName}|${duration}ms|limit=${timeout}ms") else sys.exit(1) } case _ => () } }
Логика:
Новые тесты: лимит 2 секунды. Не укладывается — билд падает.
Legacy-тесты (компиляция пользовательских скриптов, тяжёлые DAO): лимит 4 секунды. Список фиксирован и ревьюится.
Диагностический режим (
collectMode): не роняет билд, а выводит все медленные тесты в stderr. Удобно для периодического аудита.
Пороги покрытия
Я ��становил пороги покрытия на текущие реальные значения. Теперь если покрытие упадёт ниже порога — билд не пройдёт. Это не про 100% coverage, это про «не позволяй ситуации ухудшаться».
Упрощение CI-пайплайна
В CI было 18 отдельных job'ов для запуска тестов по модулям. Каждый поднимал своё окружение, тянул зависимости. Тесты стали достаточно быстрыми, чтобы запускать их в рамках build-job'а. Убрал 18 job'ов — пайплайн стал проще и быстрее.
Бонус: от Future + Thread.sleep к Cats IO
Отдельного упоминания заслуживает рефакторинг тестов конкурентности.
Код: было — Future + Thread.sleep
val updateF = Future { (0 to 25).map { i => dao.update(entity) Thread.sleep(Random.between(350, 450)) } } val readF = Future { (0 to 100).map { i => dao.findById(entity.id) Thread.sleep(Random.between(50, 150)) } } Await.result(for { _ <- readF; _ <- updateF } yield (), 60.seconds)
Код: стало — Cats IO
val updateAll = (0 to 10).toList.traverse_ { _ => IO(dao.update(entity)) } val readAll = (0 to 30).toList.traverse_ { _ => IO(dao.findById(entity.id)) } (updateAll, readAll).parTupled .guarantee(IO(dao.delete(entity.id))) .unsafeRunSync()
Что изменилось:
Thread.sleep убран — нагрузка формируется без искусственных пауз.
Итерации сокращены — 10 + 30 вместо 25 + 100.
guaranteeвместоandThen— cleanup гарантирован даже при исключении.parTupled— параллельное выполнение без ручногоFuture+ExecutionContext.
Тест стал и быстрее, и надёжнее, и читабельнее.
Итоговые результаты
Этап | Что сделали | Время | Сокращение |
|---|---|---|---|
Baseline | Исходное состояние | 615с (10m 15s) | — |
Этап 1 | Таймауты, прогрев, чистка | 198с (3m 18s) | –68% |
Этап 2 | Параллелизм nested suite'ов | 117с (1m 57s) | –41% |
Этап 3 | Общий PostgreSQL-контейнер | 101с (1m 41s) | –14% |
Итого | 615с → 101с | ~6x ускорение | –84% |
Выводы
1. Начинать стоит с профилирования
Не надо сразу бежать включать параллелизм. Сначала замерь, где реальные bottleneck'и. В моём случае 68% ускорения дали точечные исправления таймаутов — без единого архитектурного изменения.
2. timeout = 10 — это не «запас», это мина
Тестовые таймауты имеют свойство копироваться из теста в тест, из года в год. Их никто не ревьюит, потому что «работает же». А потом 15 тестов × 10 секунд = 2.5 минуты чистого ожидания.
3. Параллелизм не бесплатен
Каждый уровень параллелизма приносит свои проблемы: connection pool exhaustion, преждевременное закрытие ресурсов, CPU contention. Увеличивайте число потоков постепенно.
4. lazy val в object — мощный паттерн для test infrastructure
Потокобезопасная, ленивая, гарантированно однократная инициализация. Идеально для shared-ресурсов: контейнеры, пулы соединений.
5. Защищай достигнутое
Оптимизация без защиты от регрессий — Сизифов труд. Кастомный reporter + пороги покрытия — простой и эффективный рубеж обороны.
6. Инструменты имеют лимиты
Попытка запихнуть 42 suite'а в один мега-suite привела к потере событий в ScalaTest'овском reporter'е. Не всякая «очевидная» оптимизация работает на практике.
Заключение
615 секунд → 101 секунда. Шестикратное ускорение. 800 тестов, ни один не пропущен.
Самое важное: 84% ускорения — это не результат какой-то одной серебряной пули. Это комбинация простых, логичных шагов: убрать мусор → распараллелить → переиспользовать ресурсы → защитить от регрессий. И 68% из этих 84% — это точечные, конкретные исправления.
Каждый из этих шагов применим к любому проекту на любом стеке. Если ваши тесты бегут дольше, чем вы варите кофе - начните с профилирования. Скорее всего, самые большие проблемы окажутся и самыми банальными в исправлении.
В сво��м канале в Telegram и канале в Max о разработке в стартапах рассказываю еще больше интересного и делюсь опытом, заходите, буду рад!
Всем добра и тихих релизов!
