Если вы пишете тесты на ZIO, то с моками, скорее всего, уже сталкивали��ь. И почти наверняка — с ZIO Mock. Формально он решает задачу, но на практике ломает Arrange‑Act‑Assert, «краснит» в IDEA и иногда падает так, что вы видите только InvalidCallException: null. В Яндекс Вертикалях мы довольно долго жили с этой библиотекой — пока количество таких тестов не перевалило за пару сотен и они не расползлись по десятку команд.

Меня зовут Женя Веретенников, я тимлид в Яндекс Вертикалях и последние годы занимаюсь инструментами для Scala‑разработчиков и инфраструктурой бэкенд‑монорепозитория. Когда стало ясно, что ZIO Mock больше мешает, чем помогает, мы решили отказаться от него полностью — и подружить ZIO Test с классическим ScalaMock. Он даёт более предсказуемый синтаксис и понятные ошибки, но из коробки с ZIO не работает.

В этой статье я расскажу не о том, как пользоваться новой библиотекой, а о том, как мы её делали: какие ограничения ZIO‑стека пришлось учитывать, где пришлось лезть под капот ScalaMock и ZIO Test и во что в итоге превратилась эта инженерная затея. Это история про построение собственного test tooling в большой Scala‑кодовой базе — с честными компромиссами и практическими выводами.


Контекст: как мы жили с моками 

Сначала — контекст по Scala в Яндекс Вертикалях. На Scala у нас написаны Авто.ру, Яндекс Недвижимость, Яндекс Аренда и общие сервисы вокруг них. В сумме это около 4 млн строк Scala‑кода, над которыми постоянно работают примерно 100 разработчиков в 15 командах.

Чтобы понять, что происходило с моками, вернусь в октябрь 2024-го. Мы активно используем экосистему ZIO: ZIO Test у нас дефолтный фреймворк для тестов, а ZIO Mock тогда был дефолтом для моков. На нём было написано 260 файлов с тестами в десяти командах.

С одной стороны, 260 спек — это много. С другой — ZIO Mock мы начали использовать в 2019 году, и за пять лет это вроде бы не так уж много. Отсюда возникает предположение: возможно, разработчики сознательно избегали ZIO Mock — потому что с ним было что‑то не так. Чтобы проверить эту гипотезу, посмотрим, как вообще работает ZIO Mock.

ZIO Mock на примере

Допустим, у нас есть внешний сервис, который мы не контролируем. Он делает очень простую штуку: по user ID отдаёт user name. И у нас есть ZIO‑клиент к этому сервису — буквально один метод, который возвращает строку, обёрнутую в ZIO.

Теперь на основе этого внешнего сервиса хотим написать какой‑нибудь свой код. Для простоты пусть это будет API, которая возвращает приветствие пользователю по user ID. Устроено суперпросто: берём user name и добавляем «Hello» в качестве префикса.

class ApiService(userService: UserService) {
  def getGreeting(userId: Int): UIO[String] =
    for {
      userName <- userService.getUserName(userId)
    } yield s"Hello, $userName!"
}

Хотим это протестировать. Пишем простой тест на ZIO Test: если getUserName(4) возвращает «Agent Smith», то getGreeting(4) возвращает «Hello, Agent Smith!». Внутри вызываем APIService.getGreeting(4). Здесь APIService мы вытаскиваем из ZIO environment — если с ZIO не работали, очень упрощённо можно представить, что это dependency injection. Дальше проверяем, что getGreeting действительно вернул «Hello, Agent Smith!»

test("return greeting")(
  for {
    result <- ZIO.serviceWithZIO[ApiService](_.getGreeting(4))
  } yield assertTrue(result == "Hello, Agent Smith!")
)

Но чтобы запустить этот тест, нужно предоставить ему инстанс APIService. Для этого есть метод provide. Мы предоставляем APIService с помощью ZLayer.derive. Опять же, если с ZLayer не работали, упрощённо это способ «положить» APIService в DI‑контекст. 

Но чтобы APIService создался, нам нужен UserService, и его хочется замокать. 

test("return greeting")(
  for {
    result <- ZIO.serviceWithZIO[ApiService](_.getGreeting(4))
  } yield assertTrue(result == "Hello, Agent Smith!")
).provide(
  ZLayer.derive[ApiService],
  ??? /* mocked UserService */
)

В ZIO Mock это делается так: мы создаём объект, навешиваем на него аннотацию @mockable с указанием типа, который хотим мокать. После этого можем настроить поведение: если вызвался метод getUserName(4), то он возвращает «Agent Smith».

@mockable[UserService]
object UserServiceMock

UserServiceMock.GetUserName(
  assertion = Assertion.equalTo(4),
  result = Expectation.value("Agent Smith")
)

Эту конструкцию можно использовать как слой (ZLayer), который мы затем передаём в наш DI‑контекст. 

test("return greeting")( // GREEN! 🟢
  for {
    result <- ZIO.serviceWithZIO[ApiService](_.getGreeting(4))
  } yield assertTrue(result == "Hello, Agent Smith!")
).provide(
  ZLayer.derive[ApiService],
  UserServiceMock.GetUserName(
    assertion = Assertion.equalTo(4),
    result = Expectation.value("Agent Smith")
  )
)

Собственно, это мы и делаем — подставляем её, и тест становится зелёным. Простейший тест на ZIO Mock — в десять строк.

Что тут не так

Несмотря на простоту примера, в нём сразу проявляется несколько проблем.

Нарушение Arrange/Act/Assert

Во‑первых, в этом тесте нарушается паттерн Arrange‑Act‑Assert. Тесты удобно писать именно так: сначала фаза Arrange, где мы настраиваем окружение для теста, затем фаза Act — вызываем код, который хотим протестировать, и после этого фаза Assert — проверяем, что код ведёт себя так, как ожидается в этом тестовом окружении.

test("return greeting")(
  for {
    // act
    result <- ZIO.serviceWithZIO[ApiService](_.getGreeting(4))
    // assert
  } yield assertTrue(result == "Hello, Agent Smith!")
).provide(
  ZLayer.derive[ApiService],
  // arrange
  UserServiceMock.GetUserName(
    assertion = Assertion.equalTo(4),
    result = Expectation.value("Agent Smith")
  )
)

Здесь же тест написан как будто шиворот‑навыворот. Сначала идёт Act, потом Assert, а затем Arrange. Чтобы понят��, что вообще происходит, тест приходится читать снизу вверх, потом сверху вниз и снова вниз. Это неудобно.

«Краснота» в IDEA

Даже этот простой тест подчёркивается красным в IDEA. Конкретно — в IntelliJ IDEA, которой пользуются практически все наши разработчики. В более сложных тестах таких примеров можно найти ещё больше.

Плохо читаемые падения тестов

Вторая проблема — ZIO Mock имеет тенденцию падать с плохо читаемыми ошибками. Давайте это проверим. Сейчас мы замокали метод так, что если он принимает аргумент 4, он возвращает строку «Agent Smith». Чисто для теста давайте перенастроим мок так, что он ожидает аргумент 3 — не тот, который мы реально передаём.

UserServiceMock.GetUserName(
  assertion = Assertion.equalTo(3), // BOOM! 💥
  result = Expectation.value("Agent Smith")
)

В такой ситуации хочется, чтобы библиотека для моков помогла легко понять, что ожидался аргумент 3, а на самом деле был передан 4. Но если запустить тест с таким слоем, он упадёт с InvalidCallException: null. Удачной отладки.

zio.mock.internal.MockException$InvalidCallException: null
	at zio.mock.internal.ProxyFactory$$anon$1.$anonfun$invoke$37(ProxyFactory.scala:374)
	at zio.ZIO$.$anonfun$die$1(ZIO.scala:3182)
	at zio.ZIO$.$anonfun$failCause$1(ZIO.scala:3259)
	at <empty>.ApiServiceZioMockSpec.spec(ApiServiceZioMockSpec.scala:18)

Есть способы это обойти, но про них знают и помнят не все разработчики. У нас примерно в половине тестов встречалась такая проблема.

Поддержка и будущее библиотеки

Кроме того, макрос @mockable невозможно реализовать на Scala 3. Это связано с тем, как библиотека использует макросы: такой подход не поддерживается в метапрограммировании Scala 3. К тому же в октябре 2024 года уже были планы задепрекейтить библиотеку — и позже они были реализованы.

Насколько сильно всё это болело у разработчиков, можно судить по опросу продуктивности, который мы проводили прошлым летом среди скалистов, джавистов, фронтендеров. Несмотря на то что ZIO Mock используют только скалисты, боль от него попала в топ-3 проблем вообще всех разработчиков.

К этому моменту уже должно быть очевидно, что проблема есть. Есть ZIO Mock, и от него нужно избавляться. Несмотря на то что на нём написано 260 спек и это стандартный способ писать моки, ситуация выглядит именно так. Возникает вопрос: что с этим можно делать и какие вообще есть варианты?

Как решать проблему

Самый простой вариант — сказать разработчикам: «С завтрашнего дня вы больше не пишете тесты с моками». Но так мы, конечно, не сделали.

Во‑первых, потому, что у нас было 260 спек, написанных с моками, и переписывать их на какие‑то ручные заглушки — нетривиальная задача. Во‑вторых, в каких‑то случаях моки всё‑таки полезны.

Можно было бы допилить ZIO Mock, но с учётом невозможности поддержки Scala 3 и планов на депрекейт библиотеки это тоже странный путь. Можно было бы написать свою библиотеку для моков с нуля, но это звучит как сложная техническая задача, и не очень понятно, зачем это делать, когда можно взять уже существующую библиотеку и сделать её дефолтным решением.

В этот момент мы посмотрели на решения, которые есть. Самым подходящим для нас оказался ScalaMock. Во‑первых, он решает все проблемы, которые у разработчиков были с ZIO Mock — поддерживает паттерн Arrange‑Act‑Assert, не «краснит» в IDEA, даёт понятные ошибки при падениях тестов и поддерживает Scala 3. В целом это надёжная библиотека, уже проверенная временем, которую не страшно тащить в тесты.

Но у неё была проблема: из коробки ScalaMock не работал с ZIO Test. И что с этим делать?

Давайте попробуем завести интеграцию ScalaMock и ZIO Test и посмотреть, что из этого получится. 

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

Ещё один нюанс: дальше все примеры будут на Scala 3. Сильных дебрей Scala 3 не будет — код просто выглядит чище, потому что не нужно писать скобочки.

Заводим прототип scalamock-zio с нуля

Если заглянуть в документацию ScalaMock на тот момент, там можно было найти такую фразу: «Если вы хотите использовать ScalaMock без ScalaTest или без Specs2 — а именно для этих тестовых фреймворков он тогда официально был поддержан, — вам нужно просто реализовать свой подтип MockFactoryBase. Таким образом, ScalaMock можно использовать в любом тестовом фреймворке».

Создадим ScalaMock‑овский мок и настроим его. Настройка в какой‑то степени похожа на ZIO Mock, может быть, даже чуть проще. Мы также говорим, что если вызван getUserName(4), мок должен вернуть строку «Agent Smith».

val m = mock[UserService]
m.getUserName.expects(4).returns(ZIO.succeed("Agent Smith"))

Пишем тест returnGreeting: вызываем getGreeting(4) и проверяем результат. Теперь мы провайдим, как и раньше, инстанс APIService. Дополнительно мы провайдим мок m, который создали за пределами теста. Абсолютно минималистичный пример. Этот тест зелёный.

val m = mock[UserService]
m.getUserName.expects(4).returns(ZIO.succeed("Agent Smith"))
test("return greeting"): // GREEN! 🟢
  for result <- ZIO.serviceWithZIO[ApiService](_.getGreeting(4))
  yield assertTrue(result == "Hello, Agent Smith!")
.provide(ZLayer.derive[ApiService], ZLayer.succeed(m))

Получается, что концептуально две эти библиотеки как будто бы можно интегрировать. Но при этом не хочется создавать мок где‑то в одном месте, а потом как‑то снаружи опрокидывать его в DI. Хочется создавать мок прямо внутри provide, потому что dependency injection для этого и нужен.

Давайте переместим создание мока внутрь создания слоя. Теперь мы не можем замокать метод за пределами теста. Но что мы можем сделать? Мы можем вытащить из ZIO environment UserService и замокать его точно так же, как и раньше. Этот тест тоже зелёный.

test("return greeting"): // GREEN! 🟢
  for
     <- ZIO.serviceWith[UserService]:
      .getUserName.expects(4).returns(ZIO.succeed("Agent Smith"))
    result <- ZIO.serviceWithZIO[ApiService](_.getGreeting(4))
  yield assertTrue(result == "Hello, Agent Smith!")
.provide(ZLayer.derive[ApiService], ZLayer.succeed(mock[UserService]))

Теперь проверим, работают ли два теста друг с другом. Рядом с returnGreeting напишем ещё один — returnGreetingTwo. В нём тот же самый код, здесь он опущен для простоты. И вот здесь начинаются проблемы.

suite("ApiServiceSpec")(
  test("return greeting"): // GREEN! 🟢
    for
       <- ZIO.serviceWith[UserService]:
        .getUserName.expects(4).returns(ZIO.succeed("Agent Smith"))
      result <- ZIO.serviceWithZIO[ApiService](_.getGreeting(4))
    yield assertTrue(result == "Hello, Agent Smith!"),
  test("return greeting 2"): // BOOM! 💥
    // same code
).provide(ZLayer.derive[ApiService], ZLayer.succeed(mock[UserService]))

returnGreeting зелёный, а returnGreetingTwo внезапно красный. Если посмотреть на вывод ошибки, видно следующее: returnGreetingTwo упал с ошибкой о том, что был неожиданно вызван какой‑то мок — ожидался один, а вызвался другой. Не очень понятно, в чём именно дело.

+ return greeting
- return greeting 2
  Unexpected call: <mock-1> UserService.getUserName(4) // What?! 💥

  Expected:
    <mock-2> UserService.getUserName(4) once (called once)

  Actual:
    <mock-1> UserService.getUserName(4)
    <mock-2> UserService.getUserName(4)

Что может помочь разобраться? В ScalaMock есть фича — именование моков. Давайте ей воспользуемся, чтобы было понятно, какой мок ожидался, а какой на самом деле был вызван. Именовать мок просто: мы передаём ему имя. Теперь в тесте returnGreeting мок называется greeting, а в тесте returnGreetingTwo — greetingTwo.

test("return greeting"):
  // тот же код
.provide(/* api /, ZLayer.succeed(mock[UserService]("greeting"))),

test("return greeting 2"):
  // тот же код
.provide(/ api */, ZLayer.succeed(mock[UserService]("greeting 2"))),

Стало чуть понятнее: в тесте returnGreetingTwo вызов мока greetingTwo считается неожиданным. Но всё равно непонятно, почему так происходит.

+ return greeting
- return greeting 2
  Unexpected call: <greeting 2> UserService.getUserName(4)

  Expected:
    <greeting> UserService.getUserName(4) once (called once)

  Actual:
    <greeting 2> UserService.getUserName(4)
    <greeting> UserService.getUserName(4)

Здесь логично предположить, что дело в состоянии ScalaMock, связанном с настройкой expectations. Для проверки заглянем в исходники ScalaMock.

Там есть сущность expectationContext — список всех expectations, которые разработчик настраивает для моков. Каждый вызов expects просто добавляет новую запись в этот список.

expectationContext = new ListBuffer[Handler]

// на m.getUserName.expects(4)
def expects(/* params /) =
  expectationContext += new Handler(/ params */)

При этом ZIO Test по умолчанию запускает тесты параллельно, а expectationContext реализован как ListBuffer и не является thread‑safe. В результате при одновременной настройке ожиданий из разных тестов часть данных теряется: может сохраниться только один мок, и второй тест падает, потому что его expectations просто не были учтены.

// в двух тестах в параллель:
expectationContext += <greeting> UserService.getUserName(4)
expectationContext += <greeting 2> UserService.getUserName(4)
// остаётся только <greeting>, другой тест падает

Отсюда можно сделать вывод, что ScalaMock не совсем thread‑safe.

ScalaMock — не thread-safe

Что с этим делать? Теоретически можно было бы сразу идти в сторону поддержки параллельного запуска. Но на этапе прототипа мы выбрали более простой и предсказуемый вариант — запускать такие тесты последовательно.

Для этого оставляем тот же тест‑сьют с двумя тестами и навешиваем на него аспект sequential. В ZIO Test есть тестовые аспекты — это способ задавать свойства выполнения тестов, в том числе последовательный запуск. После этого оба теста снова становятся зелёными: если мок вызван ожидаемым образом, всё работает. 

suite("ApiServiceSpec")(
  test("return greeting"):
    // тот же код.
  test("return greeting 2"):
    // тот же код.
).provide(/* тот же код */)

Так на уровне прототипа проблема гонок внутри ScalaMock была снята за счёт последовательного выполнения тестов.

Дальше проверяем обратный случай: что будет, если ожидание настроили, но код, который должен вызвать мок, так и не выполнили. Пишем тест, который мокает сервис, но затем завершается на assertCompletes — без фактического вызова тестируемого кода. Такой тест должен падать, но в текущем виде он остаётся зелёным. 

test("should fail"): // GREEN! 💥
  for
     <- ZIO.serviceWith[UserService]:
      .getUserName.expects(4).returns(ZIO.succeed("Agent Smith"))
  yield assertCompletes

Это означает, что в прототипной интеграции отсутствует проверка expectations по завершении выполнения теста.

Смотрим, как это делает ScalaMock. В документации упоминался трейт MockFactoryBase: чтобы использовать ScalaMock с любым тестовым фреймворком, нужно реализовать/подмешать подходящий подтип. Внутри MockFactoryBase есть метод withExpectations, который принимает what. Логика у него такая: сначала он инициализирует состояние ScalaMock (в том числе сбрасывает expectationContext), затем выполняет what и после этого проверяет expectations.

trait MockFactoryBase:
  protected def withExpectations[T](what: => T): T =
    initializeExpectations()
    val result = what
    verifyExpectations()
    result

  private def initializeExpectations()
  private def verifyExpectations()

Это похоже на то, что нам нужно, но есть две оговорки.

Первая: в нашем случае what — это ZIO‑эффект. Если просто присвоить его в val result = what, эффект сам по себе не выполнится.

Вторая: методы initialize и verify в ScalaMock приватные, напрямую переиспользовать их нельзя. Но сами методы короткие, поэтому в прототипе мы их копипастим к себе, делаем protected и оформляем в отдельный трейт ZIOMockFactory (любые совпадения с ZIO Mock случайны).

package org.scalamock.ziotest

trait ZIOMockFactory extends MockFactoryBase:

  protected def initializeExpectations() = /* copy-paste /

  protected def verifyExpectations() = / copy-paste */

После этого возвращаемся к тесту, который должен падать. Вместо MockFactoryBase подмешиваем ZIOMockFactory и добавляем два аспекта: before для initializeExpectations и after для verifyExpectations. Теперь тест действительно становится красным — и с понятным сообщением, что getUserName(4) ожидался один раз, но не был вызван ни разу. 

object ApiServiceSpec extends ZIOSpecDefault with ZIOMockFactory:
  override def spec =
    suite("ApiServiceSpec")(
      test("should fail"): // RED! 🔴 As expected
        // тот же код
    ).provide(/* тот же код */)
      @@ sequential
      @@ before(ZIO.succeed(initializeExpectations()))
      @@ after(ZIO.attempt(verifyExpectations()).orDie)
+ return greeting
+ return greeting 2
- should fail
  Unsatisfied expectation:

  Expected:
    <mock-3> UserService.getUserName(4) once (never called - UNSATISFIED)

По такой ошибке сразу ясно, что именно пошло не так. На этом этапе проверки expectations начали работать так же предсказуемо, как и в стандартных интеграциях ScalaMock с другими тестовыми фреймворками.

Дальше видно, что набор аспектов sequential, before и after нужен в каждом тесте, который пишется на этой интеграции. Чтобы не повторять одно и то же, выносим их в общий трейт ScalamockZIOSpec. В ZIO Test есть метод aspects, который позволяет задать аспекты на уровне спека. После этого в самих тестах остаётся только extend ScalamockZIOSpec — без ручного дублирования аспектов, а вся служебная логика интеграции остаётся вне тестов и не влияет на их структуру.

trait ScalamockZIOSpec extends ZIOSpecDefault with ZIOMockFactory:
  override def aspects =
    super.aspects ++
      Chunk(
        sequential,
        before(ZIO.succeed(initializeExpectations())),
        after(ZIO.attempt(verifyExpectations()).orDie))

На этом этапе у нас получился рабочий микропрототип, который закрывает ключевые боли ZIO Mock: тесты снова читаются в Arrange‑Act‑Assert, падения становятся понятными, а код перестаёт «краснить» в IDEA. Плюс сохраняется поддержка Scala 3 и Scala 2.13 — для нас это было важно.

Если подытожить, как мы к этому пришли: сначала зафиксировали проблему, затем выбрали кандидатное решение (ScalaMock) и проверили, что его можно заинтегрить с ZIO Test, а дальше двигались от минимальной интеграции к более полноценной — добавляя поведение и устраняя баги по мере появления.

Что в этом процессе помогало больше всего — регулярно заглядывать в исходники ScalaMock. Когда вы строите библиотеку поверх другой библиотеки, понимание того, как она устроена внутри, экономит много времени и сильно упрощает отладку.

Наводим красоту

Дальше, когда у нас есть прототип, который как‑то работает на минималках, возникает следующий этап — навести красоту. Сделать более удобный API, поддержать дополнительные юзкейсы. Таких примеров было много, но хочется рассказать про один — казалось бы, очень простой: заменить явное создание слоя с моком через ZLayer.succeed(mock) на простой вызов zioMock.

Нам не хочется каждый раз писать ZLayer.succeed(mock). Нам хочется, чтобы это был вызов одного метода — потому что мы хотим, чтобы разработчик библиотеки всегда предоставлял моки именно через слои. Плюс у него может быть целая простыня моков, и хочется, чтобы он писал поменьше. Сперва попробуем такое решение: напишем zioMock = ZLayer.succeed(mock[A]), где A — любой тип. 

Подставим вызов этого метода в provide. Это не сработает. Тест упадёт с ClassCastException: какой‑то анонимный класс почему‑то не может быть скастован в UserService

def zioMock[A: Tag]: ULayer[A] = ZLayer.succeed(mock[A])

suite(/* тот же код */)
  .provide(ZLayer.derive[ApiService], zioMock[UserService])

  // Падает с ошибкой:
  // java.lang.ClassCastException:
  // class ScalamockZIOSpec$$anon$1 cannot be cast to class UserService

Если присмотреться, видно: когда мы вызывали mock для конкретного типа UserService, всё работало. А когда начали вызывать mock для абстрактного типа A, перестало. Посмотрим, что именно генерирует mock[A].

На самом деле метод mock — это макрос. В исходниках ScalaMock есть метод MockImpl.mock, который генерирует код — по сути, синтаксическое дерево, AST. 

// исходники scalamock
inline def mock[T](implicit mockContext: MockContext): T =
  ${MockImpl.mock[T]('{mockContext})}

Хочется понять, что именно он генерирует. Для этого можно написать свой метод debugMock, внутри которого вызвать свой DebugMockImpl

// наш код
inline def debugMock[T](implicit mockContext: MockContext): T =
  ${DebugMockImpl.debugMock[T]('mockContext)}

Всё, что нам здесь нужно, — это возможность посмотреть, для какого типа генерируется мок и какой код при этом получается. В макросах никто не запрещает писать принты — это нормально.

Мы пишем, для какого типа у нас генерируется мок, и печатаем сгенерированный код. Никакой магии: мы не хотим менять AST, который генерирует макрос ScalaMock. Мы просто возвращаем его дальше, чтобы он использовался как раньше.

def debugMock[T: Type](mockContext: Expr[MockContext])
                      (using quotes: Quotes): Expr[T] =
  val result = MockImpl.mock[T](mockContext)
  println(s"// Генерируем мок для типа: ${Type.show[T]}")
  println(s"// Сгенерированный код:")
  println(s"${result.show}")
  result

Подставляем debugMock в наш zioMock и смотрим на вывод. Важно: вывод происходит на этапе компиляции, не в рантайме.

def zioMock[A: Tag]: ULayer[A] = ZLayer.succeed(debugMock[A])

suite(/* тот же код */)
  .provide(ZLayer.derive[ApiService], zioMock[UserService]))

Видно, что генерируется мок для типа A, и сгенерированный код выглядит так: создаётся анонимный класс, который ��кстендит A, и возвращается новый инстанс этого анонимного класса типа A. Неудивительно, что такой объект нельзя привести к UserService.

// Генерируем мок для типа: A
// Сгенерированный код:
{
  class $anon extends A with scala.reflect.Selectable { /* ... */ }

  (new $anon(): A & scala.reflect.Selectable)
}

Для сравнения посмотрим, что происходит, если вызвать debugMock для типа UserService

Тогда видно, что мок генерируется для правильного типа. Сгенерированный код — это анонимный класс, который экстендит UserService. В нём есть метод getUserName, он замокан и лезет внутрь ScalaMock, чтобы выполнить нужные операции. В конце возвращается инстанс этого класса типа UserService.

ZLayer.succeed(debugMock[UserService])

// Генерируем мок для типа: UserService
// Сгенерированный код:
{
  class $anon extends java.lang.Object
                 with UserService
                 with scala.reflect.Selectable { /* ... */
    override def getUserName(id: scala.Int) = // ...
  }

  (new $anon(): UserService & scala.reflect.Selectable)
}

Если подытожить: когда мы вызываем mock для UserService, создаётся корректный мок. Когда вызываем mock[A], создаётся битый мок. Что с этим делать?

Очевидно, что в мок нужно передавать конкретный тип. Но при этом мы хотим, чтобы этот сгенерированный код сразу был обёрнут в ZLayer.succeed.

Можно прямо в макросе вызвать ZLayer.succeed и внутрь него сразу передать вызов скаламоковского MockImpl.mock. В итоге это макрос буквально на одну строчку: мы вызываем ZLayer.succeed и внутрь передаём MockImpl.mock.

def zioMock[T: Type](mockContext: Expr[MockContext])(using Quotes): Expr[ULayer[T]] =
  '{ ZLayer.succeed(${MockImpl.mock[T](mockContext)}) }

У нас больше нет промежуточного немакросного метода: в zioMock сразу передаётся конкретный тип — UserService. Это устраняет проблему с абстрактным типом и позволяет сохранить удобный способ предоставления моков через слои, не ломая типовую модель ScalaMock. После этого тест становится зелёным. 

suite(/* тот же код */) // GREEN! 🟢
  .provide(ZLayer.derive[ApiService], zioMock[UserService])

Теперь мы можем использовать zioMock, не указывая каждый раз ZLayer.succeed вручную. Казалось бы, простая фича, но чтобы её сделать, пришлось немного повозиться.

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

Прототип готов. Что дальше

Дальше началась работа, которая уже меньше связана с метапрограммированием и больше — с другими практическими вещами.

Во‑первых, хотелось проверить, что прототип вообще адекватен и им удобно пользоваться. У меня было много тестов на ZIO Mock, которые можно было переписывать — за пять лет их накопилось достаточно. Я взял и переписал 40 таких тестов, по ходу нашёл ещё несколько проблем в библиотеке и пофиксил их.

В какой‑то момент стало понятно, что разработкой уже можно делиться с окружающими. Я написал документацию — такую, чтобы разработчикам не нужно было ходить ко мне и что‑то спрашивать, а можно было просто прочитать её и понять, как пользоваться библиотекой. Это оказался важный момент, который сильно упростил внедрение.

После этого я сделал анонс во внутреннем сообществе разработчиков: мол, early adopters, приходите пользоваться. Они пришли, попробовали, принесли ещё несколько проблем, которые я сам не заметил. Где‑то они даже законтрибьютили, где‑то я фиксил сам. Это тоже сильно помогло. После этого библиотека, по сути, больше не менялась.

Тесты в финальной версии библиотеки выглядят очень похоже на исходный прототип. Из заметных отличий — появился хелпер returnsZIO, по сути это одна строка кода. 

object ApiServiceSpec extends ScalamockZIOSpec:
  override def spec =
    suite("ApiServiceSpec")(
      test("return greeting"):
        for
           <- ZIO.serviceWith[UserService]:
            .getUserName.expects(4).returnsZIO("Agent Smith"))
          result <- ZIO.serviceWithZIO[ApiService](_.getGreeting(4))
        yield assertTrue(result == "Hello, Agent Smith!"),
    ).provide(ZLayer.derive[ApiService], mock[UserService])

В итоге прототип был окончательно стабилизирован. 

Дальше началась уже организационная работа. Я пошёл и договорился со всеми лидами команд о переезде. К счастью, ZIO Mock у всех болел, поэтому сильно убеждать никого не пришлось. Команды переписывали тесты в течение нескольких месяцев, каждая в своём темпе. Все свои тесты они в итоге переписали — за что им отдельное спасибо.

Какую пользу принёс scalamock-zio

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

Во‑первых, мы полностью отказались от ZIO Mock и закрыли сразу несколько проблем, о которых шла речь выше: от читаемости тестов и ошибок до поддержки Scala 3 и общего developer experience.

Во‑вторых, эффект оказался заметен количественно. Первый анонс новой интеграции во внутреннем сообществе разработчиков был в январе 2025. С тех пор команды написали более 200 новых тестов с использованием scalamock‑zio. Это почти столько же, сколько накопилось за пять лет использования ZIO Mock. Похоже, это неплохо отражает, насколько библиотека оказалась удобной в повседневной работе.

От разработчиков мы также получили неожиданный, но показательный фидбэк:раньше, если для задачи требовался ZIO Mock, написание тестов часто откладывали «на потом» — теперь такого эффекта больше нет.

Никакой внутренней специфики в решении не оказалось, поэтому следующим логичным шагом стало открытие кода. Интеграция была вмержена в ScalaMock и вышла в релизе 7.5.0. Сейчас её можно подключить к своему проекту обычным способом — в зависимости от используемой системы сборки — и использовать без ZIO Mock, как это делаем мы. Например, для sbt подключить можно так:

libraryDependencies += "org.scalamock" %% "scalamock-zio" % "7.5.0" % Test

Для тех, кому интересно покопаться глубже, есть репозиторий с компилирующимися примерами кода из этой статьи и документация по scalamock‑zio — они покрывают базовые сценарии использования и отражают тот самый прототип, который со временем превратился в стабильное решение.