Як ефективно використовувати компонентні тести на Playwright
Радий вітати, я Олександр Микулич, Frontend Guild Master у TENTENS Tech by SKELAR. У попередньому дописі розповідав про виклики, з якими ми зіткнулись у проєкті, міграцію системи на модульний моноліт та які переваги ми врешті-решт отримали. У цій статті розповім про інший важливий пазл нашої архітектури — компонентні тести на Playwright.
Два роки тому перед нами постав виклик — рефакторинг системи. Тоді ми зрозуміли, що без автотестів далеко не дійдемо. Потрібен механізм, який страхуватиме і дозволить спати спокійніше. Тому почали досліджувати, яке тестування нам потрібно. Зрештою зупинились на компонентних тестах Playwright.
У мережі є багато інформації про те, як працює Playwright всередині, але мало про компонентні тести. Тому сьогодні я сконцентруюсь саме на цьому. Розберемо, як працюють компонентні тести під капотом і наші особливості роботи з ними. Якщо ж вам цікаво більше почитати про Playwright, то можу рекомендувати статтю колеги Олександра про його досвід міграції з Cypress на Playwright.
Приклад компонентного тесту
Пропоную почати з простого прикладу, як може виглядати компонентний тест на Playwright.
Нехай ми маємо vue компонент Counter.vue. При кліку на кнопку «Increment» число збільшується на 1. Якщо ви не знайомі з vue, це не проблема, оскільки компонентне тестування не сильно зав’язане на UI-фреймворк.
<script setup lang="ts"> const counter = ref(0) function inc() { counter.value++ } </script> <template> <div> Count: {{ counter }} <button @click="inc()"> Increment </button> </div> </template>
Давайте напишемо простенький компонентний тест, який натисне на кнопку і перевірить, що стан системи відповідає очікуванням:
import { test, expect } from '@playwright/experimental-ct-vue' import Counter from './Counter.vue' test('should increment ...', async ({ mount }) => { const component = await mount(Counter) const button = component.locator('button') await button.click() await expect(component).toContainText('Count: 1') })
Одна з переваг Playwright полягає у тому, що навіть при базовому знанні js/ts код читається дуже легко. Що ж тут відбувається?
- Для початку імпортуємо функції test і expect. Ці функції допоможуть нам описати тест і очікування від системи.
- Далі імпортуємо vue-компонент, який потрібно протестувати.
- Описуємо тест. Класична структура: підготовка, дія, перевірка (докладніше про це нижче).
- Викликаємо функцію mount і передаємо наш компонент. Компонент відмальовується у браузері.
- Шукаємо кнопку.
- Натискаємо на неї.
- Перевіряємо, що стан системи відповідає очікуванням.
І хоч тест виглядає просто, але насправді під капотом все трішки складніше.
Що ж там всередині
Перше, що важливо розуміти, — написаний тест і компонент працюють у різних процесах. Спрощено можна сказати, що тест працює у nodejs-процесі, а код компонента — у браузері. Коли ми запускаємо команду `playwright test` у консолі, запускається runner, який знаходить усі тести у вашому проєкті й певним чином виконує їх.
Процес того, як саме Playwright запускає тести, сьогодні розглядати не будемо, оскільки це не впливає на специфіку компонентних тестів і потягне на окреме обговорення. Та якщо вам буде цікавий розбір цього процесу, напишіть про це у коментарях.
Окремо від ранера буде запущено браузер, з яким Playwright буде надалі комунікувати й робитиме це асинхронно. Ось чому тести на Playwright обсипані await-ами.
Розберемо кожен рядок з нашого тесту
import { test, expect } from ’@playwright/experimental-ct-vue’;
Коли ви пишете звичайний e2e-тест на Playwright, то імпорт цих функцій виглядатиме так:
import { test, expect } from '@playwright/test';.
Чим же відрізняється імпорт з @playwright/test від @playwright/experimental-ct-{react,svelte,vue}?
Ключова різниця тільки в тому, що ви отримуєте додаткову фікстуру `mount` у вашому тесті, яка дозволяє відмалювати потрібний вам компонент на сторінці. Фікстуру mount буде описано нижче.
import Counter from ’./Counter.vue’;
Здавалось би, все просто: імпортуємо компонент vue і далі можемо оперувати змінною Counter, передаючи її у mount. Але насправді, якщо запустити подібний код на nodejs, то ви отримаєте помилку виду «Unknown file extension „.vue“», тому подібний код в ідеалі не мав би навіть запускатись.
Як Playwright це обходить
Це відбувається за допомогою babel і кастомного плагіна «playwright-debug-transform». Задача цього плагіна — обробити код тесту, який буде запущено, і підмінити всі імпорти «*.vue» чи «*.svelte» компонентів на змінні з певною мета-інформацією. Тому на виході замість «import Counter from ‘./Counter.vue’» ми отримаємо такий код:
const Counter = { __pw_type: 'importRef', id: '..._src_components_Counter_vue', }
test(’should increment’, async ({ mount }) => {
Тут задаємо ім’я нашого тесту і вказуємо, що потребуємо фікстури mount. Насправді механізм фікстур у Playwright є надзвичайно потужним. У проєкті ми пишемо і підтримуємо свої кастомні фікстури, і це заслуговує на окремий розбір їх підкапотної роботи. Тому, якщо вам цікаво зануритись глибше, також дайте знати про це у коментарях.
const component = await mount(Counter);
Це найцікавіший рядок тесту, оскільки найбільше магії відбувається саме тут.
Але перед тим, як розібратись з mount, розберімося, як код нашого компонента взагалі потрапляє у бразер?
Для цього Playwright робить білд вашого проєкту, використовуючи vite. Щоб vite зміг це зробити, йому потрібна точка входу. Нею виступає index.html та index.ts у папці Playwright.
index.html початково виглядає таким чином:
<!DOCTYPE html> <html lang="en" > <head> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.icon" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite App</title> </head> <body> <div id="root"></div> <script type="module" src="./index.ts"></script> </body> </html>
index.ts початково — пустий файл. Тут можна імпортувати потрібні нам стилі чи якісь бібліотеки, які знадобляться для роботи додатка. Маючи цю точку входу, vite розуміє, який саме код повинен потрапити у фінальний білд.
Після того, як білд готовий, буде запущено preview-сервер. Зазвичай він буде доступний за адресою: localhost:3100. Відкривши це посилання у браузері, буде завантажено збілджену статику.
Під час білда Playwright робить декілька важливих і неявних речей з «playwright/index.ts»:
- додає глобальну функцію «playwrightMount» — про неї буде нижче;
- на етапі підготовки коду тестів з babel, Playwright запам’ятовує усі компоненти, які ми імпортували у наших тестах; надалі він прокидує їх у реєстр компонентів у «playwright/index.ts», щоб потім мати до них доступ у браузері.
Після того, як білд готовий, preview-сервер запущено, Playwright запускає тест, відкриває браузер і переходить на localhost:3100.
Далі розберемо безпосередньо «const component = await mount(Counter);». Для наочності підставимо значення у змінній Counter:
// const component = await mount(Counter) const component = await mount({ __pw_type: 'importRef', id: '..._src_components_Counter_vue', })
З коробки Playwright дозволяє нам передати якийсь шматок коду в браузер і виконати його там. Фікстура mount якраз використовує таку можливість:
- вона отримує метадані компонента;
- викликає глобальну функцію playwrightMount з цими метаданими вже у браузері;
- у браузері функція playwrightMount знаходить потрібний компонент у реєстрі компонентів і передає його фреймворку vue на відмалювання;
- далі браузер повертає селектор відмальованого компонента у фікстуру;
- фікстура mount зі свого боку створює локатор на цей компонент і повертає його у тест у змінну «component».
Ось і вся підкапотна магія компонентного тестування. Усі наступні кроки тесту є стандартними для Playwright і не містять якоїсь специфіки.
Ось так це виглядатиме разом:
const button = component.locator(’button’);
За допомогою механізму локаторів ми оголошуємо, як саме потрібно шукати нашу кнопку.
await button.click();
На цьому етапі кнопку фактично буде знайдено у браузері, й відбудеться клік. Знову нагадаю про необхідність await, оскільки тест і кнопка зараз перебувають в окремих процесах, і комунікація між ними асинхронна.
await expect(component).toContainText(’Count: 1′);
Знову шукаємо за локатором фактичний елемент у браузері і перевіряємо, чи в DOM-і є потрібний нам текст.
Бонусом пропоную поглянути, як насправді виглядає код нашого тесту при запуску:
import { test, expect } from '@playwright/experimental-ct-vue' const Counter = { __pw_type: 'importRef', id: '..._src_components_Counter_vue', } try { test('should increment ...', async ({ mount }) => { const component = await mount(Counter) const button = component.locator('button') try { await button.click() } catch (playwrightError) { debugger throw playwrightError } try { await expect(component).toContainText('Count: 1') } catch (playwrightError) { debugger throw playwrightError } }) } catch (playwrightError) { debugger throw playwrightError } //# sourceMappingURL=data:application/json;charset=utf-8;base64,...
Благо, що генеруються source maps і при дебазі у тому ж VSCode ми можемо працювати з нашим оригінальним тестом, навіть не здогадуючись, що під капотом з ним відбулися зміни.
Час виконання
Часто пишуть, що Playwright швидкий. Для нас він перевершив усі очікування.
Зараз ми використовуємо компонентні тести на двох основних репозиторіях. На наших gitlab-ранерах (у Chrome) отримуємо такі результати:
Репозиторій 1. Наш основний продукт зі складною конфігурацією і логікою старту.
- Кількість тестів: 1046.
- Час виконання: 6 хв.
- Кількість воркерів: 16.
Репозиторій 2. Набагато легша конфігурація і простий старт.
- Кількість тестів: 747.
- Час виконання: 2 хв.
- Кількість воркерів: 16.
Ось так виглядають логи тестів:
Наші особливості написання комп. тестів
Уже близько двох років ми використовуємо компонентне тестування і за цей час випрацювали певні домовленості та способи написання таких тестів. Про це далі.
Домовленості
- Тест для нас — це першочергово документація роботи компонента, тому читабельність і легкість сприйняття — у пріоритеті.
- Структура тесту: A-AA-AA.
- Тест не повинен знати про внутрішню реалізацію компонента.
Докладніше по кожному з пунктів.
DRY vs легкість читання
У тестах ми допускаємо порушення принципу DRY на користь легкості сприйняття.
Патерн тестування A-AA-AA (Arange — Act/Assert — Act/Assert)
Arange.
Підготовка до безпосереднього тестування. Сюди входять:
- Підготовка фейкових даних і мок data-endpoints.
- Підготовка оточення: desktop/mobile, роль користувача, активні фічі чи A/B тести і т.д.
- Маунт компонента — await mount (MyComponent).
- Підготовка локаторів.
Act.
Цей етап передбачає:
- взаємодію з компонентом (click, text typing і т.д.);
- непряму взаємодію, наприклад, через оточення, як-от відправка WebSocket-повідомлення і т.п.
Дуже важливо, щоб на цьому етапі ми максимально симулювали поведінку користувача або бекенд-системи. Не використовуємо хаки, які дозволяють напряму викликати метод компонента чи сервісу. Тест не повинен знати про внутрішню реалізацію — він повинен декларувати очікувану бізнес-поведінку.
Assert.
Перевірка, що стан системи на певний момент відповідає очікуванням.
На відміну від стандартної структури в юніт-тестах AAA, ми допускаємо, що в комп. тестах буде повторено етапи «дії» і «перевірки».
Відносини між тестом і компонентом
Тест повинен сприймати компонент, як чорну скриньку, тому він не може знати дечого про компонент.
1. Внутрішню реалізацію, а саме:
- методи;
- вотчери (watch);
- приватні властивості.
2. Сервіси (класи), які використовує компонент для своєї роботи.
Тест може знати:
1. Вхідні дані (props).
2. Вихідні повідомлення (events).
3. Оточення, в якому повинен працювати компонент (mobile, роль користувача і т.д.).
4. End-point-и, на які ходить компонент, і дані, які він відправляє або отримує.
5. Візуальне представлення компонента. Наприклад, тест знає, що компонент має кнопку з певним `data-test-id`.
6. Взаємодія з Browser API. До прикладу, завантаження файлу.
Менеджмент фейкових даних, або network mocking
З коробки Playwright дає змогу зручно мокати запити на бекенд таким чином:
test('...', async ({ page }) => { ... await page.route('*/**/api/v1/get-user-data', async route => { await route.fulfill({ status: 200, json: { userName: 'test-user', userId: 12345, email: '[email protected]', role: 'admin', }, }) }) ... })
Але якщо в межах одного тесту у вас є потреба замокати багато ендпоїнтів, а деякі з них ще й на етапі ініціалізації додатка, то такий спосіб виглядає не найкращим. І це був саме наш випадок.
Зараз ми працюємо з даними таким чином:
- Описуємо data-файл, який відображає потрібний нам бізнес-сценарій.
- За допомогою тега `@data-` підключаємо його до тесту.
Приклад:
- Файл з даними «userWithZeroBalance.ts».
// data/userWithZeroBalance.ts export default generateData({ currentUser: { name: 'John Doe', email: '[email protected]', role: 'user', }, balance: { amount: 0, currency: 'USD', }, })
- Підключаємо data-файл до тесту тегом
“@data-userWithZeroBalance”. // .../MyComponent.pw.spec.ts test('should ... @data-userWithZeroBalance', async ({ mount }) => { const component = await mount(MyComponent) ... })
До одного тесту можна підключити декілька файлів одночасно, що дозволяє розбивати дані на певні бізнес-сценарії й підключати їх окремо за потреби.
Є й інші цікаві механіки, які ми використовуємо:
- Скріншот тестування і скріншот Design Review.
Playwright вміє у тестування скріншотів і ми це використовуємо в багатьох місцях. Додатковим бонусом отримуємо можливість дизайнеру перевірити відповідність розробки до дизайну ще на етапі CodeReview у gitlab.
- Автоматична перевірка продуктового трекінга.
Наш продукт густо покритий трекінгом, і бувають кейси, коли під час рефакторингу зникає або починає менше відстрілюватись трекінговий івент. Компонентні тести детектять подібні випадки й сигналізують нам про це.
- Перемотування часу (до речі, в останніх релізах це вже доступно з коробки, і нема потреби реалізовувати самому).
Особливо корисна історія для time-specific фіч. Для прикладу, потрібно показати якийсь попап користувачу за 5 хвилин після того, як він зробив якусь дію в системі.
- Fullpage testing.
Комп. тести дають можливість протестувати не окремий компонент, а всю сторінку за певним роутом. У деяких випадках це дуже зручно, і приносить багато цінності.
- Параметризація тестів.
У випадку, якщо потрібно запустити той самий тест, у різних тестових обставинах у Playwright є можливість параметризації тестів.
Приклад звичайного тесту на Playwright:
[ { name: 'Alice', expected: 'Hello, Alice!' }, { name: 'Bob', expected: 'Hello, Bob!' }, { name: 'Charlie', expected: 'Hello, Charlie!' }, ].forEach(({ name, expected }) => { test(`testing with ${name}`, async ({ page }) => { await page.goto(`http://example.com/greet?name=${name}`) await expect(page.getByRole('heading')).toHaveText(expected) }) })
Ми реалізували схожу можливість, але на основі тегів.
Це виглядає таким чином:
test(` should ... [@data-case1, @data-case2, @data-case3] [@desktop, @mobile] `, async ({ mount }) => { const component = await mount(MyComponent) ... })
Замість одного тесту відпрацює шість у різних комбінаціях:
- should ... @data-case1 @desktop
- should ... @data-case1 @mobile
- should ... @data-case2 @desktop
- ...
Чого не вистачає
Зараз у комп. тестах на Playwright є одна бісяча річ, яка сильно уповільнює написання тестів. Якщо змінюєш код компонента і перезапускаєш тести, заново відбувається vite build і стартує preview server. При локальній розробці хочеться працювати з vite dev server, який дозволить моментально перезапускати потрібні тести без необхідності білда. У github playwright є декілька issues на цю тематику, я особисто слідкую за цим.
Але скоро це зміниться. Хоч у документації з цього приводу поки згадок немає, але у коді playwright вже можна знайти логіку, яка дозволяє запускати комп. тести з vite dev server. Я вже спробував цю фічу на одному з наших нових репозиторіїв, і у зв’язці з playwright ui mode це виводить розробку на новий рівень ефективності. І зокрема відкриває такі шляхи, як використання TDD з комп. тестуванням.
Висновки
Playwright — це потужний інструмент у світі автоматизованого тестування. Він потихеньку відвойовує своє заслужене перше місце поміж конкурентів. Ми використовуємо його як для E2E, так і для компонентного тестування.
І за два роки можемо впевнено сказати, що задоволені вибором і досвідом, який надає цей інструмент. Якщо ж говорити про компонентне тестування, то у нашому випадку це ідеальний баланс між швидкістю написання і ціною виконання тестів.
А чи бачите ви сенс у компонентних тестах у себе на проєкті? Напишіть про це у коментарях.
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів