Dmytro Polovynka

Мок — не чудодійний засіб, а необхідне зло. Переосмислюємо Unit-тестування

Мене звати Дмитро, я - джава тех-лід. Комерційний досвід програмування - 14 років, некомерційний - більшість мого життя. Часто проводжу співбесіди та роблю код-рев’ю і звернув увагу на однобокий підхід до використання моків в тестах. Вважається очевидним, що моки - це невід’ємна складова юніт-тестів. Цією статтею я хочу врівноважити цю точку зору.

Раз я прийшов на співбесіду і мені задали задачку на виведення певної послідовності чисел на екран. Я її успішно вирішив. І тут мені задають питання - а як це покрити юніт-тестами? Оскільки вивід на екран іде через функцію System.out.println() (джава вона така багатослівна), то інтерв’ювер вирішив перевірити моє вміння мокати статичні методи. Він не знав мою нелюбов до моків.

Що я зробив? Я поки триматиму інтригу. Але що б зробили ви?

Я часто бачу, як пишуть юніт-тести і перевантажують їх моками. Мок на те, мок на се, мок на внутрішню компоненту, мок на статичний метод, мок на об’єкт даних (DTO) з наперед визначенеми Mockito.when(user.getName()).thenReturn(“Dmytro”); Але що реально тестують ці тести? Вони безумовно піднімають покриття коду (код-каверадж), але це - просто ганяння за метриками.

Якщо в мене всередині сервісу є ще чотири сервіси, я замокав усі чотири, а в самому тесті я перевіряю, що метод вертає те, що вертає четвертий мок в порядку - то я успішно перевірив моє знання Mockito, але аж ніяк не бізнес-логіку.

Якщо я помилково замокаю статичний метод Math.min(1,2), щоб він вертав 2, то чи не буде мій тест безглуздим? Кожен мок - це зайвий код і, відповідно, зайві баги.

Якщо я перевіряю, що мок всередині мого сервісу викликається з певними параметрами, то чи не перевіряю я деталі імплементації і чи не є це аналогічним з тестуванням приватних методів?

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

Ну добре, скажете ви, але ж яка розумная цьому альтернатива?

В першу чергу треба змінити ставлення до моків. Мок - це не чудодійний засіб для тестування. Чудодійний засіб для тестування - це JUnit, Selenium або testcontainers. А от мок - це необхідне зло. Іноді без них не обійтися. Але якщо можна без них обійтися, то краще обходитися. Уникайте моків. Позбавляйтеся них, якщо це можливо. Мок - це як early return. Це як метод, котрий приймає boolean. Це майже як goto. До них можна звертатися в разі необхідності. Але при можливості треба їх уникати.

З очевидних порад: не мокайте об’єкти даних (DTO) та не мокайте статичні методи.

Що ж я зробив з моканням System.out.println()? Звісно, я його не мокав. Натомість я виніс функціонал, котрий вертав числа, що їх треба було друкувати на екран в окремий компонент і тестував вже його. Тести замість перевантаження моками мали приблизно такий вигляд:

assertThat(new FibonacciGenerator().generateSequence(5)).isEqualTo([1, 1, 2, 3, 5]);

або

assertThat(fibonacciGenerator.next()).isEqualTo(8);

Інтерв’юверу я потім зізнався, що я розумів, що він хоче почути від мене слово “мок”, але що я їх не люблю. Інтерв’ювер був нормальним, тому він зацінив цю відповідь.

Те, що я зробив - це виніс бізнес логіку в чисту функцію, яку легко протестувати. Що приводить нас до наступного кроку.

Позбавляйтеся моків хорошим кодом

Якщо у тесті забагато всього треба мокати, то є ймовірність, що не дуже якісним є сам код. Але як покращувати код? Цьому присвячені цілі книги, тому моя відповідь не може бути вичерпною. Можна багато писати про слідування принципам SOLID, або Clean Code. І це - правильно. Але я зосереджуся на наступному - зверніться в сторону функціонального програмування. І це не обов’язково має бути чисте ФП з моноїдами та функторами. Не треба переписувати весь код на реактивщину.

Найважливіше у функціональному програмування - це чисті функції. Чиста функція - це та, котра приймає параметри і віддає результат, але при цьому не змінює стан системи і не залежить від нього. Іншими словами - чиста функція завжди вертає ту саму відповідь для тих самих параметрів, не зважаючи ні на що. Наприклад якщо метод викликає зовнішні апішки, записує, або зчитує щось з бази, або щось кладе в загальний кеш, то чистим цей виклик не є. Чисті функції ніколи не мають сигнатуру зі словом void. Чисті функції ідеально юніт-тестуються і, зверніть увагу, моки для них не обов’язкові.

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

Дам приклад - у вас є компонент, назвімо його OrderStatisticsNotificator, в котрого є метод, котрий вичитує щось з бази, трансформує дані з бази в якийсь прийнятний формат і шле повідомлення в якусь чергу. Зазвичай люди мокають виклики бази і черги і тестують саму трансформацію. Але в цьому випадку нам треба винести трансформацію в окремий компонент, наприклад OrdersToStatisticsTransformer, який приймає на вхід дані з бази, і вертає повідомлення, що пошлеться в чергу. Ми винесли відповідальність за трансформацію в окремий компонент, що є правилом хорошого тону, і тепер нам дуже просто його тестувати. Замість усіх Mockito.mock ми просто передаємо різні параметри у функцію і перевіряємо, що вона нам вертає. Тести стають коротшими, їх легше писати, читати, відповідно їх може бути більше, код краще покритий.

Якщо вже ми дуже хочемо протестувати, що виклик бази і виклик черги відбуваються у самому OrderStatisticsNotificator, то можемо написати юніт-тест, але його вистачить одного, тому що саму трансформацію ми вже потестували. Ну або писати додаткові тести, щоб перевірити обробку неочікуваних ситуацій. Для деяких з випадків варто замислитися над інтеграційними тестами.

Також функціональщина - це про те, що функцію можна як передати в метод, так і повернути її. Тут головне не перемудрити, але якщо ви раптом можете винести виклик зовнішньої системи у лямбду, то тепер ви можете передати в метод не ту лямбду, котра таки викликає зовнішню систему, наприклад

(id) -> db.fetchUser(id), 

а просто

(id) -> return new User(id, "Jim", 25).

Тестувати тепер можна без мокання і без зайвих компонентів.

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

Добре написаний код легше тестується. І навпаки - якщо вам складно протестувати свій код, можливо варто покращити якість.

Моки і внутрішні компоненти

Ви пишете юніт-тест для певного компонента, наприклад UserStatisticsService в якого є залежність на інший внутрішній компонент, хай буде UserHelper. Зазвичай люди мокають UserHelper, бо - а як же інакше. Насправді це зовсім не обов’язково. Особливо, якщо UserHelper - це самодостатній клас, котрий не є залежним від зовнішніх систем. Якщо він, до прикладу просто допомагає погрупувати користувачів по року народження, то є зміст лишити його в UserStatisticsService тесті як є. Чому так, якщо це суперечить правилу, що юніт-тест має тестувати лише один юніт? Поясню від зворотного.

Уявимо собі, що UserStatisticsService колись робив групування користувачів по року народження сам. Був тест написаний для UserStatisticsService і все було гаразд. І тут програміст вирішив, що треба винести групування користувачів в окремий сервіс. Наприклад тому що така сама логіка використовується в інших місцях. Ну або тому що були не прораховані окремі випадки в оригінальному коді. Відповідно програміст виносить цей код у UserHelper (це, до речі, невдала назва, але ми зараз не про найменування говоримо) і покриває його детальними тестами.

Що відбувається з попереднім тестом UserStatisticsService? Виходить, що його повністю треба переписувати, тому що тепер треба мокати додаткову залежність. Тепер є декілька сценаріїв - програміст взагалі відмовляється від рефакторінгу, тому що не хоче переписувати весь цей чудовий і детальний тест, і просто копі-пастить цей код в інше місце. Або програміст таки сідає за переписування тесту. Оскільки тест переписався разом із рефакторінгом самого коду ми не можемо відловити помилку рефакторінгу. Юніт тест, котрий мав би допомагати при рефакторінгу, рефакторінгу лише заважає. Програміст починає ненавидіти рефакторінг та юніт-тести і припиняє робити перше, а друге робить “на відчепись”.

Але можна було піти іншим шляхом. В тесті, там де ініціалізується UserStatisticsService, наприклад ось так:

@Before
void initialize() {
    userStatisticsService = new UserStatisticsService();
}

натомість зробити ось так:

@Before
void initialize() {
    userStatisticsService = new UserStatisticsService(new UserHelper());
}

От прямо так, без моків.

Що ми маємо на виході - юніт тести взагалі не треба переписувати. Це мало того, що береже дорогоцінний (і просто дорогий) час, але ще й допомагає відловлювати помилки рефакторінгу. Мало того, якщо ми навпаки - вирішимо інлайнути функціонал UserHelper-а назад, то знову треба буде лише змінити ініціалізацію класу, а тести лишити як є.

Що ж робити, якщо ми говоримо про компонент, який таки має залежність від зовнішнього світу? Що якщо це не UserHelper, а UserService, котрий таки залежить від якоїсь UserDao що безпосередньо звертається в базу? Якщо ми підемо від зворотного, то ми побачимо, що знову ж таки є зміст не мокати UserService.

Уявимо собі, що перед рефакторінгом ініціалізація класу мала такий вигляд:

    userStatisticsService = new UserStatisticsService(userDaoMock);

то тепер вона може мати такий вигляд:

    userStatisticsService = new UserStatisticsService(new UserService(userDaoMock));

З одного боку нам не треба переписувати тест, що чудово. З другого боку, якщо довести цю ідею до абсурду, ми, тестуючи контролери, по суті будемо ініціалізувати цілу систему, наприклад

new UserController(new UserService(userDaoMock, new ResourcesService(equipmentDaoMock, roomsDaoMock, desksDaoMock), ....))

Тобто з цим підходом треба бути обережним. Сліпо користуватися цим методом я не раджу. Але що можна робити - це ширше трактувати поняття “unit”. Воно ж не дарма називається саме unit test, а не class test. Якщо UserHelper, або UserService може трактуватися, як частина юніту UserStatisticsService, то можна використати підхід ініціалізації компонентів замість моків. Якщо ж внутрішній компонент занадто складний, наприклад це - імплементація патерну фасад, тоді краще мокати його.

З відмовою від моків ми отримаємо ще одну перевагу. Уявимо собі, що UserService змінив свою поведінку і свідомо поламав контракт - наприклад почав кидати UserNotFoundException замість того, щоб вертати null. Або почав вертати строку чи json в іншому форматі. Зрозуміло що юніт-тести для нього будуть переписані, тому що це - не рефакторінг а зміна поведінки. Але що станеться з тестами що від нього залежать? Якщо тести не мокали UserService, а використовували його як є, то тести впадуть і тепер треба змінювати UserStatisticsService щоб він відпрацьовував правильно. Але якщо тести використовували моки, то нічого нам не вкаже на те, що відбувається щось не те. Тести на UserStatisticsService будуть зелененькими, тому що моки працюють так, щоб тести не впали, хоча реальний компонент поводить себе інакше. Помилка, можливо, виявиться лише на етапі інтеграційних тестів, під час тестування руками, а то й в продакшні. В мене була така ситуація, коли всі тести були зелені, покриття було майже стовідсоткове, але система не працювала, тому що моки себе поводили не так, як справжні компоненти. Тоді я вперше побачив, що означає коли юніт-тести тестують моки замість того, щоб тестувати бізнес-логіку.

Імплементація інтерфейсів замість моків

Часто наш код залежить не від імплементації, а від інтерфейсу. І це можна використати у власну користь.

Уявимо собі, що в багатьох місцях є залежність від UserDao - який нам просто дозволяє зробити CRUD операції над базою. І тепер ми мокаємо цей UserDao у багатьох тестах та робимо купу Mockito.when(userDao.findUserById(1)).thenReturn(new User(1)) та інших складніших речей, типу .thenAnswer(id -> id == 1 ? new User(1) : null). І схожий код ми пишемо у всіх тестах.

Як варіант є зміст просто зробити власну, тестову імплементацію UserDao, наприклад UserDaoArrayListImpl і використовувати її. В чому перевага такого підходу?

Писати дуже складні моки схоже на переписування імплементації замоканого класу. При чому ми це робимо у всіх тестах, де такий компонент використовується. Написати власну імплементацію в таких випадках - це нічим не гірший підхід, а в багатьох випадках кращий. У власній імплементації ми в тому числі можемо симулювати конекшн таймаут, або інші виняткові ситуації звичайним прапорцем. Або так значно простіше перевіряти повідомлення, які були закинуті у кафку, а також їх кількість. Я зараз не кажу, що це - завжди краще ніж моки. Але якщо однаковий мок ми пишемо в багатьох місцях, якщо ми мокаємо занадто багато методів, а особливо ланцюжок викликів, то задумайтеся над тестовою імплементацією. Заодно побачите чи ваш інтерфейс не має забагато методів і чи не є він, бува, God-interface.

Я успішно використовував цей підхід саме в ситуаціях з імплементацією DAO, маючи під капотом звичний ArrayList, або HashMap. Але як, власне тестувати самі DAO?

Моки і база

Як проюніт-тестити базу? Якщо коротко - то ніяк. Усі спроби приречені на провал. Іноді я бачу щось типу цього:

dataSource = mock(DataSource.class)
dbConnection = mock(DatabaseConnection.class)
when(dataSource.getConnection()).thenReturn(dbConnection)
expect(dbConnection.startTransaction())

Не робіть цього. Не треба мокати датасорс, конекшни і транзакції. Цей тест - ні про що. Взагалі мокати такі низькорівневі інтерфейси - це антипатерн. Я ще ні разу не бачив, щоб ці тести щось давали.

Найкраще для бази писати інтеграційні тести. Під інтеграційними тестами я маю на увазі такий тест, де ми інстанціюємо DAO і вона під капотом викликає живу базу. Тобто не треба піднімати всю апку. Ці тести можна писати відносно testcontainers, або якоїсь тестової бази. І ні - не використовуйте для цього ін-меморі базу даних, якщо ви не використовуєте цю ж базу в проді. Писати тести під HSQLDB, якщо в проді у вас стоїть MSSQL - погана звичка. Причин декілька, дві з них - ви не можете використовувати MSSQL-специфічні методи - це раз. Два - бази можуть по різному працювати з різними типами даних, наприклад я колись напоровся на case-insensitive колонки, де думав, що вони будуть case-sensitive, або на дуже особливу роботу MSSQL з колонками типу IDENTIFIER, байти якого вона зберігає в неприродній послідовності. Але це я відволікся.

Загалом тестування DAO це саме той випадок, коли юніт-тести не підходять.

Окремо про статичні методи

Не треба мокати статичні методи. За весь час моєї роботи я жодного разу не бачив потребу в моканні статичних методів. Мокання статичних методів - це щось виняткове. Не сприймайте це як норму.

Зазвичай статичний метод є чистою функцією - навіщо його тоді мокати? Виклик цієї функції - це лише деталь імплементації. Ми ж не мокаємо Math.min!

Мокання статики може бути показом того, що ми ганяємося за метриками. В мене був приклад, коли для алгоритму хешування треба було зробити MessageDigest.getInstance("SHA-256") а оскільки це - джава, то треба було відловити checked exception. Код-каверадж видавав занизьке покриття, тому що catch clause не був покритий тестами. Це при тому, що цей ексепшн ніколи не впаде. Тут є два варіанти - заігнорити цей компонент для перевірки покриття (а це було єдине, що він робив). Або статично замокати цей метод і викидати той ексепшн, щоб ощасливити код-каверадж. Я обрав перший спосіб, як менше зло.

Але є випадки, коли дійсно хочеться замокати статичний метод. Очевидним прикладом є компонент, котрий видає різні вітання залежно від пори дня. Всередині викликається Instant.now() і залежно від часу видається вітання “Добрий день”, або “Добрий вечір”. Чи можна обійтися без цього? Ну, по-перше, можна передавати час в компонент параметром, зробивши з нього чисту функцію. Другий варіант - створити інтерфейс TimeProvider, котрий буде вертати теперішній час і тоді створити власну імплементацію компонента, котра буде вертати той час, котрий нам треба і користуватися вже ним, наприклад так:

new GreetingFactory(new StaticTimeProviderImpl("2024-05-06 21:30:00"))

З кожного правила можуть бути винятки, так і тут. Але все-таки уникайте мокання статичних методів.

Хороші моки

Іноді використання моків має зміст. Це - хороші моки. Хороший мок - це той, який тесту не заважає, а гарно вплітається в код, як хороша рима у вірші. Якщо мок стає каменем спотикання - то цей мок поганий.

Очевидно, що треба мокати зовнішні залежності. І на це є як мінімум дві причини:

Якби не ці два нюанси, то мокати зовнішні залежності було б непотрібно. Але ми живемо в справжньому світі, тому мокати зовнішні залежності - необхідно.

Є ще інші випадки, коли мок підходить. На деякі з них я натякав вище. Наприклад мокати фасад, котрий під капотом викликає багато чого. Також моки чудовий інструмент, щоб перевірити, як відпрацьовує мій компонент, якщо замоканий сервіс викине помилку. Моки в таких тестах виглядають доволі натурально і на їх налаштування витрачається лише один рядок. Щось типу Mockito.when(userDao.findUser(1)).thenThrow(new UserNotFoundException()); Хоча насправді навіть тут не завжди ясно, чи треба використовувати власне мок, чи варто написати власну dummy-імплементацію інтерфейсу, про що я писав вище.

Висновок

Треба сприймати моки, як додатковий, але далеко не основний інструмент для тестування. Треба уникати мокання статичних методів. Класи які можна просто інстанціювати, мокати не обов’язково. Позбавлятися від моків можна пишучи якісний код, одним з підходів є використання чистих функцій. Мокання зовнішніх компонентів має зміст, а от перед моканням внутрішніх - треба задуматися. Чи не є вони частиною юніту? Чи не варто написати тестову імплементацію? Не мокайте низькорівневі компоненти- це рідко щось дає. Пишіть юніт-тести так, щоб тестувати бізнес-логіку, а не вміння використовувати Mockito.

І пару слів про релігійне юніт-тестування. Багато з того, що я написав, звучить як єресь і я це усвідомлюю. Але для мене основне - це не сліпе слідування жорстким правилам юніт-тестування, а про добре відтестований код. І якщо моки цьому сприяють - тоді я не маю нічого проти них. Але часто моки засмічують тести, роблять їх складними для читання і не допомагають відловити помилки рефакторінгу. Уникати моків - це хороша стратегія.


Ця ж стаття на ДОУ