Як і навіщо покривати тестами свій проєкт
Всім привіт! Мене звати Лесь Дібрівний. Вже понад чотири роки я працюю як Unity Developer. Працював на двох проєктах в КТ — EdTech (розвивальні застосунки для дітей від 0 до 8 років) і клон Minecraft — Realcraft, зараз розвиваю власну продуктову компаніію Fantasy Frontier Studios. Також вже більш як п’ять років викладаю Unity в університетах Києва, маю невеличкий YouTube-канал.
На Games Gathering Lviv ‘24 я читав лекцію про те, як і навіщо покривати свій проєкт тестами. На GameDev DOU публікую її у вигляді статті для вашої уваги та зручності.
Працюючи в компанії Keiki, я написав близько 600 тестів, а загалом їх на проекті було близько 1700, тобто доволі багато. Тож, мені є, що вам розповісти. Поїхали
Недоліки тестів
Розпочнімо з недоліків, щоб ми одразу розуміли, яким проєктам тести потрібні, а яким не дуже.
Тести дуже вимогливі до архітектури, а особливо Unit-тести, де ви перевіряєте свої методи. Все тому, що коли у вас є складна система, вам потрібно її запустити, прогнати якийсь метод, у системи може бути багато залежностей. Якщо ці залежності неправильно розподілені при архітектурі, якщо все базується на прямих взаємозв’язках, цю систему буде дуже важко симулювати й щось з нею робити. Наприклад, коли ми в команді прийшли до тестів, довелося пересаджувати частину класів на інтерфейси, тому що з ними в цьому плані працювати легше.
Тести часто забувають підтримувати. Вони, як родич, про якого ви згадуєте тільки на весілля чи інші свята. Вони існують, ви інколи до них заходите, і все, що в основному з ними робите — перевіряєте, чи вони не червоні. Тож часто при рефакторингу, створенні та додаванні нового функціоналу, ви забуваєте, що цей «родич» існує й забуваєте його оновити. Тому тести починають давати неправильні результати. А оскільки ви за ними орієнтуєтеся, чи правильно працює проєкт, маєте проблеми з проєктом. І це те, до чого всім потрібно бути готовими..
Кому не потрібні тести
Поговорімо про те, кому треба подумати про доцільність тестів на проєкті.
MVP-проєктам: Тести немає сенсу писати, якщо ви ще не запустились, не знаєте, чи будете підтримувати цей проєкт, чи ви його спробуєте викинути в магазин, він не піде, і ви про нього забудете. Тому що core-функціонал завжди йде першим. Тести — це довго, дорого, розтягує ваш цикл розробки. Відповідно MVP виходить пізніше і з цим можуть бути проблеми.
Проєктам з коротким життєвим циклом (типу hypercasual і casual): залежить від компанії до компанії, але в більшості випадків гіперкежу робиться багато, 10 проєктів закидається в стор, якісь з них потонули, про них забувають, ті, хто плавають, хай собі плавають. Покривати такі проєкти тестами не те що має багато сенсу, тому що дуже рідко підтримується та якось розширюється їхній функціонал. Тому треба думати, чи потрібно вам стільки часу і ресурсів витрачати на проєкт, якщо цикл розробки від 2 до 4 тижнів. Щоб написати тести, ви витратите ще стільки ж часу.
«Працює — не чіпай»: проєкти, які вже
Голодний бізнес, у якого багато Manual QA: Якщо у вас вся розробка — це бізнес, який приходить і каже «нам потрібно цей A/B тест запустити завтра», тоді робимо. «Ми хочемо цю фічу випустити через два тижні». Робимо. І коли бізнес насипає, насипає, насипає, насипає, і вас навіть хвилинки зайвої немає, то тести писати немає сенсу ні вам, ні бізнесу, бо буде ображатися, що ви пишете те, що не принесе якогось такого великого value. І якщо у вас доволі хороший штат QA, і ваші тестувальники знають свою роботу, ви можете цим пожертвувати. Бо ми, наприклад, на проєкті почали писати тести, коли залишилися з одним QA. І це була просто така життєва необхідність покрити тестами якісь супер стандартні речі, які QA постійно перетещує.
Кому потрібні тести
Всім іншим :) Автоматичні тести роблять вас впевненішими у вашому проєкті. Є дуже багато місць проєкту, які QA дуже важко протестити, або це буде надто великий проміжок часу для тесту. Якщо ви робите регресійне тестування, це може розтягнути його на пару днів, або і на тиждень. Тому тести — це ваш спокій, тести — це круто, в тести потрібно інвестувати.
З чого починаються тести
Це не обов’язково, але вам потрібна Unity 22. Тому що до
Unity Test Framework — це основне. Якщо ви хочете побачити гарне віконце тест-ранера у проєкті, і щоб в ньому щось відображалось, без нього ви це не зробите.
NUnit — це розширення для вашої мови програмування, для C#, щоб вам дали ті бібліотеки, які вам потрібні взагалі для прогонки тестів.
Далі не обов’язкові, це опціональні речі, які зроблять ваше тестування простіше.
NSubstitute— фреймворк, що дозволяє створювати об’єкти, які повертають потрібний вам результат.
Fluent Assertion — фінальна перевірка, чи щось працює чітко, як ви очікуєте. У ній є значно ширша можливість перевірок для якихось ваших даних та тестів, ніж у юнітівській Debug.
Якщо у вас ці п’ять речей є, ви можете стартувати робити тести.
Іменування тестів
Коли вже все підготовлено, переходимо до наступного дуже важливого етапу — це іменування тестів. Чим краще ви іменуєте тест, чим більше додаєте контексту, тим краще для вас.
Тому що якщо ви заходите, і там червоний тест, який потрібно пофіксити, і він названий «Test something to something», то вам спочатку треба дивитися, що той тест робить в коді. Проблеми може бути дві: або сам тест неправильний, застарілий, або у вас десь функціонал в застосунку відвалився.
Якщо ім’я тесту у вас нормальне, то ви одразу йдете в функціонал дивитися, що там зламалося. Якщо ім’я тесту не нормальне, ви спочатку розбираєтесь з тестом, а потім йдете чистити й фіксити функціонал. На це буде витрачатися доволі багато часу. Особливо, якщо людина нова на проєкті. Особливо, якщо той, хто писав тести, не дуже слідкував за їхньою структурою. Це буде вести до дуже великих проблем, і дуже великих затрат часу. Оскільки ж в ідеальному світі ви всі свої тести вбудовуєте в build-машину, якщо вона у вас є, вам одразу за логами з вашої build-машини потрібно розуміти, що у вас відвалилося. Тому іменування тестів дуже важливе.
Ви бачите п’ять найпопулярніших варіантів іменування тестів. Мабуть, в усіх них є проблема, крім останнього. По-перше, вони тяжко читаються. Коли у вас просто зливається текст одним величезним рядком, прочитати його важко. По-друге, кожен із цих варіантів доволі сильно вас обмежує.
Що добре — тут є чітка структура. Ви будете заходити й знати, що, наприклад, якщо це перший варіант іменування, то перше — це method name, друге — це те, як ви тестуєте, і останнє — це результат. І ви зайшли, прочитали, розумієте структуру. Але насправді, коли ми почали писати тести та використовували цей перший варіант, то виявили, що неймовірно багато часу втрачається на те, щоб видумати це ім’я, описати ним все, що відбувається в тесті. Тому що ця структура вам реально заважає.
В принципі, ви можете запустити тести на будь-якому з цих іменувань. Я б не радив другий варіант, тому що в ньому є це слово «test». Якщо ви зайшли в вікно тест ранера, то, не знаю, що там має бути, крім тестів.
Ці варіанти хороші, але ми з Unity-розробниками під час палких обговорень прийшли до свого варіанту іменування, яке зараз у нас живе на проєкті, і яке покриває все необхідне.
Стиль «Fucking Anaconda»
Свій стиль іменування тестів ми назвали «Fucking Anaconda». В чому його суть?
- Перше слово з великої букви
- Назви(класів, методів, івентів) — пишуться так як в коді
- Решта слів пишуться з маленької букви
- Логічні частини розділяються _
IsTestNamingPerfect_if_we_use_FuckingAnaconda_returs_maybe
По факту, єдине обмеження — це англійська мова і те, як будується речення. Тобто у вас є речення, які описують функціонал тестів, слова ви розділяєте нижнім підкресленням, і тільки назви методів, класів та якихось елементів з коду ви пишете так, як вони пишуться в коді.
Тоді, заходячи в тест, ви розумієте: якщо першою йде цільна частина з великої букви, то це або метод, або об’єкт, або якась там дата. Вже по цьому мінімуму ви можете знайти в пошуку цей метод в якомусь із ваших файлів. Далі просто звичайний опис, як речення англійською мовою. По ньому ви вже, в принципі, одразу в контексті того, що є в цьому тесті. Тому, якщо використовувати цей підхід, можна теж називати свої тести класно. У нього є свої недоліки — інколи назви тестів виходять кілометрові. Але краще прочитати кілометр назви, аніж влізти в код і розбиратися, що цей тест робить.
Які тести писати першими
Коли ми вже визначилися, як іменувати тести та що нам потрібно при тесті, час обрати, які з них писати першими. Я пропоную найперше покривати свій проєкт тестами на перевірку здоров’я.
Префаби, стореджі, все серіалізоване треба покрити тестами. Чому? По-перше, Unity дуже любить бити серіалізацію, особливо при оновленнях та підлиттях гілок. По-друге, це тести, які пишуться дуже швидко, дуже малою кров’ю, і покривають велику частину того, що QA би перевіряв місяцями. І серед таких тестів, якраз тести на биті префаби та об’єкти, на адреси. Або ж якщо ви використовуєте асет-бандли, локалізацію, і будь-які серіалізовані дані, які ви вважаєте важливими для вашого проєкту.
Биті префаби та обʼєкти
Assets_folder_does_not_contain_invalid_prefabs
Build_in_scenes_do_not_contain_invalid_objects(36)
All_screens_does_not_have_zero_in_xyz_lossy_scale
Я думаю, всі бачили такі вікна жаху в Unity-проєкті. Там, де у вас місінги, де позлітали посилання на спрайти, де взагалі префаб перетворився незрозуміло на що. І якщо QA буде тестити цю частину, то йому треба перетестити весь проєкт, бо ви ніколи не знаєте, де це станеться.
Якщо ж у вас написані тести, то, наприклад, перший тест, це «Assets folder does not contain invalid prefabs». Я думаю, за назвою одразу зрозуміло, що він йому дає — перевіряє всі префаби на те, чи в них немає речей типу мети, що злетіла, чогось зламаного, місінгів на якісь компоненти. У вас будуть в проєкті ці місінги, і, можливо, навіть в деяких місцях вони вам нічого не зламають. Але ви не знаєте точно. А тести закрили, забули у вас ідеально все з асетами. Так само сцени. Пройтись ієрархією сцен, подивитися, де там що позлітало, QA на це витратить тиждень, а з тестом це триватиме секунд 20.
І останнє (ми це вже додали пізніше) — перевірка, чи всі екрани з нормальною шкалою в один. Тому що коли в нас екрани, всі вони з окремим канвасом через свою оптимізацію. Дуже часто, коли редагувався екран на якійсь сцені в Unity й overriding prefab, одразу злітають в нуль всі його скейли. І тоді, коли ви запускаєте застосунок, у вас з префабом твориться якась дичина. Тому це дуже корисні тести, які ви можете написати. Написати їх можна хвилин за 20, тому що структура в них завжди плюс-мінус однакова. Завантажили всі файли з проєкту, спробували у вас їх відтестити і якщо Unity не віддало помилку, то у вас все прекрасно. Пройшлись по об’єктах, перевірились, чи немає місінгів, все прекрасно.
Адресабли/асет-бандли
Addressables_DefaultGroup_contain_only_MenuScene
Addressables_does_not_contain_duplicated_names
Адресабли на дефолтні групи. Залежно від того, хто що використовує, тести будуть відрізнятися, але у нас два основних тести на них. Це те, що наша дефолтна група, в ній знаходиться тільки одна меню-сцена, тому що групи визначають, як будуть завантажуватися ваші файли.
Дефолтна група повинна завантажуватися завжди, адже з неї стартує проєкт. Якщо у вас в ній буде ще купа якихось файлів з різних частин проєкту, вони будуть підвантажуватися тоді, коли ви завантажуєте собі дефолтну групу. Це не окей. І друге, це те, що всі адреси не мають дублікатів в іменах. Я думаю, тут можна навіть не пояснювати, для чого нам цей тест важливий.
Локалізація
Не всі проєкти мають локалізацію. Та Keiki World локалізований на більш як 14 мов. Підхід до неї доволі стандартний. Є компонента, в якій лежить локалізаційний ключ. Є табличка, яка переганяється в JSON, і локалізаційна компонента через цей ключ дістає з таблиці переклад. І тут насправді стільки місць в проєкті, де все може піти не туди... Тому якщо у вас є локалізація, локалізаційні тести must have.
Які є тести на локалізацію?
Characters_from_default_localization_exists_in_fonts
Localization_keys_from_default_localization_have_translation_for_each_language
Localization_keys_from_every_LocalizationSetter_in_prefabs_exist_in_default_localization
LocalizationSetters_have_not_NullOrWhitespace_key
Наприклад, у Keiki World 14 мов з підтримкою китайської та японської. Це дуже проблемні мови, тому що потрібно генерувати TMP-атласи під них, а ієрогліфів у них просто тисячі. Якщо ви вкинете у ваші атласи просто всі ієрогліфи, які є у світі, то отримаєте застосунок на
Також потрібно протесити, що до всіх ключів, які є в локалізації, є переклад. Якщо у вас є ключ без перекладу на якусь мову, у вас щось зламається. Можете уявити, скільки тестувальнику знадобиться часу, щоб пройти всі екрани, продивитися всі тексти й знайти помилку в застосунку на 16 мов. Це майже нереально.
Далі треба протестити, що всі ключі, які висять на наших компонентах з реалізованих проєктів, є локалізацією. Теж перевірка, яку зробити у QA забере дуже багато часу. І, звичайно, оці ключі всі не нові, де б вони не були. Навіть якщо ми з коду задаємо десь ключ, таке буває, у нас вони лежать в окремих статичних класах, звідки потім тест просто дістає всі ключі, і перевіряє, чи з ними все окей. Тож тести на локалізацію економлять максимально багато часу.
Серіалізовані дані
З серіалізованими даними тут вже в кожного проєкту може бути по-своєму. Чому ми писали на них тести? Зверху ви бачите головне меню Keiki World, в якому близько 30 мініігор. Обведені червоними кружечками іконки — три різні способи потрапити в ці ігри.
Перший — це просто список ігор. У другому відображається гра, яка вийшла останньою. Третій — це уроки з нашими іграми. Оскільки це education app, вони розташовані в певному порядку, щоб діти вивчили спочатку алфавіт, потім цифри тощо.
Фактично кожна з цих іконок — це вхід в одне місце, але вони мають свої серіалізовані дані. І дуже важливо, щоб в іграх, в нових активностях і в уроках були дані, валідні для гри. Тому що вони ведуть, наприклад, на рівень з ресурсами для гри, на спрайти, на звуки. Якщо там будуть стояти якісь не валідні дані, ви отримаєте помилку.
Розповім, як це виглядає у Keiki World. У гри є основний стартовий сторедж, у якому лежать скрипти зі спрайтами та з усім, і в ньому є ID. І є три місця в нашого проєкту, де ми за цим ID стараємося достукатися до цих даних. QA буде важко перевірити, що всюди ID стоять правильні. Написати ж тести, щоб завантажити сторедж і перевірити його дані — дуже швидко і просто.
Game_resources_configured_for_each_LevelId_from_GameData
Game_resources_configured_for_each_LelelId_from_LearningPlan_Steps
Game_resources_configured_fore_each_LevelId_from_NewActivity
Game_resources_configured_fore_each_LevelId_from_LearningChallenge
По факту, є чотири тести, які беруть один сторедж і перевіряють, що левел ID, який в ньому є, є і в ресурсах для гри, щоб їх можна було виконати.
Unit-тести
Unit-тести — це про тестування саме функціоналу якогось вашого методу. Ці тести супер важливі для core-функціоналу. Вони будуть дорогими, от якраз їх найважче підтримувати.
Структура Unit-тестів
Метод доволі простий. Наприклад, приходить словничок, і ми стараємося отримати з нього все, що нам потрібно, щоб щось робити. В цьому випадку цей метод можна використати, наприклад, для запуску якоїсь гри з push notifications. Ви у сповіщенні відсилаєте дату, дата прийшла. Якщо вона збігається з запуском гри, ви запускаєте користувачу гру, коли він клікнув на ваше сповіщення.
Тут є три етапи перевірки даних, навіть трошки більше, а потім — запуск самої гри. На цей метод можна написати доволі багато тестів, але давайте спочатку розберемося, як їх писати.
Підхід AAA: Arange, Act, Assert
У Keiki World крім іменування ми ще домовилися про чітку структуру тестів. Це підхід AAA. Він немає нічого спільного з AAA-розробкою, а розшифровується так: «Arrange, Act, Assert». Тобто спочатку ви готуєте поле бою, виставляєте все, що вам потрібно на нього, потім робите перевірочку, тобто запускаєте якийсь цільовий метод, і останнє — перевіряєте той результат, який вам видало. Доволі проста структура. Вона забезпечує те, що коли ви заходите й у вас там сотні тестів, завжди знаєте, де що шукати, де що може відвалитися.
Що робить цей метод? Він фактично перевіряє, що якщо в дату не прийшов параметр Game ID, значить це сповіщення не повинне запускати ігри. Отже, результат має бути false. Ми перевіряємо, що при такому запуску результат буде саме false.
Дуже важливе правило для тестів — вони завжди повинні давати єдино правильний результат. Тобто, якщо ми запускаємо тест на відсутність Game ID і подивимось на перевірку нашого методу, то побачимо, що Game ID у нас перевіряється найпершим. В принципі, якщо його нема, то до наступної перевірки ми навіть не дійдемо. Передавати в тест потрібно таку дату, щоб вона ламала ваш метод тільки в одному місці. Тобто якби ми тут не передали левели, і не передали все далі, що йде по тесту, ми не могли б оцінити, чи тест віддав нам правильний результат, тому що воно б могло віддати false в другому місці. А наш тест такий: ой, я очікую false, все класно. Тому правило єдиного правильного результату потрібно використовувати завжди.
Далі йде перевірка. Перевіряємо, що у нас все буде працювати на кожному рівні, де ми не віддамо якийсь
Про що я говорю? В нас є гейм-лаунчер, який буде запускати гру. І от якраз Substitute, це те, що я говорив про створення болванок для об’єктів з відомим результатом. Що робить наш метод? Він перевіряє всі дані, і якщо все ок, то заходить в інтерфейс гейм-лаунчера, і каже «запусти мені цю гру». Тому крім результату методу, що він поверне true або false, ми ще перевіряємо, чи наш інтерфейс отримав виклики, чи був залучений метод з цього інтерфейсу. Це вже йде повна перевірка методу. Тобто ви перевіряєте не тільки результат, а всі зв’язки, які може мати ваш метод. І перевіряєте за тим самим принципом, що результат методу. Тобто передали дату без Game ID, перевірили, що метод повертає false, перевірили, що гейм-лаунчер не отримав жодного виклику на метод.
І так ви йдете структурою вашого тесту. Для даної перевірки цього методу в мене вийшло 10 тестів. Дуже важливо те, що ви перевіряєте як позитивний результат, так і негативний. Тобто у вас було, що повертає false, у вас було, що метод не отримує виклик. А потім ви маєте обов’язково перевірити, чи метод отримує виклик, і при чому чітко вказати, скільки викликів цього методу ви очікуєте. Тому що, якщо ви тут не вкажете число, у вас може бути таке, що метод викликається три рази, коли ви очікуєте його тільки один раз. І тоді у вас, знову ж таки, проблема.
В принципі, всі Unit-тести працюють за таким принципом. Я говорив, що це дорого. Тому що можливо вам доведеться рефакторити проєкт через оцю річ. Ви хочете інтерфейсами й сабститутом закрити всі залежності, які у вас є для тестування якоїсь частини вашого коду, щоб ви могли створити мод, перевірити, чи йде його виклик, що з нього віддається, що він буде повертати для якогось результату. Тому вам будуть потрібні або інтерфейси, або абстрактний клас. В інших випадках вам доведеться танцювати навколо ваших залежностей, якось їх прикидувати, обробляти. Це буде дуже боляче, і це буде дуже-дуже-дуже дорого.
Ігрові тести та їх проблеми
Ігрові тести — це тести, які запускають, симулюють запуск вашої гри, і проходять просто по якихось місцях, перевіряють, чи там всі івенти відлетіли, чи все заініціалізувалося. Тобто фактично вони роблять симуляцію запуску вашої гри. І з ними є дуже багато проблем. Чесно скажу, це були дуже важкі два тижні розбивання коліна, коли ти знаходиш всі ці проблеми.
Перша проблема
Без додаткових модулів ви запустите ці тести тільки в Editor. І тут виникає проблема. Ви хочете, щоб ці тести гналися на таргетних девайсах. Якщо ви робите під ПК, там майже все окей. Якщо ви робите під мобільні пристрої, у вас немає сенсу перевіряти в Editor, адже вам потрібно перевірити на пристроях. Звичайно, є функціонал, який буде однаковий для всього, але все одно це доволі велике обмеження. І буде дуже важко вбудувати, наприклад, для build-машини, щоб ці тести перевірялись кожен раз, коли ви запускаєте гру.Друга проблема
Всі ігрові тести працюють тільки на корутинах. І з тасками, з Unit-тасками вам буде важко щось тестувати. Вам доведеться очікувати на результати й так далі.Третя проблема
Всі ігрові тести запускаються в одній ігровій сесії. Це означає, що у вас є 5 тестів, які перевіряють, наприклад, головне меню вашого застосунку. Вони запускаються підряд і між ними не чиститься ні статичний контекст, ні завантажені файли. Воно все висить між перезапусками тестів. І тому ви через статичні дані будете отримувати дуже багато болю. Бо вони не очищені, а ви очікуєте, що вони очистяться. І вам треба буде ці дані чистити своїми руцями, сподіваючись, що ви нічого не забудете.Четверта проблема
Якщо ви використовуєте Zenject, то той функціонал, який є в ньому для ігрових тестів, не працює. Просто не працює. Я його дебажив, щоб погратися з тестами. Там йде пошук об’єкта на сцені, якого навіть не існує, там неправильний неймінг всередині. Може, вони вже в останніх версіях Zenject, ну, не Zenject, а екс Zenject, це пофіксили, але не знаю.П’ята проблема
Виконання займає дуже багато тестів. Якщо юніт-тести проганяються за одну-дві секунди, то ігрові тести займають стільки часу, скільки потребує шлях вашого застосунку від завантаження до якоїсь сцени, яку вам треба протестити. Ми написали близько 30 ігрових тестів для наших проєктів, і в сумі вони майже 30 хвилин і запускаються. Запускати такі тести перед кожним білдом — дуже дорого і займе багато часу. Тож якщо ви хочете спробувати ігрові тести, можете спробувати, але по факту вони не дають вам нічого, що ви не можете перевірити або Unit-тестами, або шляхом перевірки структури даних.Наостанок
Не старайтеся робити всі тести правильно за всіма засадами ООП. Тести — це ваш бекдор. Треба рефлексія і дістати приватне поле? Робіть це. Треба десь якось зламати ваш код в самому тесті? Ламайте. Головне, щоб тести не впливали на ваш проєкт. Бо якщо писати тест, от просто для того, щоб написати цей тест, потрібно змінити дуже сильно залежності в вашому проєкті, і ви не рефакторите його, а просто пишете один тест, то краще зайти через задні двері, витягнути те, що вам потрібно, і піти собі спокійно. В тестах це ніхто не побачить, функціонал вашого проєкту та код залишиться чистеньким, і це такий край, в який ніхто не ходить.
Підписуйтеся на Telegram-канал @gamedev_dou, щоб не пропустити найважливіші статті і новини
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів