Розбираємо сучасні можливості Mockito

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер і технічний письменник, хочу поділитися з вами своїм досвідом використання бібліотеки Mockito.

Якщо ви часто пишете тести на Java, швидше за все, використовуєте зв’язку Junit + Mockito + Hamcrest, яка себе добре зарекомендувала і, по суті, не має конкурентів у своїй ніші. У цій зв’язки Mockito потрібна для заміни (stubbing) коду, але її можливості постійно змінювалися на всіх етапах її розвитку, тому мені хотілося б актуалізувати і систематизувати все головне, що є в поточній версії (5.10).

Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про останні тенденції та практики з написання автоматизованих тестів, для тих, хто застосовує старі версії Mockito (legacy-проєкти) і хоче дізнатися, що йому дасть міграція на нову версію, а також для тих, хто хоче перейти з інших бібліотек мокування (PowerMock, JMockit, JMock, EasyMock). Оскільки матеріалу зібралося дуже багато як для однієї статті, то я вирішив розбити її на кілька частин.

Основні можливості

Ще 10 років тому серед бібліотек мокування йшла гостра боротьба за виживання та своїх шанувальників. Але в 2024 році можна констатувати, що, по суті, ця битва завершилася явною перемогою Mockito. Проєкти JMockit та JMock не підтримуються понад три роки.

Останній коміт проєкту PowerMock був 24 лютого(!) 2022 року. І тільки EasyMock ще намагається розвиватися, але як свідчить статистика Stackoverflow, його популярність приблизно в 13 разів менше, ніж Mockito. Тому якщо і вивчати/використовувати інструменти мокування, краще всього почати саме з Mockito.

Додати його до свого проєкту досить просто:

              <dependency>
                     <groupId>org.mockito</groupId>
                     <artifactId>mockito-core</artifactId>
                     <version>5.10.0</version>
                     <scope>test</scope>
              </dependency>

Водночас сама бібліотека досить легковісна і завжди була такою. Якщо jar-файл для версій 3.x займав 59 Кб, то зараз лише 70 Кб. Щоправда Mockito транзитивно підтягує за собою бібліотеки ByteBuddy (4 Мб), ByteBuddy агент (250 Кб) та Objenesis (50 Кб). Але про це трохи згодом.

Який базовий алгоритм її використання? Уявімо, що ми маємо клас OrderService, який ми хочемо протестувати:

@RequiredArgsConstructor
public class OrderService {
      
       private final OrderRepository orderRepository;
      
       public List<Order> findAll() {
              return orderRepository.findAll();
       }
}

Водночас він має залежність — OrderRepository, і для написання юніт-тесту добре б її ізолювати:

public interface OrderRepository {
      
       List<Order> findAll();
      
       Order findById(int id);
 
}

Це пов’язано зокрема й з тим, що така залежність і її реалізації вимагають наявності СУБД. Писати ж клас-заглушку (stub) ми не хочемо, оскільки у цьому інтерфейсі багато методів, наприклад, до роботи потрібен лише один. Тоді, використовуючи клас Mockito та її метод mock(), ми передаємо потрібний нам клас і отримуємо об’єкт того ж типу:

       OrderRepository orderRepository = mock(OrderRepository.class);

Водночас у консолі відразу з’являється цікаве повідомлення, що використовується бібліотека ByteBuddy та її агент:

WARNING: A Java agent has been loaded dynamically (\.m2\repository\net\bytebuddy\byte-buddy-agent\1.14.10\byte-buddy-agent-1.14.10.jar)

Якого типу змінна orderRеpository і звідки вона взялася? Її тип буде приблизно таким: OrderRepository$MockitoMock$Vlotqvtw. І ця назва говорить про те, що це згенерований на льоту тип, швидше за все, за допомогою бібліотеки ByteBuddy. Що буде, якщо ми зараз викличемо методи з цього об’єкту:

       Order order = repository.findById(1);
       List<Order> orders = repository.findAll();

Цікаво, що order тут буде null, а orders — порожній список (LinkedList), тобто не завжди повертаються дефолтні значення для типів Order та List. Зрозуміло, що порожній список більш безпечний і кращий у роботі, ніж null, тому що не викличе NullPointerException. Але все ж таки, де зашита така цікава логіка? У класі ReturnsEmptyValue:

if (Primitives.isPrimitiveOrWrapper(type)) {
            return Primitives.defaultValue(type);
            // new instances are used instead of Collections.emptyList(), etc.
            // to avoid UnsupportedOperationException if code under test modifies returned
            // collection
        } else if (type == Iterable.class) {
            return new ArrayList<>(0);
        } else if (type == Collection.class) {
            return new LinkedList<>();
        } else if (type == Set.class) {
            return new HashSet<>();
        } else if (type == HashSet.class) {
            return new HashSet<>();
        } else if (type == SortedSet.class) {
            return new TreeSet<>();
        } else if (type == TreeSet.class) {
            return new TreeSet<>();

Більше того, якщо ваш метод повертає Integer, то в мок-варіанті він за замовчуванням поверне 0, а не null. У Mockito є навіть підтримка Optional у різних варіаціях:

static Object returnCommonEmptyValueFor(Class<?> type) {
        if (type == Optional.class) {
            return Optional.empty();
        } else if (type == OptionalDouble.class) {
            return OptionalDouble.empty();
        } else if (type == OptionalInt.class) {
            return OptionalInt.empty();
        } else if (type == OptionalLong.class) {
            return OptionalLong.empty();
        }
        return null;
    }

Такий підхід може призвести до прихованих помилок, так як ви можете просто забути перевизначити потрібний вам метод з мок-об’єкта. А що, якщо вам потрібно все-таки повернути null або, наприклад, ви хочете викинути виняток для всіх невизначених (un-stubbed) методів? Для цього є свій API, де ви другим аргументом передаєте об’єкт типу Answer, який визначає дефолтну логіку реалізації в orderRepository:

       OrderRepository orderRepository = mock(OrderRepository.class, invocation -> { throw new IllegalStateException(); });

Тепер під час виклику будь-якого методу OrderRepository буде викидатися IllegalStateException. Правда тепер ви не можете перевизначати (stub) методи в цьому інтерфейсі, тому що при цьому завжди буде викликатися метод, який викидатиме це же виключення.

Змінити дефолтну відповідь (answer) за умовчанням теж не складно, використовуючи перерахування Answers:

OrderRepository repository = mock(OrderRepository.class, Answers.RETURNS_MOCKS);

У цьому випадку всі методи OrderRepository повертають дефолтні значення для тих типів, де вони визначені, а для всіх інших — мок-об’єкти, які ми також можемо перепрограмувати. Тобто хоч ми й не вказали поведінку для нового мок-об’єкта, Mockito зробив це за нас, повернувши дефолтні значення. Це можна вважати і перевагою, і недоліком.

Якщо у вас клас з великою кількістю методів, можна взагалі не вказувати поведінку для них. З іншого боку, для якихось ключових методів, що вимагають реалізації, такий підхід буде помилковим, але жодної помилки не викличе.

Як змінити (stub) поведінку будь-якого з методів мока? Для цього є кілька конструкцій:

  • when — thenReturn;
  • doReturn — when.

Дана конструкція говорить Mockito завжди повертати порожній список при виклик findAll() у мока:

when(orderRepository.findAll()).thenReturn(List.of());

Тепер поговоримо про те, як працює магія мокування. Справа в тому, що Mockito 1.x використовував CgLib як бібліотеку для генерації проксі-класів. Але Mockito 2.1, що вийшов у 2016 році, перейшов на ByteBuddy і зараз це єдиний інструмент для модифікації коду. Що цікаво, ви гарантовано отримаєте просідання у швидкодії.

Тут може виникнути закономірне питання. А навіщо потрібна ще й бібліотека Objenesis, якщо є такий потужний інструмент, як ByteBuddy? У Objenesis є лише одне завдання — створення об’єктів без виклику конструктора, оскільки іноді це або неможливо, або може призвести до побічних ефектів.

Водночас до версій 5.x Mockito за умовчанням використовував так званий режим (mock maker) sub-classing, коли на льоту генерувалися класи-спадкоємці як моки. Це призводило до певних обмежень, якщо класи були final. Починаючи з версій 5.x за умовчанням використовується режим inline, коли класи модифікуються на льоту за допомогою того ж ByteBuddy.

Водночас залишилася можливість використовувати sub-classing для окремих випадків, де не підтримується динамічна модифікація байт-коду (GraalVM або Android). Для цього необхідно підключити залежність mockito-subclass.

І починаючи з Mockito 4.8.0 ви можете прямо під час створення мока явно вказати потрібний вам режим mock maker:

              OrderRepository orderRepository = mock(OrderRepository.class, withSettings().mockMaker(MockMakers.SUBCLASS));

Наскільки безпечною є така модифікація байт-коду? Уявімо, що у нас є інтерфейс з деякими анотаціями:

public interface OrderRepository {
 
       @Deprecated
       List<Order> findAll();

Чи збережуться вони, якщо ми створимо мок? Так, збережуться, як і все інше в сигнатурі класу.

Для OrderRepository динамічна модифікація коду взагалі не потрібна, тому що це інтерфейс, і ми могли б використовувати і JDK Proxy для генерації мока, тим більше що це завжди підтримувалося Mockito. Тепер це також можна зробити за допомогою нового API:

              OrderRepository orderRepository = mock(OrderRepository.class, withSettings().mockMaker(MockMakers.PROXY)); 

Spy

Припустимо, що в нашому інтерфейсі є дефолтний метод:

public interface OrderRepository {
      
       default List<Order> findByIds(List<Integer> ids) {
              return ids.stream().map(this::findById).toList();   
       }
      
       Order findById(int id);

Що буде з цим методом, якщо ми створимо мок для OrderRepository:

       OrderRepository orderRepository = mock(OrderRepository.class);         
       when(orderRepository.findById(1)).thenReturn(new Order());       
             
       List<Order> orders = orderRepository.findByIds(List.of(1, 2));

Тестування покаже, що він, як і передбачалося, просто поверне порожній LinkedList. Якщо такий варіант вам підходить, то все гаразд. Але що, якщо ви хочете зберегти поведінку тих методів, які мають тіло? Тут є кілька варіантів розв’язання. Перший варіант підійде, якщо вам потрібно змінити лише один метод:

       OrderRepository orderRepository = mock(OrderRepository.class);
       when(orderRepository.findByIds(any())).thenCallRealMethod();

Якщо у вас багато дефолтних методів, такий підхід погано масштабується, тому простіше налаштувати це під час створення мока:

OrderRepository orderRepository = mock(OrderRepository.class, Answers.CALLS_REAL_METHODS);

Але є третій підхід, який підійде і для інтерфейсів, і для класів — spy. Коли ви на основі реального об’єкта створюєте spy, то можете використовувати наявний код, так і замінювати (stub) його. Для нашого випадку це буде виглядати так:

       OrderRepository orderRepository = spy(OrderRepository.class);

Коли ви використовуєте spy та реальні методи, то при спробі його мокувати об’єкт може перебувати у неконсистентному стані або не підтримувати поточні аргументи. Наприклад, ви хочете створити spy для StringBuilder, щоб під час виклику compareTo з аргументом null повертати один (а не викидати виключення):

       StringBuilder builder = spy(StringBuilder.class);
       when(builder.compareTo(null)).thenReturn(1);

Але оскільки builder не мок, а реальний об’єкт, то під час виконання другого рядка builder.compareTo(null) викине виключення. Тому тут доведеться використовувати іншу конструкцію doReturn-when:

              StringBuilder builder = spy(StringBuilder.class);
              doReturn(1).when(builder).compareTo(null);

Підтримка спеціальних типів

Поговоримо про ті типи, які з’явилися недавно, або які Mockito ще недавно не вмів замінювати. Java records з’явилися в Java 16:

       record Point (int x, int y) {          
       }

І зараз робота з ними не викликає труднощів:

       Point point = mock(Point.class);
       when(point.x()).thenReturn(10);

Аналогічно та з перерахуваннями. Якщо у вас є тип Season:

       enum Season {
              WINTER, SPRING, SUMMER, AUTUMN
       }

то ви можете перевизначити будь-який з його методів:

       Season season = mock(Season.class);
       when(season.name()).thenReturn("N/A");
             
       String name = season.name(); // N/A

Висновки

У цій частині ми розглянули базові можливості Mockito, які з’явилися, починаючи з версій 2.x. Зараз Mockito є найбільш використовуваною Java-бібліотекою для мокування, перш за все тому що вона підтримує одразу три режими роботи:

  • JDK проксі;
  • Sub-classing;
  • Inline byte-code modification.

Міграція з Cglib на ByteBuddy дозволила створювати моки практично для будь-яких типів даних (final classes, перерахування) та додатково забезпечила підтримку поточних версій Java. Ви можете використовувати цю бібліотеку як для створення моків, так і для створення spies. У наступній частині ми поговоримо про декларативний підхід, обмеження Mockito та best practices.

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Нагадаю, що для Котліна краще використовувати Mockk — mockk.io

легковажна

може «легковісна» ?

для написання юніт-тесту добре б її ізолювати

Чи правильно розумію, що це важливо тільки для випадку JDK проксі? Sub-classing та inline прожує відсутність інтерфейсу?

Вже вкотре стаття без форматування коду та правильної підсвітки, виправте будь ласка бо псує враження

Плюсую, просто неможливо читати. Але автору пофіг на чиюсь зручність

какой автор такая и статья или ресурс?

Підписатись на коментарі