Fake-объекты практичнее mock-объектов

Стоит помнить  —  если вы имеете дело с неудачно разработанным 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-метода тип.

Код

Большинство фрагментов кода выше взяты отсюда.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Pravin Sonawane: Mocking is not practical — Use fakes