Моки без боли

от автора

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

И все-таки лично для меня писать и поддерживать тесты на моках всегда было отдельным видом боли. Думаю, все знакомы с ситуацией: добавил в метод новый аргумент — и пошёл в 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): Unitclass 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.5import org.scalamock.stubs.Stubsimport munit.FunSuiteclass 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.5import org.scalamock.stubs.ZIOStubsimport 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.5import org.scalamock.stubs.CatsEffectStubsimport munit.CatsEffectSuiteclass 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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1030834/