1. Mockito: что это такое и зачем нужно

Говоря коротко, Mockito – фреймворк для работы с заглушками.

Как известно, при тестировании кода (прежде всего юнит-тестировании, но не только) тестируемому элементу часто требуется предоставить экземпляры классов, которыми он должен пользоваться при работе. При этом часто они не должны быть полнофункциональными – наоборот, от них требуется вести себя жёстко заданным образом, так, чтобы их поведение было простым и полностью предсказуемым. Они и называются заглушками (stub). Чтобы их получить, можно создавать альтернативные тестовые реализации интерфейсов, наследовать нужные классы с переопределением функционала и так далее, но всё это достаточно неудобно, избыточно и чревато ошибками. Более удобное во всех смыслах решение – специализированные фреймворки для создания заглушек. Одним из таковых (и, пожалуй, самым известным для Java) и является Mockito.

Mockito позволяет создать одной строчкой кода так называемый mock (что-то вроде основы для нужной заглушки) любого класса. Для такого mock сразу после создания характерно некое поведение по умолчанию (все методы возвращают заранее известные значения — обычно это null либо 0). Можно переопределить это поведение желаемым образом, проконтролировать с нужной степенью детальности обращения к ним так далее. В результате mock и становится заглушкой с требуемыми свойствами.

Также mock можно создать и для тех классов, новый экземпляр которых вообще-то так просто не создашь, в частности, классов с исключительно приватными конструкторами типа синглтонов и утилитных классов, а при минимальной настройке фреймворка — и перечислений (enums).

Наибольшее распространение получили следующие возможности Mockito :

  • создание заглушек для классов и интерфейсов;

  • проверка вызова метода и значений передаваемых методу параметров;

  • использование концепции «частичной заглушки», при которой заглушка создается на класс с определением поведения, требуемое для некоторых методов класса;

  • подключение к реальному классу «шпиона» spy для контроля вызова методов.

2. Mock и Spy

Центральный класс Mockito, через который предполагается обращаться к большей части функционала, — это, собственно, класс под названием Mockito (есть также класс BDDMockito, предоставляющий примерно те же возможности в форме, более подходящей для BDD. Доступ к функционалу реализован через его статические методы.

Помните, что методы mock объекта возвращают значения по умолчанию: false для boolean, 0 для int, пустые коллекции, null для остальных объектов. Для spy — значение, возвращаемое методом реального объекта.

Для того, чтобы отличить mock-объект от обычного в составе Mockito для этого есть инструмент — метод Mockito.mockingDetails. Передав ему произвольный объект, получим объект класса MockingDetails. Он содержит информацию о том, что этот объект представляет собой с точки зрения Mockito: является ли он mock, spy (см. ниже), как использовался, как был создан и прочее.

Поведение по умолчанию (и не только его) можно изменить при помощи функционала класса MockSettings, но это нечасто бывает нужно.

Чтобы создать Mockito объект можно использовать либо аннотацию @Mock, либо метод mock.

@Mock
ICalculator mcalc;

ICalculator mcalc = Mockito.mock(ICalculator.class);

Однако, что если необходимо использовать в качестве заглушки объект реального класса с имеющимся функционалом, переопределив работу только части его методов? На этот случай в Mockito есть так называемые spy, "шпионы". В отличие от mock’ов, их можно создавать на основе как класса, так и готового объекта.

DataService dataServiceSpy = Mockito.spy(DataService.class);
// or
DataService dataService = new DataService();
dataServiceSpy = Mockito.spy(dataService);

При создании spy на основе класса, если его тип — интерфейс, будет создан обычный mock-объект, а если тип — класс, то Mockito попытается создать экземпляр при помощи конструктора по умолчанию (без параметров). И только если такого конструктора нет, произойдёт ошибка и тест не сработает.

3. Управление поведением

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

Основная реализация базируется на методе Mockito.when. Этот метод принимает в качестве "параметра" вызов переопределяемого метода mock-объекта (таким образом фиксируется определяемое воздействие) и возвращает объект типа OngoingStubbing, позволяющий вызвать один из методов семейства Mockito.then…​ (так задаётся реакция на это воздействие).

//when(mock).thenReturn(value)

public interface DataService {
    void saveData(List<String> dataToSave);
    String getDataById(String id);
    String getDataById(String id, Supplier<String> calculateIfAbsent);
    List<String> getData();
    List<String> getDataListByIds(List<String> idList);
    List<String> getDataByRequest(DataSearchRequest request);
}
-----------------------------------------
List<String> data = new ArrayList<>();
data.add("dataItem");
Mockito.when(dataService.getAllData()).thenReturn(data);

После этой операции, вызвав у объекта dataService метод getAllData(), получим объект, data.

Альтернативная реализация связывания условия и результата вызова — методы семейства Mockito.do…​. Эти методы позволяют задать поведение начиная с результата вызова и возвращают объект класса Stubber, уже при помощи которого можно задать условие.

//doReturn(value).when(mock).method(params)

public interface DataService {
    List<String> getData();
}
-----------------------------------------
List<String> data = new ArrayList<>();
data.add("dataItem");
Mockito.doReturn(data).when(dataService).getData()

Обратите внимание: в первой реализации при задании поведения метода (в данном случае getAllData()) сначала выполняется вызов ещё не переопределённой его версии, и только потом, в недрах Mockito, происходит переопределение. Во второй же такого вызова не происходит — методу Stubber.when передаётся непосредственно mock, а уже у возвращённого этим методом объекта того же типа, но другой природы совершается вызов переопределяемого метода. Эта разница всё и определяет. Связывание через Mockito.do…​ никак не контролирует на стадии компиляции то, какой переопределяемый метод я вызову и совместим ли он по типу с заданным возвращаемым значением.

3.1. Задание условий вызова

Пример выше касается метода без параметров, и связанное с ним условие вызова возможно одно — сам факт вызова. Как только появляются параметры, ситуация становится сложнее. Как минимум, для вызова метода, поведение которого я задаю, мне нужно что-то ему передать. Но важнее другое: может оказаться, что задаваемую реакцию я хочу получать не всегда, а только при вызове с параметрами, отвечающими определённым требованиям. Если нужно задать реакцию на любой вызов этого метода независимо от аргументов, можно воспользоваться методом Mockito.any

public interface DataService {
    String getDataItemById(String id);
-----------------------------------------
Mockito.when(dataService.getDataItemById(any()))
       .thenReturn("dataItem");

Если же требуется, чтобы mock реагировал только на определённое значение аргумента, можно использовать непосредственно это значение или методы Mockito.eq (когда речь об эквивалентности) либо Mockito.same (когда требуется сравнение ссылок)

Mockito.when(dataService.getDataItemById("idValue"))
       .thenReturn("dataItem");
// or
Mockito.when(dataService.getDataItemById(Mockito.eq("idValue")))
       .thenReturn("dataItem");

При работе с методами с более чем одним аргументом заданные требования комбинируются в соответствии с логическим И, то есть для получения заданного результата КАЖДЫЙ из аргументов должен отвечать поставленному требованию.

Кроме того, при задании поведения такого метода нельзя комбинировать использующие матчеры статические методы Mockito и прямую передачу значений. Используйте Mockito.eq или Mockito.same

3.2. Задание результатов вызова

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

Mockito.when(dataService.getAllData()).thenReturn(data);

Также

Mockito.when(dataService.getDataById("invalidId"))
       .thenThrow(new IllegalArgumentException());

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

Выше варианты реакции подходят, если в ответ на вызов с заданными условиями нужно всегда возвращать определённое, всегда одно и то же значение результата или выбрасывать всегда одинаковое исключение. Предположим, метод принимает коллекцию значений, а возвращает другую коллекцию значений, связанных с первыми одно к одному (например, это получение коллекции объектов данных по набору их ID), и в рамках теста необходимо использовать этот mock-объект неоднократно с разными наборами входных данных, получая каждый раз соответствующий результат. В Mockito есть метод Mockito.thenAnswer, он же Mockito.then. Он принимает реализацию функционального интерфейса Answer, единственный метод которого получает объект класса InvocationOnMock.

Mockito.when(dataService.getDataByIds(Mockito.any()))
       .thenAnswer(invocation -> invocation
                .<List<String>>getArgument(0).stream()
                .map(id -> {
                    switch (id) {
                        case "a":
                            return "dataItemA";
                        case "b":
                            return "dataItemB";
                        default:
                            return null;
                    }
                })
                .collect(Collectors.toList()));

Обратите внимание: типобезопасности InvocationOnMock не обеспечивает — аргументы возвращаются либо в виде массива Object[], либо generic-методом.

Отдельно стоит упомянуть ещё один вариант реакции — thenCallRealMethod. Предназначение понятно из названия. Он действует как для mock-, так и для spy-объектов. В случае mock все поля объекта, к которым может обратиться код метода, будут опять-таки иметь значение null. Для spy же использование thenCallRealMethod означает возвращение к поведению spy по умолчанию.

Методы thenReturn и thenThrow имеют перегруженные версии, принимающие varargs.

Mockito.when(dataService.getDataById("a"))
       .thenReturn("valueA1", "valueA2")
       .thenThrow(IllegalArgumentException.class);

Здесь первый вызов метода с заданным параметром вернёт "valueA1, второй — "valueA2, а третий (и все последующие) будет вызывать выбрасывание IllegalArgumentException.

4. Слежение за вызовами методов

Метод verify позволяет проверить, была ли выполнена проверка с определенными параметрами. Если проверка не выполнялась или выполнялась с другими параметрами, то verify вызовет исключение.

Mockito.verify(dataService).getDataById(Mockito.any());

Тест с такой конструкцией пройдёт успешно, если она находится после единственного за время выполнения теста вызова метода getDataById, и упадёт, если метод не был вызван или был вызван дважды и более.

Для проверки количества вызовов определенных методов Mockito предоставляет следующие методы:

  • atLeast(int min) - не меньше min вызовов;

  • atLeastOnce() - хотя бы один вызов;

  • atMost(int max) - не более max вызовов;

  • times(int cnt) - cnt вызовов;

  • never() - вызовов не было;

Mockito.verify(dataService, Mockito.times(1)).getDataById(Mockito.any());

5. Mock-объекты как значения полей и аннотации Mockito

Если в классе теста есть поля, которым я хочу присвоить mock-объекты в качестве значений, это не обязательно делать вручную — достаточно снабдить его аннотацией @Mock.

Для spy предусмотрена аннотация @Spy — она в целом аналогична @Mock… но для spy может использоваться объект, на основе которого он будет создан (несмотря на название, этот метод предназначен не только для mock’ов, а задействует также и все нижеперечисленные аннотации). Такой объект можно сразу указать в качестве значения аннотируемого поля, но можно и не указывать — тогда spy будет создан на основе класса.

Есть аннотация @Captor для создания экземпляров ArgumentCaptor.

Ещё существует @InjectMocks. Помеченное таким образом поле инициализируется настоящим объектом указанного класса. Его поля по возможности проинициализированы значениями mock-полей, помеченных соответствующей аннотацией. Для этого используется конструктор с наибольшим числом параметров, сеттеры и так далее. Если какого-то объектного параметра конструктора не хватает, вместо него будет использован null, а вот параметр-примитив просто не позволит тесту сработать. В целом это похоже на маленькую и простую (и всё равно не такую уж примитивную) реализацию dependency injection.

6. Откат поведения к дефолтному и сессии Mockito

Чтобы привести все mock-объекты в состояние по умолчанию можно использовать методы Mockito.reset и Mockito.clearInvocations. Оба принимают varargs, и передавать им нужно соответствующие mock’и.

Ещё одно решение — использовать так называемые сессии Mockito. Именно его рекомендуют авторы. В начале сессии все mock-объекты инициализируются, а после работы обязательно должно быть выполнено её окончание (хотя mock’и продолжают оставаться функциональными и после него). Если я хочу создавать отдельную сессию для каждого тестового метода, то удобно создать поле типа MockitoSession, присвоить ему значение до вызова тестового метода и завершить сессию после.

@Mock
DataService dataService;

MockitoSession session;

@BeforeMethod
public void beforeMethod() {
    session = Mockito.mockitoSession()
            .initMocks(this)
            .startMocking();
}

@Test
public void testMethod() {
    // some code using the dataService field
}

@AfterMethod
public void afterMethod() {
    session.finishMocking();
}