Моки — достаточно крутой инструмент, если использовать его правильно.

И все-таки лично для меня писать и поддерживать тесты на моках всегда было отдельным видом боли. Думаю, все знакомы с ситуацией: добавил в метод новый аргумент — и пошёл в 30 тест-кейсов проставлять заглушки. И это только от одного нового аргумента.

Задачку сделал. Остались только тесты (c)
Задачку сделал. Остались только тесты (c)

И я не буду здесь спорить о терминологии — в этой статье я буду называть все тестовые дублёры «моками». Примеры будут на Scala, но моки в других языках работают похожим образом, так что боль универсальная. Как и решение — об этом в статье.


Предыстория

В 2023 году, когда Scala 3 уже существовала, но ещё почти никем не использовалась - я заинтересовался метапрограммированием. Туториалов не было, поддержки IDE почти не существовало — но именно это и было интересно. Я решил взять одну из ещё не переведённых на Scala 3 библиотек и портировать её. Выбор пал на scalamock — как раз то, что использовалось у нас на проекте.

Примерно тогда команда начала миграцию с Future на ZIO, а в дальнейшем планировала переход на Scala 3. Сразу выяснилось: scalamock с ZIO работает плохо. Попробовали zio-mock — интересная идея, но @mockable не была портирована на Scala 3, и использование превращалось в мучение. Перевод scalamock на Scala 3 в какой-то момент был завершён, но второй проблемы это не решало. Подружить классический scalamock с функциональными системами эффектов казалось задачей нерешаемой.

В итоге я начал писать стабы руками. Нужны были только две вещи: задать результат и потом проверить аргументы. Работало — но надоело быстро. И я точно знал, что я не один такой. Поэтому начал создавать решение, используя опыт, полученный при миграции scalamock на Scala 3. Так получился проект backstub — позднее переросший в scalamockStubs. А я стал мейнтейнером scalamock.


Что не так с классическим подходом

В scalamock есть два "разных" варианта мокирования - stub[A], и mock[A].

Оба подхода позволяют делать следующее:
1. Устанавливать возвращаемый методом результат
2. Устанавливать ожидания на аргументы, с которыми метод был вызван
3. Устанавливать ожидания на порядок вызовов разных методов.

Но у обоих есть общая концептуальная проблема - установка результата и установка ожиданий на аргументы смешаны в кучу. Хотя установка результата может спокойно существовать без установки ожиданий на аргументы. Даже больше - установку ожиданий на аргументы следует использовать далеко не всегда.

Для mock это выглядит так:

myTrait.twoArgs.expects(1, "hello").returns("world")

otherTrait.oneArg.expects("foo").returns(1)

Для stub — почти то же самое, только через when вместо expects и отдельное использование verify.

Здесь - уже лучше, установка результата отделена от проверки ожиданий. Но зачем здесь .when(*, *)?

myTrait.twoArgs.when(*, *).returns("world")
//...
myTrait.twoArgs.verify(1, "hello").once()

На практике это означает: каждый раз, когда меняется сигнатура метода, нужно обновлять все места, где этот метод используется в тестах — и в установке результата, и в настройке ожиданий. Добавили параметр requestId: UUID в метод? Компилятор покажет ошибки во всех тестах, где фигурировал этот метод, даже в тех, где вам вообще не важно, с какими аргументами он был вызван.

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


Идея нового API: разделить ответственность

Новый API строится вокруг одного принципа: установка результата, проверка аргументов и проверка порядка — три отдельные, независимые операции.

Задать результат      →  .returnsWith / .returns
Проверить аргументы   →  .calls / .times          (опционально)
Проверить порядок     →  .isBefore / .isAfter       (опционально)

Реже используемые фичи не создают проблем для используемых чаще. В большинстве случаев нам нужно только установить результат. Иногда проверить аргументы или количество вызовов. А порядок вызовов часто вообще проверять нет смысла, потому что он следует неявно.


Пример

trait ProductRepository:
  def findById(id: ProductId): Option[Product]

trait NotificationService:
  def notify(email: Email, order: Order): Unit

class OrderService(
  products: ProductRepository, 
  notifications: NotificationService
):
  def placeOrder(
    productId: ProductId,
    quantity: Int, 
    email: Email
  ): Either[OrderError, Order] =
    products.findById(productId) match
      case None          => Left(OrderError.ProductNotFound)
      case Some(product) =>
        if product.stock < quantity then Left(OrderError.InsufficientStock)
        else
          val order = Order(productId, quantity, email)
          notifications.notify(email, order)
          Right(order)

Паттерн Env и задание результата

Стабы всегда создаются внутри класса-окруженияEnv / Wiring / etc. Это стандартный паттерн при использовании моков в Scala — каждый тест-кейс получает собственный, изолированный экземпляр всех зависимостей:

//> using dep org.scalamock::scalamock::7.5.5

import org.scalamock.stubs.Stubs
import munit.FunSuite

class OrderServiceSpec extends FunSuite, Stubs:

  class Env:
    val products      = stub[ProductRepository]
    val notifications = stub[NotificationService]
    val service       = OrderService(products, notifications)

returnsWith — результат без оглядки на аргументы

Самый частый случай. Метод всегда возвращает одно и то же:

  test("товар не найден"):
    val env = Env()

    env.products.findById.returnsWith(None)

    val result = env.service.placeOrder(
      productId = ProductId("123"), 
      quantity = 1, 
      email = Email("user@example.com")
    )

    assertEquals(result, Left(OrderError.ProductNotFound)
)

Причемnotify здесь не настроен.

Если placeOrder попробует его вызвать — получит NotImplementedError с понятным описанием метода в стектрейсе.

returns — результат зависит от аргументов

Когда нужно задать разное поведение для разных аргументов, используем returns с pattern matching:

  test("недостаточно товара на складе"):
    val env = Env()
    val pid = ProductId("123")

    env.products.findById.returns:
      case `pid` => Some(Product(pid, stock = 5))
      case _     => None

    val result = env.service.placeOrder(
      productId = ProductId("123"), 
      quantity = 10, 
      email = Email("user@example.com")
    )

    assertEquals(result, Left(OrderError.InsufficientStock))

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


Проверка аргументов и количества вызовов

Когда важно убедиться, что метод был вызван — и с правильными данными — используем calls и times:

  test("отправляет уведомление после успешного заказа"):
    val product = Product(ProductId("123"), stock = 10)
    val email   = Email("user@example.com")
    val env     = Env()

    env.products.findById.returnsWith(Some(product))
    env.notifications.notify.returnsWith(())

    env.service.placeOrder(
      productId = ProductId("123"), 
      quantity = 1,
      email = email
    )

    assertEquals(env.notifications.notify.times, 1)
    assertEquals(
      env.notifications.notify.calls,
      List(
        (
          email,
          Order(productId, quantity, email)
        )
      )
    )

calls возвращает список аргументов всех вызовов:

  • List[Unit] — если аргументов нет

  • List[A] — если один аргумент типа A

  • List[(A, B, ...)] — если несколько (tuple)

Ключевое отличие от классического подхода: задание результата и проверка аргументов — происходят отдельно. Можно задать результат и никогда не проверять аргументы. Можно проверить аргументы, не указывая их при настройке результата. Изменение сигнатуры метода затронет только те тесты, которые действительно проверяют аргументы.


Проверка порядка вызовов

Самая редкая потребность — и она вынесена в отдельный явный механизм. Нужен CallLog:

  test("сначала ищем клиента, только потом сохраняем"):
    given CallLog = CallLog()

    val knownClient = ClientRecord("client-123", allowedScopes = Set("read"))
    val env = Env()

    env.repo.findByClientId.returnsWith(Some(knownClient))
    env.repo.save.returnsWith(())

    env.issuer.issue("client-123", Set("read"))
    // в данном случае это бессмысленная проверка, порядок следует неявно
    // без product нет order
    assert(env.repo.findByClientId.isBefore(env.repo.save))

CallLog создаётся вручную только там, где проверка порядка нужна. Тесты, которым это не важно, не знают о его существовании.


Как это работает под капотом

Вот черновик раздела, посмотри:


Как это работает под капотом

Хорошо, вот обновлённая версия абзаца:


Как это работает под капотом

scalamock использует два механизма Scala — макросы и неявные преобразования.

Когда ты пишешь stub[ProductRepository], макрос генерирует реализацию трейта. Для каждого метода создаётся StubbedMethod[Args, Result] с кэшом — она хранит настроенный результат и записывает все входящие вызовы с аргументами. Когда метод вызывается, он обращается к StubbedMethod: берёт результат или бросает NotImplementedError, если результат не настроен.

Когда ты пишешь env.products.findById.returnsWith(...) — здесь происходит ETA расширение: findById автоматически преобразуется из метода в функцию ProductId => Option[Product], а затем неявное преобразование ищет соответствующий методуStubbedMethod[ProductId, Option[Product]].

Можно вызвать это преобразование явно используя методstubbed:

val findById: StubbedMethod[ProductId, Option[Product]] =
  stubbed(env.products.findById)

findById.returnsWith(None)

// или указав тип явно
val findById: StubbedMethod[ProductId, Option[Product]] =
  env.products.findById

Это может быть полезно, если хочешь переиспользовать ссылку на StubbedMethod в нескольких местах теста.


Параметризованные тест-кейсы

Это место, где новый API раскрывается по-настоящему. Поведение задаётся один раз в Env, тест-кейсы только проверяют результат:

class OrderServiceSpec extends FunSuite, Stubs:

  val product = Product(ProductId("123"), stock = 10)
  val email   = Email("user@example.com")

  class Env:
    val products      = stub[ProductRepository]
    val notifications = stub[NotificationService]
    val service       = OrderService(products, notifications)

    products.findById.returns:
      case ProductId("123") => Some(product)
      case _                => None

    notifications.notify.returnsWith(())

  case class Verify(notifyCalledTimes: Int = 0)

  def testCase(
    description: String,
    productId: ProductId,
    quantity: Int,
    expected: Either[OrderError, Order],
    verify: Verify = Verify()
  ): Unit =
    test(description):
      val env    = Env()
      val result = env.service.placeOrder(productId, quantity, email)

      assertEquals(result, expected)
      assertEquals(env.notifications.notify.times, verify.notifyCalledTimes)

  testCase(
    description = "товар не найден",
    productId = ProductId("999"), 
    quantity = 1,
    expected = Left(OrderError.ProductNotFound)
  )

  testCase(
    description = "недостаточно товара",
    productId = ProductId("123"),
    quantity = 99, 
    expected = Left(OrderError.InsufficientStock)
  )

  testCase(
    description = "успешное оформление заказа",
    productId = ProductId("123"),
    quantity = 1, 
    expected = Right(Order(ProductId("123"), 1, email)),
    verify = Verify(notifyCalledTimes = 1)
  )

Интеграция с функциональными системами эффектов

ZIO

//> using dep org.scalamock::scalamock-zio::7.5.5

import org.scalamock.stubs.ZIOStubs
import zio.test.*

trait TokenRepository:
  def findByClientId(clientId: String): IO[RepositoryError, Option[ClientRecord]]
  def save(token: IssuedToken): IO[RepositoryError, Unit]

class TokenIssuerSpec extends ZIOSpecDefault, ZIOStubs:

  class Env:
    val repo   = stub[TokenRepository]
    val issuer = TokenIssuer(repo)

  override def spec = suite("TokenIssuer")(
    test("неизвестный клиент"):
      val env = Env()
      for
        _      <- env.repo.findByClientId.succeedsWith(None)
        result <- env.issuer.issue("unknown", Set("read"))
      yield assertTrue(result == Left(AuthError.UnknownClient))
    ,
    test("успешная выдача"):
      val env = Env()
      for
        _      <- env.repo.findByClientId.succeedsWith(Some(knownClient))
        _      <- env.repo.save.succeedsWith(())
        result <- env.issuer.issue("client-123", Set("read"))
        times  <- env.repo.save.timesZIO
      yield assertTrue(result.isRight, times == 1)
  )

succeedsWith / failsWith / diesWith возвращают ZIO и удобно встраиваются в for-comprehension. Если нужно поведение от аргументов — returnsZIO:

env.repo.findByClientId.returnsZIO:
  case "client-123" => ZIO.succeed(Some(knownClient))
  case _            => ZIO.succeed(None)

cats-effect

//> using dep org.scalamock::scalamock-cats-effect::7.5.5

import org.scalamock.stubs.CatsEffectStubs
import munit.CatsEffectSuite

class TokenIssuerSpec extends CatsEffectSuite, CatsEffectStubs:

  class Env:
    val repo   = stub[TokenRepository]
    val issuer = TokenIssuer(repo)

  test("успешная выдача"):
    val env = Env()
    for
      _      <- env.repo.findByClientId.succeedsWith(Some(knownClient))
      _      <- env.repo.save.succeedsWith(())
      result <- env.issuer.issue("client-123", Set("read"))
      times  <- env.repo.save.timesIO
    yield assertEquals(times, 1)

Данный проект родился из моей боли связанной с мок-тестированием. Надеюсь и вам это сэкономит время и нервы.
И я буду рад обратной связи.

Документация: scalamock.org/stubs

Вопросы, идеи, баги

Комментарии (0)