Отлаживал я как-то тесты и параллельно размышлял о null-safety. Звезды сошлись и родилась довольно странная идея - замокать null.

Искушения программиста смотрящего на Mockito.when().thenReturn()
Искушения программиста смотрящего на Mockito.when().thenReturn()

Ниже иллюстрация физической теории, что наблюдение может повлиять на исход эксперимента. Наблюдать можно через профилирование, но проще метриками и логами. В java оно чаще всего влияние происходит при нагрузочном тестировании (ну или в ПРОМе), когда запись в логов в файл блокирует независимые, казалось бы, потоки. Но тут ситуация другая - тест завершается успехом или падением в зависимости от уровня логирования (что почти равносильно зависимости от фазы луны).

/**  
* Эффект наблюдателя - простое наблюдение явления неизбежно изменяет его.  
*/  
public class ObserverEffectTest {
	private static final org.slf4j.Logger log = LoggerFactory.getLogger(Object.class);

	private void experiment() {
		// Создаем mock-объект
		final Object mockObject = Mockito.mock(Object.class);
		// Зависит от уровня логирования, наблюдаем ли мы за объектом
		log.info("mockObject is {}", mockObject);
		// Просим у великого Ничто вернуть нам самый популярный пароль
		Mockito.when(null).thenReturn("qwerty");
		// Проверяем, что Вселенная ниспослала на нас чудо
		Assertions.assertEquals("qwerty", mockObject.toString());
	}

	@Test
	public void experimentSuccess() {
		// Задаем уровень логирования - смотреть
		((ch.qos.logback.classic.Logger) log).setLevel(Level.ALL);

		experiment();
	}

    @Test
    public void experimentFailed() {
        // Задаем уровень логирования - не смотреть
        ((ch.qos.logback.classic.Logger) log).setLevel(Level.OFF);

        // При выполнении возникнет ошибка
        Assertions.assertThrows(RuntimeException.class, this::experiment);
    }
}

Когда я показывал коллегам как мокаю null и это работает, подрывалась их вера в адекватность java (а должна была в мою). Чтобы с вами не произошло того же, предлагаю разобрать факты по очереди.

  1. Объект класса Object можно создать и в нём уже есть несколько методов, которые можно замокать. Нас интересует toString().

  2. При создании заглушки Mockito.mock() создается proxy-объект, который имеет поведение по умолчанию: если метод возвращает значение, то возвращать null и больше ничего не делать. Для метода toString() поведение немного отличается, но сейчас это роли не играет, потому как его тоже можно переопределить.

  3. Когда мы переопределяем поведение через Mockito.when(myObject.callMyMethod(anyParams...)), происходит вызов метода "callMyMethod". Он возвращает null и в Mockito.when() действительно уходит null.
    Примечание: когда метод настроен бросать исключение или нам по другим причинам не нужен его вызов, стоит заменить конструкцию when().thenReturn() на doReturn().when().
    По полученному null Mockito явно не сможет определить метод, который мы хотим переопределить. Вместо этого, он собирает историю вызова методов у mock-объектов и берет последний. Другими словами, чтобы объяснить Mockito, какой именно метод должен быть переопределен, этот метод нужно вызвать.

  4. При логировании объекта вызывается метод toString(). Если логирование выключено или его уровень не достаточный, вызова метода toString() не происходит.

Собирая всё вместе: при логировании вызывается mockObject.toString(), это записывается в историю, Mockito изменяет поведение прокси для последней записи в истории - успех. Когда нет логирования, история пуста и Mockito падает с ошибкой "when() requires an argument which has to be 'a method call on a mock".

Любая достаточно развитая технология неотличима от магии

Если создавать прокси рефлексией вручную, без истории вызовов, придется каким-то способом указывать на конкретный метод. Для этого нужны класс, имя метода (строкой) и список параметров (на случай нескольких одноименных методов). Это будет сложно читаться и, скорее всего, будет иметь проблемы с опечатками и типизацией.

Способ работы с Mockito больше похож на магический ритуал - сделай несколько простых действий по инструкции, получи результат. Что происходит внутри - не так уж важно, ведь работает. Как по волшебству.

Сложности будут при поломке - сначала проблемы с поиском "где создаются", "где хранятся" и "где используются" данные. Еще "где стираются" или "почему не стираются". Магию придется развеять и увидеть реальные технологии.

Хотите знать, где живет магия?

Изумрудный город нашего времени
Изумрудный город нашего времени

Известные мне местные волшебники - Mockito, MDC, SecurityFilter и транзакции. Принцип их фокусов прост. Объекты заранее помещаются в глобальную переменную, доступную на всём пути выполнения кода, но спрятанную от программиста. А в нужный момент извлекаются оттуда. И это чертовски удобно использовать!

Чтобы не мешать другим шарлатанам (запущенным в других потоках), глобальным хранилищем являются ThreadLocal переменные.

А вот как обеспечивать чистоту показа нескольких фокусов подряд и подчищать контекст - эту проблему каждый решает сам.

Очистка контекста на примере фильма "Престиж"
Очистка контекста на примере фильма "Престиж"

А какие фокусы используете вы, чтобы сделать код более красивым и/или удобным в использовании?

Комментарии (4)


  1. Politura
    06.09.2023 17:45
    +1

    Пример с моком нулла конечно прикольный, но что-то не сходится, у меня полно тестов, где метод замокан, согласно сценарю теста он НЕ вызывается, а в конце делается проверка:

    verify(замоканныйОбъект, times(0)).замоканныйМетод();

    и все работает как ожидается, с ошибкой как у вас не падает.


    1. kotbajan Автор
      06.09.2023 17:45
      +4

      В примере падение происходит в строке Mockito.when(null).thenReturn("qwerty"); - в момент настройки (когда история вызовов пустая), а не в момент использования.

      Возможно, так проще

          @Test
          public void testFailed() {
              Object mockObject = Mockito.mock(Object.class);
              //mockObject.toString(); // если раскомментить, ошибки не будет
              Mockito.when(null) // тут падение
                      .thenReturn("qwerty");
              Assertions.assertEquals("qwerty", mockObject.toString());
          }


      1. Politura
        06.09.2023 17:45
        +1

        А, все, дошло о чем речь, спасибо!


  1. avost
    06.09.2023 17:45
    +4

    Mockito явно не сможет определить метод, который мы хотим переопределить. Вместо этого, он собирает историю вызова методов у mock-объектов и берет последний.

    Семён Семёныч! Несколько раз сталкивался со странным поведением моков, которое не мог побороть и приходилось тестировать обходным манёвром. О оно вон оно что! Скорее всего дело было в этом. Блин, чёртовы маги, наколдовали колдунства! :)