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

В этой статье расскажу как из 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 параллелизм — вещь неочевидная. Есть два уровня:

  1. SBT-уровень — параллельное выполнение top-level suite'ов. SBT сам распределяет suite'ы по потокам. Работает из коробки.

  2. 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 о разработке в стартапах рассказываю еще больше интересного и делюсь опытом, заходите, буду рад!

Всем добра и тихих релизов!