Мок — не чудодійний засіб, а необхідне зло. Переосмислюємо Unit-тестування
Мене звати Дмитро, я — джава тех-лід. Комерційний досвід програмування — 14 років, некомерційний — більшість мого життя. Часто проводжу співбесіди та роблю код-рев’ю і звернув увагу на однобокий підхід до використання моків в тестах. Вважається очевидним, що моки — це невід’ємна частина Unit-тестів. Цією статтею я хочу врівноважити цей погляд.
Одного разу я прийшов на співбесіду й отримав задачку на виведення певної послідовності чисел на екран. Успішно її вирішив. І тут мені ставлять питання — а як це покрити Unit-тестами? Оскільки вивід на екран іде через функцію System.out.println()
(Java — вона така багатослівна), то інтерв’юер вирішив перевірити моє вміння мокати статичні методи. Він не знав про мою нелюбов до моків.
Що я зробив? Поки триматиму інтригу. Але що б зробили ви?
Я часто бачу, як пишуть Unit-тести та перевантажують їх моками. Мок на те, мок на се, мок на внутрішню компоненту, мок на статичний метод, мок на об’єкт даних (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
. Чисті функції ідеально Unit-тестуються і, зверніть увагу, моки для них не обов’язкові.
Весь код не може бути написаний лише на чистих функціях, адже вся суть наших систем — це змінювати зовнішній стан, щось писати в базу або вичитувати з неї. Але треба прагнути винести все, що можливо, у чисті функції. А зони, де функції не чисті, умовно позначити червоним кольором.
Надам приклад. У вас є компонент, назвімо його OrderStatisticsNotificator
, в котрого є метод, котрий вичитує щось з бази, трансформує дані з неї в якийсь прийнятний формат і шле повідомлення в якусь чергу. Зазвичай люди мокають виклики бази й черги та тестують саму трансформацію. Але в цьому випадку нам треба винести трансформацію в окремий компонент. Наприклад, OrdersToStatisticsTransformer
, який приймає на вхід дані з бази, і вертає повідомлення, що пошлеться в чергу.
Ми винесли відповідальність за трансформацію в окремий компонент, що є правилом хорошого тону, і тепер нам дуже просто його тестувати. Замість усіх Mockito.mock
ми просто передаємо різні параметри у функцію і перевіряємо, що вона нам вертає. Тести стають коротшими, їх легше писати, читати. Відповідно їх може бути більше, а код буде краще покритий.
Якщо вже ми дуже хочемо протестувати, що виклик бази й виклик черги відбуваються у самому OrderStatisticsNotificator
, то можемо написати Unit-тест. Але його вистачить одного, тому що саму трансформацію ми вже потестували. Ну або писати додаткові тести, щоб перевірити обробку неочікуваних ситуацій. Для деяких випадків варто замислитися над інтеграційними тестами.
Також функціональщина — це про те, що функцію можна як передати в метод, так і повернути. Тут головне не перемудрити. Але якщо ви раптом можете винести виклик зовнішньої системи у лямбду, то тепер ви можете передати в метод не ту лямбду, котра таки викликає зовнішню систему. Наприклад:
(id) -> db.fetchUser(id),
А просто:
(id) -> return new User(id, "Jim", 25).
Тестувати тепер можна без мокання і без зайвих компонентів.
З цим підходом треба бути обережним, щоб не переборщити. Але мати такий підхід у власному інструментарії варто. Він особливо має сенс, коли компонент, що ми його тестуємо, має залежність на іншому компоненті, але використовує лише одну його функцію. Якщо винесемо цю функцію у лямбду, зможемо забрати зайву залежність.
Добре написаний код легше тестується. І навпаки — якщо вам складно протестувати свій код, можливо, варто покращити якість.
Моки та внутрішні компоненти
Ви пишете Unit-тест для певного компонента, наприклад UserStatisticsService
, в якого є залежність на інший внутрішній компонент, хай буде UserHelper. Зазвичай люди мокають UserHelper, бо, а як же інакше. Насправді це зовсім не обов’язково. Особливо, якщо UserHelper — це самодостатній клас, котрий не є залежним від зовнішніх систем. Якщо він, до прикладу, просто допомагає погрупувати користувачів за роком народження, то є сенс лишити його в UserStatisticsService тесті як є. Чому так, якщо це суперечить правилу, що Unit-тест має тестувати лише один юніт? Поясню від зворотного.
Уявимо собі, що UserStatisticsService
колись робив групування користувачів за роком народження сам. Був тест, написаний для UserStatisticsService
, і все було гаразд. І тут програміст вирішив, що треба винести групування користувачів в окремий сервіс. Наприклад, тому, що така сама логіка використовується в інших місцях. Ну або тому, що були не прораховані окремі випадки в оригінальному коді. Відповідно програміст виносить цей код в UserHelper (це, до речі, невдала назва, але ми зараз не про найменування говоримо) і покриває його детальними тестами.
Що відбувається з попереднім тестом UserStatisticsService
? Виходить, що його повністю треба переписувати, тому що тепер треба мокати додаткову залежність. Є декілька сценаріїв — програміст взагалі відмовляється від рефакторінгу, тому що не хоче переписувати весь цей чудовий і детальний тест, і просто копіпастить цей код в інше місце. Або він таки сідає за переписування тесту. Оскільки тест переписався разом із рефакторінгом самого коду, ми не можемо відловити помилку рефакторінгу. Unit-тест, котрий мав би допомагати при рефакторінгу, лише заважає йому. Програміст починає ненавидіти рефакторінг та Unit-тести та припиняє робити перше, а друге виконує «на відчепись».
Але можна піти іншим шляхом. В тесті — там, де ініціалізується UserStatisticsService
, наприклад, ось так:
@Before void initialize() { userStatisticsService = new UserStatisticsService(); }
Натомість зробити ось так:
@Before void initialize() { userStatisticsService = new UserStatisticsService(new UserHelper()); }
От прямо так, без моків.
Що ми маємо на виході: Unit-тести взагалі не треба переписувати. Це не лише береже дорогоцінний (і просто дорогий) час, а ще й допомагає відловлювати помилки рефакторінгу. Якщо ми навпаки вирішимо інлайнути функціонал 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 в іншому форматі. Зрозуміло, що Unit-тести для нього будуть переписані, тому що це не рефакторінг, а зміна поведінки.
Але що станеться з тестами, що від нього залежать? Якщо тести не мокали UserService
, а використовували його як є, то тести впадуть і тепер треба змінювати UserStatisticsService
, щоб він відпрацьовував правильно. Але якщо тести використовували моки, то нічого нам не вкаже, що відбувається щось не те. Тести на UserStatisticsService
будуть зелененькими, тому що моки працюють так, щоб тести не впали, хоча реальний компонент поводить себе інакше. Помилка, можливо, виявиться лише на етапі інтеграційних тестів, під час тестування руками, а то й в продакшні.
В мене була така ситуація, коли всі тести були зелені, покриття було майже стовідсоткове, але система не працювала. Тому що моки себе поводили не так, як справжні компоненти. Тоді я вперше побачив, як це, коли Unit-тести тестують моки замість бізнес-логіки.
Імплементація інтерфейсів замість моків
Часто наш код залежить не від імплементації, а від інтерфейсу. І це можна використати на власну користь.
Уявимо собі, що в багатьох місцях є залежність від UserDao
, який нам просто дозволяє зробити CRUD-операції над базою. І тепер ми мокаємо цей UserDao
у багатьох тестах та робимо купу Mockito.when(userDao.findUserById(1)).thenReturn(new User(1))
та інших складніших речей, типу .thenAnswer(id -> id == 1 ? new User(1) : null)
. І схожий код ми пишемо у всіх тестах.
Як варіант є сенс просто зробити власну, тестову імплементацію UserDao
. Наприклад, UserDaoArrayListImpl
. І використовувати її. В чому перевага такого підходу?
- Тести будуть значно коротшими й зрозумілішими.
- Нема повторення коду (дублікації), відповідно менша ймовірність десь помилитися.
- Якщо ми припустилися помилки в тестовій імплементації, треба буде її полагодити лише в одному місці.
- На таку імплементацію теж можна написати Unit-тести.
Писати дуже складні моки — схоже на переписування імплементації замоканого класу. При чому ми це робимо у всіх тестах, де такий компонент використовується. Написати власну імплементацію в таких випадках — це нічим не гірший підхід, а в багатьох випадках кращий.
У власній імплементації ми можемо симулювати connection timeout або інші виняткові ситуації звичайним прапорцем. Або так значно простіше перевіряти повідомлення, які були закинуті у кафку, а також їх кількість. Я зараз не кажу, що це — завжди краще, ніж моки. Але якщо ми пишемо однаковий мок в багатьох місцях, мокаємо занадто багато методів, а особливо ланцюжок викликів — варто задуматися над тестовою імплементацією. Заодно побачите, чи ваш інтерфейс не має забагато методів і чи не є він, бува, 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 — це саме той випадок, коли Unit-тести не підходять.
Окремо про статичні методи
Не треба мокати статичні методи. За весь час моєї роботи я жодного разу не бачив потребу в цьому. Мокання статичних методів — це щось виняткове. Не сприймайте це як норму.
Зазвичай статичний метод є чистою функцією — навіщо його тоді мокати? Виклик цієї функції — лише деталь імплементації. Ми ж не мокаємо Math.min!
Мокання статики може бути показом того, що ми ганяємося за метриками. В мене був приклад, коли для алгоритму хешування треба було зробити MessageDigest.getInstance("SHA-256")
а оскільки це Java, треба було відловити 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-імплементацію інтерфейсу, про що я писав вище.
Висновок
Треба сприймати моки як додатковий, але далеко не основний інструмент для тестування. Варто уникати мокання статичних методів. Класи, які можна просто інстанціювати, мокати не обов’язково. Позбавлятися від моків можна, пишучи якісний код. Одним з можливих підходів є використання чистих функцій.
Мокання зовнішніх компонентів має зміст, а от перед моканням внутрішніх треба задуматися. Чи не є вони частиною юніту? Чи не варто написати тестову імплементацію? Не мокайте низькорівневі компоненти — це рідко щось дає. Пишіть Unit-тести так, щоб тестувати бізнес-логіку, а не вміння використовувати Mockito.
І пару слів про релігійне Unit-тестування. Багато з того, що я написав, звучить як єресь і я це усвідомлюю. Але для мене основне — це не сліпе слідування жорстким правилам Unit-тестування, а добре відтестований код. І якщо моки цьому сприяють, тоді я не маю нічого проти них. Але часто вони засмічують тести, роблять їх складними для читання і не допомагають відловити помилки рефакторінгу.
Отже, уникати моків — це хороша стратегія.
54 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів