Розбираємо сучасні можливості Mockito. Частина третя
Всім привіт. Я Сергій Моренець, розробник, викладач, тренер, спікер і технічний письменник, хочу продовжити знайомство з Java-бібліотекою Mockito. У першій частині цього циклу статей я розповів про її основні можливості. Друга частина була присвячена розширеним можливостям, декларативному підходу та best practices. У цій, останній частині, я розповім про можливі проблеми, обмеження та дослідження ефективності Mockito.
Можливі проблеми
Якщо ви використовуєте inline mock maker, то на проєкті з дуже великою кількістю тестів та моків можете зіткнутися з тим, що тече або надто активно витрачається пам’ять. Це привід звернутися за роз’ясненнями до команди розробників Mockito, але якщо розв’язати проблему треба прямо зараз, ось рятівне коло, яке очищає всі моки, що використовуються:
Mockito.framework().clearInlineMocks();
Ще один корисний метод — Mockito.reset()
, який очищає мок-об’єкт від усіх замін:
Mockito.reset(orderRepository);
Офіційна документація зараховує його до worst practices, тому що краще створити новий мок замість виклику reset()
, але цей метод буде корисний, якщо ви тестуєте об’єкти, які є бінами і створюються вашими контейнерами додатків.
Ще один сюрприз чекає на тих, хто перейшов на версію 5.x і використовує методи з vararg
-аргументами:
public interface OrderRepository { List & lt; Order & gt; findByIds(Integer...ids); when(orderRepository.findByIds(Mockito.any())).thenReturn(List.of()); orders = orderRepository.findByIds(1, 2); }
При спробі їх мокувати ви отримаєте виключення, якщо використовуєте як аргумент any()
:
org.mockito.exceptions.misusing.UnnecessaryStubbingException: Unnecessary stubbings detected. Clean & maintainable test code requires zero unnecessary code.
Для цих типів аргументів потрібно використовувати any(Integer[].class)
:
when(orderRepository.findByIds(Mockito.any(Integer[].class))).thenReturn(List.of());
Коли у вас досить складні тести великого обсягу, іноді потрібно дізнатися, а чи є об’єкт моком. Якби ви використовували тільки JDK проксі, зробити це досить легко:
boolean isProxy = Proxy.isProxyClass(orderRepository.getClass());
Але для загального випадку такого варіанта не підходить. І тоді ви можете використовувати низькорівневий API у вигляді класу MockUtil
або високорівневий — Mockito.mockingDetails
:
MockingDetails details = Mockito.mockingDetails(orderRepository); boolean isMock = details.isMock(); boolean isSpy = details.isSpy(); Collection<Invocation> invocations = details.getInvocations(); Collection<Stubbing> stubbings = details.getStubbings(); String mockMaker = details.getMockCreationSettings().getMockMaker(); MockType mockType = details.getMockCreationSettings().getMockType();
Тут можна перевірити не тільки, чи мок ваш об’єкт, а й будь-яку докладну інформацію про нього:
- Кількість викликів його методів;
- Кількість замін;
- Тип mock maker і тип мока (статичний чи звичайний).
Обмеження
На жаль, і Mockito не всесильно. Уявімо, що у вас є клас Server
з приватним методом:
public class Server { private void init() {} }
Якщо ви використовували PowerMock, могли підмінити метод init
:
Server mock = PowerMockito.spy(new Server()); PowerMockito.doAnswer(invocation -> null).when(mock, "init"); mock.init(); PowerMockito.verifyPrivate(mock, times(1)).invoke("init");
Однак у Mockito мокування приватних методів не підтримується, а пропонується зробити рефакторинг/редизайн вашого класу, щоб замінювати публічні методи.
Ще одна несподівана проблема може виникнути, якщо ви спробуєте замокати деякі системні класи:
Mockito.mock(ByteBuffer.class);
У цьому випадку є добрий шанс отримати помилку:
org.mockito.exceptions.base.MockitoException: Mockito cannot mock this class: class java.nio.ByteBuffer. Caused by: org.mockito.exceptions.base.MockitoException: Unsupported settings with this type 'java.nio.ByteBuffer' at net.bytebuddy.TypeCache.findOrInsert(TypeCache.java:168)
Як видно зі stack-trace
, помилка йде від ByteBuddy і говорить про те, що деякі системні класи не можна мокувати, оскільки це позначиться на роботі самих ByteBuddy/Mockito.
При цьому таких класів досить багато, наприклад, InputStream, ThreadLocalRandom, WeakReference, ClassLoader або ObjectOutputStream.
Особливе становище класу System. Ви можете його мокувати:
System system = mock(System.class);
Але не можете мокувати його статичні методи. При спробі зробити це:
try (MockedStatic & lt; System & gt; mocked = mockStatic(System.class)) { mocked.when(System::getProperties).thenReturn(null); }
Ви гарантовано отримаєте помилку:
org.mockito.exceptions.base.MockitoException: It is not possible to mock static methods of java.lang.System to avoid interfering with class loading what leads to infinite loops
Аналогічне обмеження для класів Thread
, Arrays
та ConcurrentHashMap
. На додаток, ви не можете створювати mock/spy для рядків та класів.
Який вихід? Mockito пропонує нову інструкцію @DoNotMock
:
@DoNotMock class Server { }
При спробі мокувати такий клас, ви відразу отримаєте виключення, тому вона вбереже вас від спроби зробити це. Єдиний мінус — вона включена в повний пакет Mockito, тому вам доведеться тягнути його в production code.
Інший варіант, який пропонують автори Mockito — мокувати тільки власні класи (ті, які ви можете міняти), включаючи цей варіант у правила хорошого тесту.
Якщо ви використовуєте запечатані (sealed) типи для забезпечення інкапсуляції:
public sealed interface OrderRepository permits DefaultOrderRepository { List < Order > findAll(); Order findById(int id); final class DefaultOrderRepository implements OrderRepository {
То при спробі їх мокувати одержання виняток, тому що їх заміна неможлива:
org.mockito.exceptions.base.MockitoException: Mockito cannot mock this class: interface demo.OrderRepository. You are seeing this disclaimer because Mockito is configured to create inlined mocks. You can learn about inline mocks and their limitations under item #39 of the Mockito class javadoc. Caused by: org.mockito.exceptions.base.MockitoException: Unsupported settings with this type 'demo.OrderRepository' at net.bytebuddy.TypeCache.findOrInsert(TypeCache.java:168)
Трохи вище я писав про те, що Mockito вміє замінювати перерахування, але не завжди. Якщо ви спробуєте зробити це для перерахування, де є абстрактний метод:
enum Department {
QA {
@Override
String getName() {
return "Quality Assurance";
}
}, DEV {
@Override
String getName() {
return "Development";
}
};
abstract String getName();
}
То отримаєте помилку, тому що JDK робить такі типи sealed:
org.mockito.exceptions.base.MockitoException:
Mockito cannot mock this class: class Department.
Sealed abstract enums can't be mocked. Since Java 15 abstract enums are declared sealed, which prevents mocking.
You can still return an existing enum literal from a stubbed method call.
Caused by: org.mockito.exceptions.base.MockitoException: Unsupported settings with this type 'demo.OrderServiceTest$Department'
at net.bytebuddy.TypeCache.findOrInsert(TypeCache.java:168)
Також Mockito поки що не може замінювати native методи та методи з областю видимості package з пакета java.*.
Я вже писав про налаштування конфігурації, за яких Mockito завжди повертає моки (а не дефолтні значення):
@Mock(answer = Answers.RETURNS_MOCKS)
OrderRepository orderRepository;
Але такий підхід не завжди працює. Якщо метод OrderRepository
повертає Order
:
Order findById(int id);
То виклик findById
поверне мок для Order
. Але якщо у вас є метод, який повертає значення, яке не можна мокувати, то не буде жодних помилок, а просто повернеться null
.
Ще одне обмеження стосується версій JDK, що використовуються. Mockito 3.x і 4.x вимагала наявність JDK 8. Але ось нова версія 5.x вже встановила мінімально підтримувану версію JDK 11, тому якщо у вас старіші версії Java, то вам доведеться почекати з міграцією. Правда, якщо у вас вже найостанніша версія JDK (зараз 21), то можливі проблеми через те, що ByteBuddy не встигає підтримувати останні релізи Java і в такому випадку рекомендується встановлювати JVM-аргумент при запуску тестів: -Dnet.bytebuddy.experimental=true
Досліджуємо performance
Більшість розробників при виборі технології перш за все аналізують її функціональні можливості, а тільки потім всі інші (наприклад, ефективність, безпека використання тощо). Наскільки критична ефективність для Mockito? Вона ніяк не впливає на production code, але точно впливає на час роботи тестів, якщо ви в них використовуєте моки. Тому добре б написати benchrmarks, які виконають два завдання:
- Порівняння з єдиним на сьогодні конкурентом EasyMock.
- Порівняння трьох існуючих mock makers (прокси, inline, sub-classing).
Почнемо з другого завдання. Для тестування було обрано наступну конфігурацію:
JMH 1.36
- JDK 21.0.2
- Intel Core i9, 8 cores
- 32 GB
- 10 ітерацій обчислення, 10 ітерацій прогріву (warm-up)
Так виглядали тести, де ми мокували інтерфейс Callable трьома різними способами:
@State(Scope.Thread)
public class MockitoBenchmarking {
private Map<String, Callable> map = new HashMap<>();
@Setup
public void setup() {
Callable<?> inline = mock(Callable.class, withSettings().mockMaker(MockMakers.INLINE));
map.put("inline", inline);
Callable<?> proxy = mock(Callable.class, withSettings().mockMaker(MockMakers.PROXY));
map.put("proxy", proxy);
Callable<?> subClassing = mock(Callable.class, withSettings().mockMaker(MockMakers.SUBCLASS));
map.put("sub-classing", subClassing);
map.values().forEach(callable -> {
try {
when(callable.call()).thenReturn("1");
} catch (Exception e) {
e.printStackTrace();
}
});
}
@Param({"inline", "proxy", "sub-classing"})
private String mockMaker;
@Benchmark
public Object testMockMaker() throws Exception {
return map.get(mockMaker).call();
}
Найкращий результат — у проксі-варіанту, два інших варіантів — результат гірший на 10%.
Benchmark (mockMaker) Mode Cnt Score Error Units
MockitoBenchmarking.testMockMaker inline avgt 5 4454.500 ± 469.242 ns/op
MockitoBenchmarking.testMockMaker proxy avgt 5 4333.752 ± 500.859 ns/op
MockitoBenchmarking.testMockMaker sub-classing avgt 5 4445.100 ± 414.078 ns/op
Проте час здається занадто завищеним. Якщо ми просто створили JDK проксі, то метод call()
виконався за 1 нс, тобто в 1000 разів швидше. Вся річ у тому, що Mockito не тільки виконує метод call()
, але й робить внутрішнє логування, зберігає результати виклику (для подальшої верифікації), і все це має свою ціну.
І хоча 4 мс здаються незначним числом для інтеграційних тестів, де набагато більше часу витрачається на обмін даними із зовнішніми системами, але на юніт-тестах це може стати причиною серйозної затримки за часом. Чи можна його мінімізувати? Наприклад, обнуляти статистику за викликами (разом із негайною верифікацією):
@Benchmark
public Object testMockMaker() throws Exception {
Callable mock = map.get(mockMaker);
Object result = mock.call();
Mockito.verify(mock).call();
Mockito.clearInvocations(mock);
return result;
}
Тести показали, що це утричі уповільнює загальний прогрес:
Benchmark (mockMaker) Mode Cnt Score Error Units
MockitoBenchmarking.testMockMaker inline avgt 5 11711.189 ± 186.549 ns/op
Якщо ж усунути верифікацію:
@Benchmark
public Object testMockMaker() throws Exception {
Callable mock = map.get(mockMaker);
Object result = mock.call();
Mockito.clearInvocations(mock);
return result;
}
То ми виграємо приблизно 10%. Але при цьому не зможемо дізнатися, наскільки коректно відпрацювали наші моки.
Benchmark (mockMaker) Mode Cnt Score Error Units
MockitoBenchmarking.testMockMaker inline avgt 5 4007.729 ± 17.467 ns/opТепер спробуємо виконати ті ж випробування для EasyMock останньої версії (5.2):
Callable mock;
@Setup
public void setup() throws Exception {
mock = EasyMock.mock(Callable.class);
EasyMock.expect(mock.call()).andReturn("1").times(1, Integer.MAX_VALUE);
EasyMock.replay(mock);
}
@Benchmark
public Object testEasyMock() throws Exception {
return mock.call();
}
Результати показали перевагу перед Mockito приблизно у 100 разів:
Benchmark Mode Cnt Score Error Units
MockitoBenchmarking.testEasyMock avgt 5 47.539 ± 0.099 ns/op
Це тим більше дивно, що EasyMock використовує ті ж бібліотеки, що й Mockito: ByteBuddy, ASM та Objenesis. Я створив тикет про цю проблему, зачекаємо на офіційну відповідь від команди Mockito.
Висновки
Ми розглянули найважливіші приклади використання Mockito, які з’явилися, починаючи з версій 2.x. Потрібно відзначити, що зараз Mockito є найпопулярнішою Java-бібліотекою для мокування в першу чергу тому, що вона підтримує відразу три режими роботи:
- JDK проксі.
- Sub-classing.
- Inline byte-code modification.
Тому більше не потрібна бібліотека PowerMock для найскладніших сценаріїв роботи. Ще одна перевага Mockito — підтримка як імперативного, так і декларативного режиму роботи (за допомогою анотацій). Завдяки великій спільноті програмістів, які використовують цю технологію, відповіді на найчастіші питання чи проблеми є як на Stackoverflow, так і на самому Github-репозиторії.
Чи варто переходити на Mockito, якщо ви використовуєте конкурентні проєкти (JMock, JMockit, PowerMock)? Безперечно так, оскільки вона підтримує всі останні версії JDK, тоді як у конкурентів є проблема сумісності з версіями JDK 11+.
2 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів