Как организовать тестирование асинхронного кода?

Тестирование асинхронного кода в Scala требует учёта отложенного выполнения, использования Future, Promise, IO (из Cats Effect или ZIO), и требует работы с таймаутами, планировщиками, монадой эффекта и инструментами синхронизации. Важно не только проверить результат, но и корректную обработку ошибок, тайминги, параллелизм и отсутствие гонок данных.

Тестирование Future с ScalaTest

В ScalaTest встроена поддержка Future. Она позволяет писать тесты, ожидающие завершения асинхронных операций.

import org.scalatest.flatspec.AsyncFlatSpec
class MySpec extends AsyncFlatSpec {
def asyncOperation(): Future\[Int\] = Future.successful(42)
"asyncOperation" should "return 42" in {
asyncOperation().map { result =>
assert(result == 42)
}
}
}

AsyncFlatSpec автоматически ожидает завершения Future и проваливает тест, если он завершается с ошибкой или превышается таймаут.

Проверка ошибок

"failing future" should "fail with exception" in {
recoverToSucceededIf\[RuntimeException\] {
Future(throw new RuntimeException("fail"))
}
}

Также можно использовать recoverToExceptionIf[T] для получения самого исключения.

Установка таймаутов

import scala.concurrent.duration._
import scala.concurrent.Await
val result = Await.result(myFuture, 3.seconds)

Используется в классических (FlatSpec) тестах, но не рекомендуется для Async*-спеков, так как может заблокировать поток.

Работа с Promise

"promise" should "complete later" in {
val p = Promise\[Int\]()
Future {
Thread.sleep(100)
p.success(42)
}
p.future.map { result =>
assert(result == 42)
}
}

Можно использовать Promise в моках или когда нужно вручную управлять завершением.

Параллельные вычисления

Проверка параллельной работы может быть проведена с Future.sequence или Future.traverse.

"parallel computation" should "run concurrently" in {
val tasks = List.fill(5)(Future(Thread.sleep(100)))
Future.sequence(tasks).map(_ => succeed)
}

Важно контролировать ExecutionContext, чтобы тесты не блокировали глобальный пул.

Тестирование с Cats Effect и IO

Если используется cats.effect.IO, стандартный подход — использовать IOTest или cats.effect.testing.scalatest.AsyncIOSpec.

import cats.effect.IO
import cats.effect.testing.scalatest.AsyncIOSpec
import org.scalatest.funsuite.AsyncFunSuite
class IOSpec extends AsyncFunSuite with AsyncIOSpec {
def asyncIO: IO\[Int\] = IO.pure(123)
test("IO should return value") {
asyncIO.asserting(_ == 123)
}
}

Метод asserting — удобный способ проверить значение, возвращаемое IO.

Пример проверки задержки

Можно измерить длительность исполнения:

import scala.concurrent.duration._
"delayed future" should "take at least 1 second" in {
val start = System.nanoTime()
Future {
Thread.sleep(1000)
"done"
}.map { res =>
val elapsed = (System.nanoTime() - start).nanos
assert(elapsed >= 1.second)
}
}

Это может быть полезно при тестировании таймеров, бэк-оффов, ожидания между ретраями и т.п.

Использование Eventually

Модуль Eventually полезен для асинхронных проверок с ретраями:

import org.scalatest.concurrent.Eventually
import org.scalatest.time.{Millis, Seconds, Span}
class MySpec extends AsyncFlatSpec with Eventually {
implicit override val patienceConfig =
PatienceConfig(timeout = Span(2, Seconds), interval = Span(50, Millis))
"eventually" should "wait for condition" in {
var x = 0
Future {
Thread.sleep(300)
x = 42
}
Future {
eventually {
assert(x == 42)
}
}
}
}

Работа с ZIO

Если проект использует ZIO, можно использовать zio.test:

import zio._
import zio.test._
import zio.test.Assertion._
object ZIOSpec extends ZIOSpecDefault {
def spec = suite("My ZIO Tests")(
test("ZIO should succeed with value") {
for {
value <- ZIO.succeed(42)
} yield assert(value)(equalTo(42))
}
)
}

ZIO предоставляет встроенные генераторы, свойства и временные контроллеры.

Управление временем (virtual time)

Для сложных сценариев (например, таймауты, дедлайны) можно использовать тестовый планировщик:

val testScheduler = TestScheduler()
implicit val ec = ExecutionContext.fromExecutor(testScheduler)
val delayed = Future {
Thread.sleep(1000)
"done"
}
// продвижение времени вручную
testScheduler.tick(1.second)

Аналогично работает TestControl в Cats Effect 3.

Проверка завершения Future за таймаут

val f = Future { Thread.sleep(2000); 42 }
val result = Await.ready(f, 1.second) // вызовет TimeoutException

Также можно использовать таймауты через future.withTimeout(...), реализованные в кастомных утилитах или библиотеках.

Проверка на race conditions

Использование ConcurrentHashMap, AtomicInteger, Semaphore и других средств синхронизации может помочь в моделировании параллельных состояний и гонок.

Инструменты

  • ScalaTest AsyncSuite — стандартный способ писать тесты с Future

  • Cats Effect AsyncIOSpec — удобство для IO

  • ZIO Test — фреймворк ZIO с поддержкой времени и эффектов

  • Eventually / Retry — для ожидания состояний

  • Mockito + Promise — для контроля асинхронного поведения

Асинхронное тестирование требует дисциплины: избегать Thread.sleep, не блокировать главный поток, использовать неблокирующие абстракции, учитывать таймауты и планировщики.