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

И я не буду здесь спорить о терминологии — в этой статье я буду называть все тестовые дублёры «моками». Примеры будут на 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]— если один аргумент типаAList[(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