Стоит помнить — если вы имеете дело с неудачно разработанным API, предпочтительнее использовать mock-объекты.
Что такое mock- и fake-объекты
Определения можно найти во множестве статей в сети. Я просто упомяну их здесь для полноты картины.
Mock — это объекты, обладающие тем же интерфейсом, что и реальные типы, но которые могут быть заданы сценарием для возврата заранее запрограммированных ответов. Они регистрируют полученные вызовы, которые можно проверить в рамках тестовых утверждений.
Fake — это объекты, обладающие полностью работоспособной реализацией реального типа, но оптимизированные и упрощённые для целей отладки.
Почему fake-объекты лучше?
Проверка состояния
Тестирование методом чёрного ящика: в чём же разница? Mock-объекты используются для тестирования поведения, а fake-объекты — для тестирования состояния.
Тестирование методом белого ящика: процесс mock-обработки вызовов, осуществляемых SUT (тестируемой системой) на её зависимостях с применением операторов when
и последующей проверкой verify
подразумевает, что тест знает и точно определяет, как тестируемая система должна себя вести. Это и называется тестированием методом белого ящика:
@Test fun whiteBoxTest() {
// GIVEN
-- initialize inputs and expected values
-- set expectations on mocks --> when(mock.doSomething(param1, param2).thenReturn(value1)
// WHEN
-- trigger SUT
// THEN
-- verify interactions with mock --> verify(mock).doSomething(param1, param2)
}
Проблемы тестирования методом белого ящика: любое изменение реализации, например оптимизация алгоритма, очистка кода или изменение порядка операторов, может привести к сбою теста. Эти изменения приводят к обновлению испытательного стенда, заставляя тест “следовать” реализации. Таким образом, тест становится ничем иным как дубликатом кода и требует такой же поддержки.
Тестирование методом чёрного ящика: тестирование состояния с помощью fake-объектов — это способ написания потребительских тестов. Тестируемая система рассматривается как “чёрный ящик”, и утверждения делаются только в сравнении с выводом тестируемой системы. Любые изменения в реализации действительны, только если подтверждены тестами. Такие тесты надёжны. Они указывают “что” и не указывают “как” (что в любом случае делает консьюмер API). Это и есть тестирование методом чёрного ящика. Имейте в виду, что тестируемая система не обязана быть одним классом:
@Test fun blackBoxTest() {
// GIVEN
-- initialize inputs and expected values
// WHEN
-- trigger SUT
// THEN
-- assert that state (output) of SUT matches expectations
}
Тестирование методом чёрного ящика заставляет нас думать об API как о консьюмере. Если для вас не так просто задавать состояние в тестах, консьюмеру API будет непросто его использовать.
Тесты как система поддержки
Тесты должны быть своего рода защитным ограждением для рефакторинга. Если алгоритмический рефакторинг заставляет вас обновлять тесты, фактически вы модифицируете и само это ограждение. Мало того, что в итоге вы меняете спецификации, как вам нравится, вы еще и выполняете больше работы. Это противоречит цели тестирования:
fun deposit(accountId: String, amount: Int, interest: Int) : Account {
val account = accountRepository.get(accountId)
// Рефакторинг: использование одной транзакции вместо двух
// Это прерывает тест с mock-объектами, но не тест с
// fake-объектами
//transactionManager.add(TransactionRequest(TransactionType.DEPOSIT, account, amount))
//transactionManager.add(TransactionRequest(TransactionType.DEPOSIT, account, interest))
transactionManager.add(TransactionRequest(TransactionType.DEPOSIT, account, amount + interest))
return transactionManager.executePendingTransactions(accountId)
}
Тесты в качестве API документации
Пользовательские тесты ведут себя как документации. Имя теста — это краткое описание поведения тестируемой системы в заданных условиях, а сам тест объясняет возможности API, коэффициент использования и граничные сценарии. Это задаёт чёткое различие между тестовым и производственным кодом.
// Различия между стилями именования тестирования по методу белого и черного ящиков. Имена теста выступают в качестве документации
@Test fun `GIVEN amount and interest to deposit WHEN deposited THEN should create deposit transactions and execute them`() {
// имя тестирования по методу белого ящика описывает проверку поведения
}
@Test fun `GIVEN amount and interest to deposit WHEN deposited THEN should update account balance`() {
// имя тестирования по методу черного ящика описывает функциональность API
}
// Тестирование по методу черного ящика с применением fake-объектов легче читается. Тело этого теста фокусируется на коэффициенте использования API
@Test
fun `GIVEN amount and interest to deposit WHEN deposited THEN should update account balance`() {
// GIVEN
val originalAccount = Account("account1", 0)
val amount = 1000
val interest = 100
val expectedAccount = originalAccount.copy(balance = 1100)
fakeAccountRepository.put(originalAccount.id, originalAccount)
// WHEN
val actualAccount = accountManager.deposit(originalAccount.id, amount, interest)
// THEN
assertEquals(expectedAccount, actualAccount)
}
Для тестов проверки поведения, использующих mock-объекты, тест описывает “как” тестируемая система взаимодействует со своими зависимостями (а это не то, до чего есть дело консьюмеру). Это тоже документация, но избыточная — для этого мы можем просто прочесть производственный код.
Функциональные и интеграционные тесты
Fake-объекты, созданные для модульного тестирования, можно легко применять для написания функциональных или интеграционных тестов. С mock-объектами это сделать сложнее.
Поддержка использования реализации
Не нужно использовать fake-объекты для всего. Чистая функция или чистый функциональный тип полностью управляются своими входными данными и отслеживаются выходными данными, что можно непосредственно использовать в тестах. Mock-подход, напротив, приводит к тому, чтобы применять mock-объекты для всех зависимостей:
// Тест с fake-объектами и использованием реального экземпляра типа
// TransactionManager
@Before
fun before() {
// fake
accountRepository = FakeAccountRepository()
transactionRepository = FakeTransactionRepository()
// реальный экземпляр
transactionManager = TransactionManager(accountRepository, transactionRepository)
// SUT
accountManager = AccountManager(accountRepository, transactionManager)
}
Другой пример: рассмотрим тестируемую систему, являющуюся не классом, а семейством классов. Например, если класс внедряется с абстрактными фабриками, вы можете использовать реальную фабрику, а fake применять только для её продуктов.
При желании, используя DI фреймворки, можно проще и чище создавать подобные тестовые установки с реальными экземплярами и fake-продуктами с возможностью многократного использования.
Fake снижает многословность
Mock-подход склонен игнорировать логику, непосредственно не проверенную тестом. Подобная логика, однако, будет реализована в производственном сценарии. Строгое применение mock помогает избежать этого, но делает тестовую установку многословнее.
Поскольку fake-объекты являются полноценными функциональными реализациями, вся логика проверяется постоянно как и в производственном коде. Тест становится менее многословным и более релевантным:
@Test fun `GIVEN amount and interest to deposit WHEN deposited THEN should create deposit transactions and execute them`() {
// GIVEN
val originalAccount = Account("account1", 0)
val amount = 1000
val interest = 100
val expectedAccount = originalAccount.copy(balance = 1100)
val amountRequest = TransactionRequest(TransactionType.DEPOSIT, originalAccount, amount)
val interestRequest = TransactionRequest(TransactionType.DEPOSIT, originalAccount, interest)
val amountTransaction = Transaction("t1", TransactionType.DEPOSIT, originalAccount, amount, TransactionStatus.PENDING)
val interestTransaction = Transaction("t2", TransactionType.DEPOSIT, originalAccount, interest, TransactionStatus.PENDING)
every { accountRepository.get(originalAccount.id) } returns originalAccount
every { transactionManager.add(amountRequest) } returns amountTransaction
every { transactionManager.add(interestRequest) } returns interestTransaction
every { transactionManager.executePendingTransactions(originalAccount.id) } returns expectedAccount
// WHEN
val actualAccount = accountManager.deposit(originalAccount.id, amount, interest)
// THEN
assertEquals(expectedAccount, actualAccount)
verify {
accountRepository.get(originalAccount.id)
transactionManager.add(amountRequest)
transactionManager.add(interestRequest)
transactionManager.executePendingTransactions(originalAccount.id)
}
}
При mock многословность высока, читаемость теста низкая:
@Test
fun `GIVEN amount and interest to deposit WHEN deposited THEN should update account balance`() {
// GIVEN
val originalAccount = Account("account1", 0)
val amount = 1000
val interest = 100
val expectedAccount = originalAccount.copy(balance = 1100)
fakeAccountRepository.put(originalAccount.id, originalAccount)
// WHEN
val actualAccount = accountManager.deposit(originalAccount.id, amount, interest)
// THEN
assertEquals(expectedAccount, actualAccount)
}
Fake богаче, чем mock
Fake-объекты имеют боле богатую функциональность. Например, они предоставляют возможность логирования. Таким образом, модульные тесты могут при необходимости генерировать логи, что упрощает отладку.
class RealLogger : Logger {
fun log(message: String) {
// отправляет логи на сервер
}
}
// При отладке тестов можно включить FakeLogger, чтобы узнать, почему тест не прошёл
class FakeLogger : Logger {
val isEnabled = false
fun log(message: String) {
if(isEnabled) println(message) // логи отображаются в консоли
}
}
Fake поддерживает принцип персональной ответственности (SRP)
Оператор when
при mock-подходе заставляет нас фокусироваться на конкретном методе, над которым реализуется mock в данный момент. Нам нет дела до того, как работает этот зависимый класс или как он устроен. Да и в целом всё равно, даже если этот метод принадлежит другому классу! Это может привести к тому, что класс API будет неудачно структурирован и нарушит SRP на уровне класса.
Даже на уровне метода использование такого ArgumentMatcher
, как any()
, как правило игнорирует сигнатуру метода (нам было бы всё равно, если бы у метода были дополнительные параметры, потому что мы бы просто использовали другой any()
!). В тесте нас интересует только то, что возвращает оператор when
.
Если метод выполняет две задачи, mock проще, а fake сложнее. Точно так же тип, выполняющий несколько действий, заставляет свой fake-объект имитировать несколько действий, что затрудняет создание и поддержку. В таком случае почему бы не заставить их делать что-то одно?
Тесты должны помогать в разработке API. Классы должно быть легко расцеплять и декомпозировать путём рефакторинга, когда тесты подтверждают, что новый дизайн работает как ожидалось:
// Тестируемая система
class CreateTicketUseCase(
private val validateTicketUseCase : ValidateTicketUseCase,
private val saveTicketUseCase : SaveTicketUseCase
) : {
fun invoke(request: CreateTicketRequest): Result<Ticket> {..}
}
// Может использовать реальный экземпляр этого класса
// SRP - имеет только одну функцию - сохранять тикет
class SaveTicketUseCase(
private val ticketRepository : TicketRepository
) : {
fun invoke(ticket: Ticket): Result<Ticket> {..}
}
// Реализация fake
class FakeTicketRepository : TicketRepository
Функциональные варианты использования — шаблон, помогающий следовать SRP.
Цена fake
Проверка API
Если вам нужны механизмы проверки, подобные mock, для отслеживания, вы можете добавить дополнительные методы к fake (например, FakeUserRepository.getUserCount()). Это дополнительные усилия, но они удерживают вас от излишнего использования библиотек, таких как mockito, которые легко некорректно использовать, что приводит к структурному тестированию.
Интерфейс с одной производственной реализацией
Использовать интерфейс для каждого тестируемого типа не очень удобно, потому что необходимо создавать fake-реализацию. Как по мне, это ничтожная цена в сравнении с предоставляемыми преимуществами.
Тестирование fake-реализации
Поскольку fake является полностью функциональной реализацией, она может быть достаточно сложна чтобы быть тестируемой. Обычно это отголосок неудачного дизайна API и намёк на необходимость сделать типы более компактными, чтобы fake-реализации оставались простыми.
Финальные замечания
Применение mock-объектов должно быть ограничено ситуациями, когда создание и поддержка fake-объектов сводит к нулю описанные выше преимущества. Такие ситуации возникают, как правило, при работе с неудачно разработанными API.
Существуют способы абстрагироваться от такого API, чтобы использование fake-объектов стало возможным, однако это выходит за рамки данной статьи. Типичные абстракции включают обёртывание или компоновку в более подходящий для fake-метода тип.
Код
Большинство фрагментов кода выше взяты отсюда.
Читайте также:
- Модульное тестирование с имитацией сетевых вызовов
- TDD и обработка исключений в ASP.NET Core с помощью xUnit
- Наглядное руководство по каждому типу тестов
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Pravin Sonawane: Mocking is not practical — Use fakes