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

💡 Усі статті, обговорення, новини про тестування — в одному місці. Приєднуйтесь до QA спільноти!

Мене звати Дмитро, я — джава тех-лід. Комерційний досвід програмування — 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-тестування, а добре відтестований код. І якщо моки цьому сприяють, тоді я не маю нічого проти них. Але часто вони засмічують тести, роблять їх складними для читання і не допомагають відловити помилки рефакторінгу.

Отже, уникати моків — це хороша стратегія.

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

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

Але зрештою код з бізнес логікою і код який робить сайд ефекти має бути розділений .

хмм, а може інтервʼювер очікував, що ви заміните System.out своєю власною імплементацією ? System.setOut(customPrintStream) ?

Це цікава ідея. Але ні — інтерв’ювер хотів почути про «мок» про що сам мені зізнався. Я не думаю, що він би від мене це приховував.

Прочитав цей топік.
У автора дуже багато думок, які слід було впорядкувати та систематизувати, щоб текст був більш читабельним.
А так це виходить не стаття, а дуже довга і багато в чому плутана скарга на моки та тих, хто їх використовує.
У моках немає нічого поганого, це інструмент, який дозволяє легше писати простіші юніт-тести.
Якщо хтось неправильно користується моками, це проблема програміста, а не інструмента.
Ви ось у тексті скаржитесь, що люди пишуть (на Java) поганий код, але чому ви тоді Java не лаєте за те, що вона дозволяє писати такий поганий код?
Тому я раджу переосмислити ваш досвід роботи з моками, тим більше, що позиція

я — джава тех-лід

до чогось зобов’язує.

Код-каверадж

Є хороше словосполучення «покриття коду» або «покриття коду тестами».
Навіщо так перекручуватися та спотворювати українську мову?

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

Теми: Java, QA, tech, unit testing

А хто проставляв теги для цього топіку?

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

Пишіть Unit-тести так, щоб тестувати бізнес-логіку, а не вміння використовувати Mockito.

Ну й хто в продакшені так робить й чим керується? «Я вмію користуватись Mockito. Дай-но напишу юніт-тести щоб покрасуватись!»
Правильна порада звучить так:

Пишіть Unit-тести так, щоб тестувати бізнес-логіку, а не задля code coverage, «бо за спринт треба 25% нового коду покрити»
Отже, уникати моків — це хороша стратегія.

І як, виходить це робити пишучи сервіси на спрінгу?

А чим спрінг завинив? Якщо ви конкретно про Dependency Injection — то він дуже спрощує роботу з ініціалізацією проекту. Тобто це просто інструмент, щоб руками об’єкти в конструктори передавати.
Якщо відповідати на питання — виносите бізнес логіку в окремі компоненти і тестуєте їх без моків.

Бізнес логіка в більшості випадків залежить від інших сервісів. Як тут без моків? Тобто всю бізнес логіку в утилітні класи і всі параметри передавати у метод передавати? Чи як?

Спробувати можна. Наприклад ви побачили якийсь дуже складний приватний метод, але аж раптом — він зовнішні залежності не викликає — це вже один кандидат. Або можна спробувати спочатку зробити виклики зовнішніх сервісів, а потім вже пробувати обробити всі результати разом. Можна взагалі зробити Lazy запит — тобто обгорнути його у функцію і викликати його лиш тоді, коли треба. В імперативному програмуванні воно виглядає неорганічно, але от наприклад в WebFlux це — звична річ — передати якийсь Mono у функцію. Відповідно цей Mono може і не викликатися, а протестувати метод можна без моків, бо замість справжнього моно можна передати Mono.just().
Я не кажу, що це завжди вийде, але якщо пробувати побачити такі місця, то їх виявляється більше, ніж спочатку очікувалося.

Тому і кажуть, що в ідеалі спочатку треба писати тести, а потім функції =)

Тому і кажуть, що в ідеалі спочатку треба писати тести, а потім функції =)

І як це змінює потрібність/непотрібність моків? Рано чи пізно ви дійдете до інтеграційних точок.
Бонус спрінга ще й в тому, що є спрінг-дата, яка не передбачає написання своєї реалізації інтерфейсів (не забороняє, але це буде код число для тестів)

як тіко тре шось мокати — то є явний маркер порушення SOLID

Пан колись писав тести на сервіси у які інжектяться інші сервіси?

та ну нє. Я ж рубіст. У нас всьо просто: ***к-хуяк і в прод.

Тоді що виходить? Такий сервіс без моків не потестуєш. То значить SOLID порушується в такому випадку?

як тіко тре шось мокати — то є явний маркер порушення SOLID

або це явний оверінженірінг

Якої саме букви порушення?

Бо враження що відсутнє розуміння що SOLID це набір різних правил, а не якесь одне універсальне правило.

Single responsibility — не порушено, навпаки наявність зовнішнього функціоналу свідчить що правило а конано.
Open/closed та Interface segregation — взагалі ніяким боком до тестування. Хіба що зовнішній сервіс прям хардкорно заявлений як final і тоді замотати не вийде.
Liskov substitution — не стосується моків бо ми там нічого не наслідуємо.
Залишається dependency inversion і тут я знов не бачу ніяких протиріч з моками.

Open/closed та Interface segregation — взагалі ніяким боком до тестування. Хіба що зовнішній сервіс прям хардкорно заявлений як final і тоді замотати не вийде.

До слова, перечитав свій коментар і зрозумів що якщо замокати нічого неможливо — це як раз і є порушення SOLID в частині букв O та I, але не для класа який ми тестуємо, а для його залежностей яки мо хочемо замокати.

А за помилки в тексті вибачаюсь: з телефона набирав і не відстежив автозаміни.

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

Все з точністю до навпаки: якщо треба перевірити два варінти «четвертий сервіс вертає коректну відповідь» та «четвертий сервіс кидає ексепш, вертає null (хоча не повинен) чи вертає щось незрозуміле» то саме мок дозволяє легко перевірити це в декількох тестах. При цьому жодних додаткових багів цей мок не несе, бо він навпаки вертає саме те що і має вертати за умовами кейсу.

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

Так це має перевіряти не тест твого сервісу, а тест четвертого сервісу.

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

Здорові думки у автора

Від себе: якщо тести не тестять сценарії використання сервісу, тоді це просто для кавереджу 😊

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

Та не обовʼязково проблема у тому, що код поганий. Можливо, код просто дуже простий. Завдяки сучасним інструментам і підходам у нас зазвичай купа методів, які існують тільки для підтримання структури кода. Це методи типу «валідувати параметри -> отримати данні з репозиторія -> замапити результат у ДТО». Навіщо на них юніт тести взагалі?

Якщо контролери-сервіси-даошки мають однорядочкові методи, котрі просто викликають одне одного і перетягують дані з бази на UI — тоді так, змісту з юніт-тестів небагато. Але щойно з’являється щось складніше — хоча б якась бізнес логіка (перевірка на консистентність переданих даних, якась особлива фільтрація чи сортування, перемаплення не 1-в-1 а щось поскладніше) — то вже можна юніт-тестувати. При чому по можливості треба переносити цю бізнес-логіку в окремі компоненти, котрі не мають зовнішніх залежностей і приклад цього поданий в статті (там де тестується OrderStatisticsNotificator).

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

Ну тоді і в моках зʼявляється сенс, чи не так?

Не обов’язково. Якщо винести бізнес-логіку в окремі компоненти, то їх можна тестувати без моків.
Тобто ви, звісно, можете написати пару тестів з моками. Але при цьому тестувати компоненту, котра не має зовнішніх залежностей значно простіше.

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

Пишіть Unit-тести

Не пишіть юніт тести.
Піднімайте в докері базу, sqs чи що у вас там є і пишіть інтеграційні тести. Тестуйте флоу вашего хендлера реквеста/меседжа.

Не пишіть юніт тести.
Піднімайте в докері базу, sqs чи що у вас там є і пишіть інтеграційні тести. Тестуйте флоу вашего хендлера реквеста/меседжа.

Інтернет — таке місце де не зрозуміло хто приколюється, а хто просто дов...б, тому запитаю:
Це ви серйозно не знаєте для чого застосовуються юніт тести, а для чого інтеграційні?

Це ви серйозно не знаєте для чого застосовуються юніт тести, а для чого інтеграційні?

Я тому і кажу, що вони не потрібні бо прекрасно знаю різницю

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

99% тутешніх пише Круд сервіси.
Ні бібліотеки, ні фреймворки, ні бази даних, а Круд сервіси. Навіть в цій статті всі приклади пов’язані з Users & Orders.
В Круд сервісах юніт тести не потрібні в 99% випадків.

Ну як завжди, www.joelonsoftware.com/2002/05/06/five-worlds

Ті хто сидить на «внутрішньому» софті — навіть якщо він в 2024 виглядає як перший бекенд веба, який далі є фронтендом для якогось мікросервіса — просто не думають, що є і інші світи. А ті, хто в інших світах і жодного разу не писав круда — як я — тим треба ще зрозуміти, що якесь ствердження зроблено кимось, хто не знає нічого крім внутрішнього.

просто не думають, що є і інші світи.

Тут трохи не згодний. Книжки про тестування описують універсальні рішення без привʼязки до якихось деталей проекту. Я не бачив книжок на кшталт «Автоматизація тестування CRUD» чи «Unit-тестування вбудованих рішень». Можливо є під авторством якогось Раджеша чи Суміта, але то не мейнстрім. Теорія тестування доволі універсальна.

Хоча ти знаєш чудово мою позицію з приводу тестування — я класничний антивакс антитестіст. Моя думка залишається незмінною до сих пір, що тести не покращують якість коду, що краще інвестувати в архітектуру та рішення, які мінімізують рівень помилок в проекті, максимізують рівень обізнаності про процеси, які відбуваються всередині, робити fail safe та fault tolerant системи замість монолітів та жорсткоструктурованих та жорсткоповʼязаних систем. Тести показують рівень недовіри до коду, рівень нерозуміння розробником проекту, що й треба вирішувати в першу чергу.

Ну як завжди, www.joelonsoftware.com/2002/05/06/five-worlds

Дякую. Я вже почав відчувати себе занадто старим, бо знаю про Фаулера, а виявляється, що є ще навіть ті хто пам’ятають Джоела :)

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

Практична складова:
Як ви будете змінювати поведінку вашого dummy в залежності від тесту? Тому тут мок — це просто зручність.

Далі ми підходимо, до пробілу в теорії (може його й немає, статтю не читав):
Ключова фіча моків — це можливість перевірки взаємодії/інтеракції з об’єктом.
В статті слово «verify» зустрічається 0 разів, тобто в статті немає прикладів використання моків за призначенням.

Варто уникати мокання статичних методів.

Варто уникати в принципі статичних методів.

Класи, які можна просто інстанціювати, мокати не обов’язково.

Не обов’язково. І що?
Просте правило: ми мокаємо залежності юніта, який ми тестуємо. Тут складність визначити границі того юніта і що є залежністю, а що деталлю реалізації. Власне знову ж це приходить з досвідом.

Тому перед тим як щось переосмислювати, варто почати з того, що осмислити його.

Ключова фіча моків — це можливість перевірки взаємодії/інтеракції з об’єктом.

Так, але, на жаль, часто моки використовують там, де їх використовувати не треба. Цитуючи вас же ж:

Просте правило: ми мокаємо залежності юніта, який ми тестуємо. Тут складність визначити границі того юніта і що є залежністю, а що деталлю реалізації.

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

Так, але, на жаль, часто моки використовують там, де їх використовувати не треба

Що значить «не треба»? Ви можете чітко показати, що є сценарій де це «не доцільно», тобто заважає (саме це зазвичай мають на увазі коли говорять «не треба»).
По тексту ви використовуєте цю фразу у значенні «не має однозначної потреби і вам це не подобається».

моки ДТОшок, в котрих мокають гетери

Це проблема ... вет фор іт ... початківців, вона не є системною серед досвідчених людей. Хоча бувають кейси коли воно доцільно, наприклад, коли від «ДТО» є лише інтерфейс, але то зазвичай не ДТО, а проксі (дуже специфічний сценарій)

навіть якщо їх можна просто інстанціювати, мокають статичні методи

Про статичні методи — мимо каси, як я писав проблема там в іншому.
Про «інстанціювати». У вас чудовий приклад де 1 залежність. А тепер уявіть, що там дерево залежностей на 5-10 класів.

І все через нерозуміння того, що мокати треба, а що ні.

Тут абсолютно згоден. Проблема в тому, що дана стаття (і ваш комент) не демонструють наявність того розуміння, тому ми підміняємо одне нерозуміння іншим.

А коли треба уникати early return? По моєму це чудовий паттерн... Якщо його треба використати то там або так або макарони з else.

Уникати тоді, коли цей ретурн неочевидний. Наприклад коли метод на 40 рядочків, а тут неждано-негадано ретурн на 23му.

40 рядків видно на екрані і якщо ви написали такий код що там той return не видно, то проблема не в ньому) Я розумію коли метод 200-300 рядків тоді дійсно можна загубити той return бо він не в межах видимості. Але якщо ви маєте такий метод, то можливо проблема знов не в return а в наявності такого великого методу...

метод на 200-300 рядків — це дуже потужно, але майже ніколи — необхідно

У порядку дискусії скажу, що цикломатична складність більш важлива, ніж проста кількість рядків.
Про «майже ніколи» — да, але це не має перетворитись в «ніколи», і не має бути самодостатнім принципом.

Ілюстрація про зелені тести при непрацюючій апці чудова, і ідеї вирішення досить цікаві, але доу не буде доу, якщо не додовбатися.
Автор гонить на ealry return і boolean методи, тоді як в сусідній статті ці речі подають, як панацею від мозколомних if-else конструкцій
dou.ua/forums/topic/51178
Крім того, описані підходи мають певні трейдоффи:
По-перше, якщо дуже зав’язатися на лямбди (особливо однострокові), то стає важче дебажити.
По ініціалізації через конструктор сам автор вказав можливе слабке місце, лайк. Крім того, в осписаній ситуації лінтер може сваритися на те, що actual value of parameter is always Instant.now().
Dummy імплементації, не дуже гнучкі. Якщо нам треба щоб метод повертав різні значення, то доведеться робити анонімний клас з оверрайдом, що аж ніяк не простіше, ніж засетапати мок. І як підмітив Oleh Baibula, dummy імплементація — це теж мок.
Ну і наостанок, з приводу співбесід: перевіряти вміння мокати статичні методи — це десь на рівні перерахунку всіх методів класу Object.

Все вірно. Є трейдофи, головне знати про різні варіанти.

В мене була така ситуація, коли всі тести були зелені, покриття було майже стовідсоткове, але система не працювала.

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

власну dummy-імплементацію інтерфейсу

Це і є мок, тільки без 3pp(Mockito).

власну dummy-імплементацію інтерфейсу
Це і є мок, тільки без 3pp(Mockito).

Мок — не даммі (і навіть не стаб) © Леонід Кучма

Читаємо класику (скоріше за все ТСу теж буде корисно):
martinfowler.com/bliki/TestDouble.html
martinfowler.com/...​cles/mocksArentStubs.html

Фаулер може писати що завгодно, але, наприклад, пітонівський unittest.mock універсально виконує і mock, і stub, і dummy, і фактично навіть ближче до stub (бо на невідомі виклики не дає винятків). Більш того, для більшости задач розрізняти як слова mock і stub просто не має практичного сенсу: програміст думатиме не в цих термінах, а в конкретних реакціях, що треба і що ні.

Фаулер може писати що завгодно, але, наприклад, пітонівський unittest.mock

Фаулер (а можливо навіть не він) спробував впорядкувати і класифікувати підходи. Те що є купа людей, які іпали в них розбиратись у відносно базових підходах — це їх проблема.
Іноді ця проблема буду проявлятись в скланості передати свою ідею або зрозуміти іншого. Як от просто в коменті на початку гілки.
А іноді в поганому коді. Скоріше за все ТС надивився на такий поганий код і ця стаття є криком душі, бо його колеги забили болт розбиратись з тестовими дублерами.

Фаулер (а можливо навіть не він) спробував впорядкувати і класифікувати підходи.

Це благородна ціль. Але занадта суворість у класифікації часто не іде на користь. У випадку розрізнення mock і stub, провести точну межу неможливо. Я саме про це.
Ну і його теоретичні побудування проти поширеної практики — це як кит проти слона — може, один іншого і заборе, але як їм взагалі вийти на один рінг...:)

Скоріше за все ТС надивився на такий поганий код і ця стаття є криком душі, бо його колеги забили болт розбиратись з тестовими дублерами.

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

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