Вопрос-ответ

Why are my mocked methods not called when executing a unit test?

Почему мои издевательские методы не вызываются при выполнении модульного теста?

Предисловие:

Этот вопрос и ответ задуманы как канонический ответ на большинство вопросов, возникающих из-за неправильного использования Mockito или непонимания того, как Mockito работает и взаимодействует с модульными тестами, написанными на языке Java.



Я реализовал класс, который следует модульно тестировать. Обратите внимание, что приведенный здесь код является всего лишь фиктивной реализацией и Random предназначен для иллюстрации. Реальный код использовал бы реальную зависимость, такую как другой сервис или репозиторий.

public class MyClass {
public String doWork() {
final Random random = new Random(); // the `Random` class will be mocked in the test
return Integer.toString(random.nextInt());
}
}

Я хочу использовать Mockito для издевательства над другими классами и написал действительно простой JUnit-тест. Однако мой класс не использует макет в тесте:

public class MyTest {
@Test
public void test() {
Mockito.mock(Random.class);
final MyClass obj = new MyClass();
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
// this fails, because the `Random` mock is not used :(
}
}

Даже запуск теста с помощью MockitoJUnitRunner (JUnit 4) или расширение с помощью MockitoExtension (JUnit 5) и аннотирование с помощью @Mock не помогает; все еще используется реальная реализация:

@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTest {
@Mock
private Random random;

@Test
public void test() {
final MyClass obj = new MyClass();
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
// `Random` mock is still not used :((
}
}

Почему издевательский класс не используется, хотя методы Mockito вызываются перед тестированием моего класса или тест выполняется с расширением Mockito / runner?


Другие варианты этого вопроса включают, но не ограничиваются ими:


  • Мои издевательства возвращают null / Мои заглушки возвращают null

  • Исключение NullPointerException при использовании Mockito

  • Мои издевательства имеют значение null в тесте

  • Мои издевательства не возвращают ожидаемое значение / Мои заглушки не возвращают ожидаемое значение

  • Mockito thenReturn не соблюдается / Mockito thenAnswer не соблюдается

  • @InjectMocks не работает

  • @Mock не работает

  • Mockito.mock не работает

  • Мой класс не использует mocks / Мой класс не использует заглушки

  • Мои тесты по-прежнему вызывают или выполняют реальную реализацию издевательского / заглушенного класса

Переведено автоматически
Ответ 1

TLDR: существуют два или более разных экземпляра вашего макета. В вашем тесте используется один экземпляр, а в тестируемом классе - другой экземпляр. Или вы вообще не используете mocks в своем классе, потому что вы new создаете объекты внутри класса.


Обзор проблемы (классы против экземпляров)

Макеты - это экземпляры (вот почему их также называют "макетными объектами"). Вызов Mockito.mock класса вернет макет объекта для этого класса. Это должно быть присвоено переменной, которая затем может быть передана соответствующим методам или внедрена как зависимость в другие классы. Это не изменяет сам класс! Подумайте об этом: если бы это было правдой, то все экземпляры класса каким-то волшебным образом были бы преобразованы в mocks. Это сделало бы невозможным издевательство над классами, в которых используется несколько экземпляров, или классами из JDK, такими как List или Map (которые не следует издеваться в первую очередь, но это другая история).

То же самое справедливо для @Mock аннотации с расширением Mockito / runner: создается новый экземпляр фиктивного объекта, который затем присваивается полю (или параметру), аннотированному с помощью @Mock. Этот фиктивный объект все еще необходимо передать правильным методам или внедрить как зависимость.

Еще один способ избежать этой путаницы: new в Java будет всегда выделять память для объекта и инициализировать этот новый экземпляр реального класса. Невозможно переопределить поведение new. Даже такие умные фреймворки, как Mockito, не могут этого сделать.


Решение

»Но как я могу издеваться над своим классом?« - спросите вы. Измените дизайн своих классов, чтобы их можно было тестировать! Каждый раз, когда вы решаете использовать new, вы привязываете себя к экземпляру именно этого типа. Существует несколько вариантов, в зависимости от вашего конкретного варианта использования и требований, включая, но не ограничиваясь ими:


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

  2. Если вы не можете изменить сигнатуру метода, внедрите зависимость в свой конструктор и сохраните ее в поле, которое позже будет использоваться методами.

  3. Иногда экземпляр должен быть создан только при вызове метода, а не до него. В этом случае вы можете ввести другой уровень косвенности и использовать нечто, известное как абстрактный фабричный шаблон. Затем объект factory создаст и вернет экземпляр вашей зависимости. Может существовать несколько реализаций фабрики: одна, которая возвращает реальную зависимость, и другая, которая возвращает тестовый дубль, например макет.

Ниже вы найдете примеры реализаций для каждого из вариантов (с бегуном / расширением Mockito и без него):

Изменение сигнатуры метода

public class MyClass {
public String doWork(final Random random) {
return Integer.toString(random.nextInt());
}
}

public class MyTest {
@Test
public void test() {
final Random mockedRandom = Mockito.mock(Random.class);
final MyClass obj = new MyClass();
Assertions.assertEquals("0", obj.doWork(mockedRandom)); // JUnit 5
// Assert.assertEquals("0", obj.doWork(mockedRandom)); // JUnit 4
}
}

@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;

@Test
public void test() {
final MyClass obj = new MyClass();
Assertions.assertEquals("0", obj.doWork(random)); // JUnit 5
// Assert.assertEquals("0", obj.doWork(random)); // JUnit 4
}
}

Внедрение зависимости конструктора

public class MyClass {
private final Random random;

public MyClass(final Random random) {
this.random = random;
}

// optional: make it easy to create "production" instances (although I wouldn't recommend this)
public MyClass() {
this(new Random());
}

public String doWork() {
return Integer.toString(random.nextInt());
}
}

public class MyTest {
@Test
public void test() {
final Random mockedRandom = Mockito.mock(Random.class);
final MyClass obj = new MyClass(mockedRandom);
// or just obj = new MyClass(Mockito.mock(Random.class));
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
}
}

@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;

@Test
public void test() {
final MyClass obj = new MyClass(random);
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
}
}

Отложенное построение с помощью Factory

В зависимости от количества аргументов конструктора вашей зависимости и потребности в выразительном коде можно использовать существующие интерфейсы из JDK (Supplier, Function, BiFunction) или ввести пользовательский заводской интерфейс (с пометкой @FunctionInterface, если у него есть только один метод).

Следующий код выберет пользовательский интерфейс, но будет отлично работать с Supplier<Random>.

@FunctionalInterface
public interface RandomFactory {
Random newRandom();
}

public class MyClass {
private final RandomFactory randomFactory;

public MyClass(final RandomFactory randomFactory) {
this.randomFactory = randomFactory;
}

// optional: make it easy to create "production" instances (again: I wouldn't recommend this)
public MyClass() {
this(Random::new);
}

public String doWork() {
return Integer.toString(randomFactory.newRandom().nextInt());
}
}

public class MyTest {
@Test
public void test() {
final RandomFactory randomFactory = () -> Mockito.mock(Random.class);
final MyClass obj = new MyClass(randomFactory);
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
}
}

@ExtendWith(MockitoExtension.class) // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private RandomFactory randomFactory;

@Test
public void test() {
// this is really awkward; it is usually simpler to use a lambda and create the mock manually
Mockito.when(randomFactory.newRandom()).thenAnswer(a -> Mockito.mock(Random.class));
final MyClass obj = new MyClass(randomFactory);
Assertions.assertEquals("0", obj.doWork()); // JUnit 5
// Assert.assertEquals("0", obj.doWork()); // JUnit 4
}
}

Следствие: (Неправильное) использование @InjectMocks


Но я использую @InjectMocks и проверил с помощью отладчика, что у меня есть макеты внутри тестируемого класса. Тем не менее, макетные методы, которые я настроил с помощью Mockito.mock и Mockito.when, никогда не вызываются! (Другими словами: "Я получаю NPE", "мои коллекции пусты", "возвращаются значения по умолчанию" и т.д.)


— сбитый с толку разработчик, ок. 2022


Выраженная в коде приведенная выше цитата будет выглядеть примерно так:

@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;

@InjectMocks
private MyClass obj;

@Test
public void test() {
random = Mockito.mock(Random.class);
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}

Проблема с приведенным выше кодом заключается в первой строке в test() методе: он создает и присваивает новый макет экземпляра полю, фактически перезаписывая существующее значение. Но @InjectMocks вводит исходное значение в тестируемый класс (obj). Экземпляр, созданный с помощью Mockito.mock, существует только в тесте, а не в тестируемых классах.

Порядок операций здесь следующий:


  1. Всем @Mock полям с аннотациями присваивается новый макет объекта.

  2. В поле с @InjectMocksаннотациями вводятся ссылки на макетные объекты с шага 1.

  3. Ссылка в тестовом классе перезаписывается другой ссылкой на новый макет объекта (созданный через Mockito.mock). Исходная ссылка потеряна и больше не доступна в тестовом классе.

  4. Тестируемый класс (obj) по-прежнему содержит ссылку на исходный макет экземпляра и использует его. У теста есть ссылка только на новый макет экземпляра.

В основном это сводится к Является ли Java "передачей по ссылке" или "передачей по значению"?.

Вы можете проверить это с помощью отладчика. Установите точку останова, а затем сравните адреса объектов / идентификаторы фиктивных полей в тестируемом классе и в тестируемом классе. Вы заметите, что это два разных, не связанных экземпляра объекта.

Решение? Не перезаписывайте ссылку, а настройте макет экземпляра, созданный с помощью аннотации. Просто избавьтесь от повторного назначения с помощью Mockito.mock:

@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;

@InjectMocks
private MyClass obj;

@Test
public void test() {
// this.random must not be re-assigned!
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}

Следствие: жизненные циклы объектов и аннотации magic Framework


Я последовал вашему совету и использовал внедрение зависимостей, чтобы вручную передать макет в свой сервис. Это все еще не работает, и мой тест завершается ошибкой с исключениями нулевого указателя, иногда даже до фактического запуска одного тестового метода. Ты солгал, братан!


— еще один сбитый с толку разработчик, конец 2022 года


Код, скорее всего, будет выглядеть примерно так:

@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;

private final MyClass obj = new MyClass(random);

@Test
public void test() {
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}

Это очень похоже на первое следствие и сводится к жизненным циклам объекта и ссылкам на значения. Шаги, выполняемые в приведенном выше коде, следующие:


  1. Новый экземпляр MyTestAnnotated создается платформой тестирования (например, new MyTestAnnotated()).

  2. Выполняются все конструкторы и инициализаторы полей. Здесь нет конструкторов, но есть инициализатор поля: private MyClass obj = new MyClass(random);. На данный момент random поле по-прежнему имеет значение по умолчанию nullobj полю присвоено new MyClass(null).

  3. Всем @Mock полям с аннотациями присваивается новый макет объекта. Это не обновляет значение в MyService obj, потому что оно было передано null изначально, а не является ссылкой на макет.

В зависимости от вашей MyService реализации, это может привести к сбою уже при создании экземпляра тестового класса (MyService может выполнять проверку параметров его зависимостей в конструкторе); или это может привести к сбою только при выполнении тестового метода (потому что зависимость равна null).

Решение? Ознакомьтесь с жизненными циклами объектов, порядком инициализации полей и моментом времени, когда макетные фреймворки могут / будут внедрять свои макеты и обновлять ссылки (и какие ссылки обновляются). Старайтесь избегать смешивания "волшебных" аннотаций фреймворка с ручной настройкой. Либо создайте все вручную (mocks, service), либо переместите инициализацию в методы, помеченные @Before (JUnit 4) или @BeforeEach (JUnit 5).

@ExtendWith(MockitoExtension.class)   // JUnit 5
// @RunWith(MockitoJUnitRunner.class) // JUnit 4
public class MyTestAnnotated {
@Mock
private Random random;

private MyClass obj;

@BeforeEach // JUnit 5
// @Before // JUnit 4
public void setup() {
obj = new MyClass(random);
}

@Test
public void test() {
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}

В качестве альтернативы, настройте все вручную без аннотаций, для которых требуется пользовательский бегун / расширение:

public class MyTest {
private Random random;
private MyClass obj;

@BeforeEach // JUnit 5
// @Before // JUnit 4
public void setup() {
random = Mockito.mock(random);
obj = new MyClass(random);
}

@Test
public void test() {
Mockito.when(random.nextInt()).thenReturn(42);
Assertions.assertEquals("42", obj.doWork()); // JUnit 5
// Assert.assertEquals("42", obj.doWork()); // JUnit 4
}
}

Ссылки

java unit-testing junit