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

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

Всім привіт. Я Сергій Моренець, розробник, викладач, тренер, спікер та технічний письменник, продовжую знайомство з такою Java-бібліотеки як Mockito. У першій частині цього циклу статей я розповів про її основні можливості. У другій частині сфокусуюсь на розширених можливостях, декларативному підході та best practices.

Статичні методи

Довгий час розробникам доводилося використовувати PowerMock, оскільки він умів замінювати статичні методи, на відміну Mockito. Потрібно було спеціально вказати, що ви хочете замінити клас анотацією @PrepareForTest:

@RunWith(PowerMockRunner.class)
@PrepareForTest({ Calculator.class } )
public class MathUtilsTest {

а потім викликати спеціальний метод mockStatic:

       PowerMockito.mockStatic(Calculator.class);

Але PowerMock давно вже не розвивається, більше того, він так і не зміг інтегруватися з Junit 5, що не додало йому шанувальників і популярності. Тому в Mockito 3.4 був доданий експериментальний API для мокування статичних методів, що дозволило відмовитися від PowerMock. Допустимо, у нас є такий код:

public interface OrderRepository {
      
       static int getDefaultAmount() {
              return 1;
       }

Якщо раніше можна було використовувати синтаксис виду when-then, то тепер з’явився новий клас MockedStatic та сфера видимості (scope):

try (MockedStatic<OrderRepository> repository = mockStatic(OrderRepository.class)) {
       repository.when(OrderRepository::getDefaultAmount).thenReturn(10);
       assertEquals(10, OrderRepository.getDefaultAmount());
       }
assertEquals(1, OrderRepository.getDefaultAmount());

Цей тест пройде, хоча ми в ньому двічі порівнюємо getDefaultAmount() з різними значеннями. Уся справа в тому, що створення MockStatic обернене в try-with-resources. І коли виконується блок finally цієї конструкції, він викликає метод close(), який очищує заміни для статичних методів. Такий підхід більш безпечний, якщо ви використовуєте статичні методи у всьому застосунку, і очищення мока гарантує, що мокування не впливає на інші тестові класи. Але якщо у вас цей метод викликається в різних тестах одного тестового класу, буде не дуже зручно щоразу створювати мок. Простіше вдатися до конструкції @BeforAll/@AfterAll:

       static MockedStatic<OrderRepository> repository;
      
       @BeforeAll
       static void setup() {
              repository = mockStatic(OrderRepository.class);
              repository.when(OrderRepository::getDefaultAmount).thenReturn(10);     
       }
      
       @AfterAll
       static void tearDown() {
              repository.close();
       }

Новий API та його реалізація мають свої обмеження. Поточний scope нерозривно пов’язаний з потоком через спеціальний об’єкт DetachedThreadLocal, тому якщо ви створите мок, а потім запустите деякий код в новому потоці, то новий мок не буде доступний. У цьому випадку немає якогось простого рішення додаткового мокування типів, пов’язаних з багатозадачністю (CompletableFuture, ExecutorService).

📢 Побачимося в Києві? Купуй квитки на велику конференцію DOU Day!

Конструктори

Моки конструкторів були однією з причин, через які розробникам доводилося використовувати PowerMock. У версії Mockito 3.5 нарешті з’явилася підтримка заміни конструкторів.

Отже, уявимо дефолтну реалізацію OrderRepository:

public class DefaultOrderRepository implements OrderRepository {
 
       @Override
       public List<Order> findAll() {

яка очевидно використовується в OrderService, таким чином ігноруючи принцип Inversion of Control (Ioc):

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

Ми не можемо змінити поле orderRepository або отримати доступ до нього, але ми б хотіли змінити поведінку створеного об’єкта DefaultOrderRepository. Для цього є такий API, як MockedConstruction:

try (MockedConstruction<DefaultOrderRepository> mocked = mockConstruction(DefaultOrderRepository.class)) {
       OrderService service = new OrderService();
       DefaultOrderRepository repository = mocked.constructed().get(0);
       when(repository.findAll()).thenReturn(List.of());
       List<Order> list = service.findAll();
}

Спочатку ми створюємо спеціальний мок, який перехоплюватиме створення об’єктів De-faultOrderRepository. Цей об’єкт створює всередині OrderService, тому, щоб отримати доступ до нього, використовуємо метод mocked.constructed(), який повертає всі створені моки-репозиторії. І потім застосовуємо знайомий механізм when-thenReturn, щоб поміняти реалізацію в findAll(). Тут, як й у випадку зі статичними методами, включається scope і після виходу з try-with-resource всі моки автоматично видаляються.

Декларативний підхід

У розглянутих вище прикладах ми використовували імперативний підхід, викликаючи метод Mockito.mock(). Якщо у вас багато тестів використовують той самий мок-об’єкт, немає сенсу його створювати в кожному тесті. Краще перенести його в окремий setup-метод або використовувати декларативний підхід, застосувавши розширення Mockito для Junit 5 (MockitoExtension):

@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
      
       @Mock
       OrderRepository orderRepository;

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

        @Test
       void findAll_success(@Mock OrderRepository orderRepository)  {

Водночас ініціалізація та створення основного об’єкта (OrderService) залишається зоною вашої відповідальності. Але якщо OrderService використовує лише моки у своєму конструкторі, можна додати його як поле в тестовому класі разом з анотацією @InjectMocks:

@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
      
       @Mock
       OrderRepository orderRepository;
      
       @InjectMocks
       OrderService orderService;

І тоді Mockito спробує передати всі моки вашого тесту до цього конструктора:

@RequiredArgsConstructor
public class OrderService {
      
       private final OrderRepository orderRepository;

Що якщо такого мок немає? Ось, наприклад, у нас є клас Server, який приймає список:

       @InjectMocks
       Server server;
 
       class Server {
             
              public Server(List<String> items) {
                    
              }

Тоді Mockito, без особливих проблем, просто передасть як аргумент null. На жаль, немає жодної можливості змінити такий підхід. Тому можна лише порекомендувати стару добру перевірку вхідних аргументів на кшталт fail-fast:

       public Server(List<String> items) {
              Objects.requireNonNull(items);         
       }

Ще одним неприємним сюрпризом може стати те, що пошук відповідних моків для in-jection ніяких не враховує generic types, тому якщо у вас буде таке поле, Mockito його і передасть у конструктор з непередбачуваними наслідками:

       @Mock
       List<Integer> items;

Чи можна створювати SPY декларативним способом? Так, можна:

       @Spy
       @InjectMocks
       OrderService orderService;

І потім перепрограмувати, якщо потрібно:

       when(orderService.findAll()).thenReturn(null);

У минулих розділах я розповів про мокування статичних методів. На щастя, і тут можливий декларативний підхід:

       @Mock
       MockedStatic<OrderRepository> repository;
 
       @BeforeEach
       void setup() {
              repository.when(OrderRepository::getDefaultAmount).thenReturn(10);
       }

Тут моки автоматично видаляються після запуску кожного тесту, що робить такий підхід більш безпечним. Щодо конструкторів, все трохи складніше. Ви можете використати для них декларативний підхід:

       @Mock
       MockedConstruction<DefaultOrderRepository> repository;

Але під час спроби замокувати методи репозиторію:

       OrderService service = new OrderService();
       DefaultOrderRepository repository2 = repository.constructed().get(0);
       when(repository2.findAll()).thenReturn(List.of());

Ви виявите, що repository2 — не мок, а звичайний об’єкт. Я створив тикет на цей приклад, зачекаємо на офіційну відповідь.

Best practices</h2

Якщо ви створили мок і перевизначили метод:

              OrderRepository repository = mock(OrderRepository.class);
             
              when(repository.findById(1)).thenReturn(new Order());

Але не викликали його у тесті, то за дефолтом отримаєте помилку:

org.mockito.exceptions.misusing.UnnecessaryStubbingException: 
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
  1. -> at demo.OrderServiceTest.findAll_success(OrderServiceTest.java:32)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
                at org.mockito.junit.jupiter.MockitoExtension.afterEach(MockitoExtension.java:197)

Така поведінка практикується у версії 2.x і вище, але може призвести до помилок під час міграції якихось legacy-проєктів. Як тимчасовий workaround можна змінити тип Strictness (суворості) на Lenient, за якого такі перевірки не проводяться. Водночас можна це зробити імперативним шляхом:

OrderRepository repository = mock(OrderRepository.class, withSettings().strictness(Strictness.LENIENT));

Декларативним шляхом:

       @Mock(strictness = Mock.Strictness.LENIENT)
       OrderRepository orderRepository;

Або лише на рівні всього тестового класу:

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
public class OrderServiceTest {

Однак такий підхід можна використовувати лише тимчасово, оскільки він говорить про те, що у вас або некоректно налаштовані моки, або неправильна логіка тесту. Якщо ж ви використовується значення Strictness.STRICT_STUBS (рекомендоване), Mockito перевірить, що перевизначений метод викликався (як мінімум один раз). Але якщо вам потрібно перевірити, що якийсь метод викликався/не викликався, навіть якщо він не перевизначався? Тоді рекомендується наприкінці тесту викликати метод Mockito.verify:

       Mockito.verify(orderRepository, times(1)).findById(1);

Тут перевіряється одразу два факти:

  1. Метод був викликаний певну кількість разів.
  2. Метод був викликаний з певними значеннями аргументів (якщо вони є).

У всіх наведених прикладах ми вказували тип мок від час його створення. Починаючи з версії 4.10 це є необов’язковим, і загалом тепер виглядає більш елегантно:

OrderRepository orderRepository = mock();

Щоправда тут потрібно чітко вказувати тип під час оголошення змінної, і ключове слово var застосувати не вдасться:

              var orderRepository = mock();

Висновки

Отже, як ви переконалися, Mockito 5 має ті можливості, яких не вистачало в ранніх версіях, через що доводилося використовувати PowerMock. Зараз же мокування і статичних методів, і конструкторів можливе без її участі.

Крім того, Mockito пропонує два підходи для роботи з нею: імперативний та декларативний. Імперативний підходить у разі одноразового використання мока, а другий підхід більш доречний у разі, коли деякий об’єкт використовується відразу кількома тестами.

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному1
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

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