Моки — достаточно крутой инструмент, если использовать его правильно.
И все-таки лично для меня писать и поддерживать тесты на моках всегда было отдельным видом боли. Думаю, все знакомы с ситуацией: добавил в метод новый аргумент — и пошёл в 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]— если один аргумент типа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
Автор: goshacodes
