Розділ 1 з 10
Навіщо TypeScript у Playwright
Playwright підтримує TypeScript «з коробки»: ви пишете тести у файлах .ts, а раннер сам читає їх, трансформує в JavaScript і запускає. Жодного окремого кроку збирання не потрібно.
Типи дають чотири речі, які особливо відчутні у великому тестовому наборі. Перша — автодоповнення: редактор знає всі методи page, Locator і expect та їхні параметри, тож не треба тримати API в голові. Друга — помилки до запуску: друкарська помилка в назві методу чи пропущений аргумент підсвічуються одразу, а не через хвилину прогону. Третя — самодокументування: сигнатура login(page: Page, username: string, password: string): Promise<void> розповідає про функцію більше, ніж коментар. Четверта — безпечний рефакторинг: коли ви перейменовуєте поле в інтерфейсі тестових даних, компілятор покаже кожне місце, яке треба оновити.
Хто насправді ловить помилки типів
Проблема. Поширене уявлення: «раз тести на TypeScript, то Playwright не дасть запустити код із помилкою типу».
Чому це не так. Документація прямо каже, що Playwright не перевіряє типи і запускає тести навіть за наявності некритичних помилок компіляції TypeScript. Раннер лише трансформує код. Помилки з прикладу вище показують мовний сервіс TypeScript у редакторі та компілятор tsc.
Рішення. Запускайте компілятор окремо, у режимі лише перевірки: npx tsc -p tsconfig.json --noEmit. Локально зручно тримати його в режимі спостереження з прапорцем -w, а в CI ставити окремим кроком перед тестами.
Коли. Завжди, коли проєкт на TypeScript. Без цього кроку помилка типу може тихо дожити до падіння тесту під час виконання.
Розділ 2 з 10
tsconfig.json для Playwright-проєкту
Головне, що треба зрозуміти про tsconfig у Playwright: його читають двоє різних «клієнтів», і кожен бере з файлу своє.
Перший клієнт — сам Playwright. Він для кожного файлу, який завантажує, шукає найближчий tsconfig.json, піднімаючись угору по дереву каталогів, і з усіх compilerOptions підтримує лише п'ять опцій: allowJs, baseUrl, paths, references і extends. Другий клієнт — компілятор tsc і мовний сервіс у редакторі. Для них важливі всі інші опції: strict, target, module тощо. Тобто рядок "strict": true ніяк не змінює поведінку раннера, він лише визначає, наскільки суворо tsc і IDE перевірятимуть ваш код.
Звідси ще одна зручність із документації: можна покласти окремий tests/tsconfig.json, і Playwright підхопить його для тестів автоматично. Можна також явно передати файл прапорцем npx playwright test --tsconfig=tsconfig.test.json або опцією tsconfig у playwright.config.ts. В останньому випадку цей tsconfig не застосовується до завантаження самого конфігу та файлів, які він імпортує.
Чому саме module і moduleResolution
У TypeScript 6.0 значення moduleResolution: "node" (воно ж "node10") оголошене застарілим, а TypeScript 7.0 успадкував нові значення за замовчуванням із версії 6.0 і перетворив усі її deprecations на помилки. Release notes TypeScript 6.0 пояснюють, що відтепер moduleResolution: "bundler" можна поєднувати з module: "commonjs", і саме ця комбінація часто є найзручнішим шляхом міграції з node10.
Для Playwright-проєкту вона логічна. Якщо в package.json немає "type": "module", а файли мають розширення .ts, а не .mts, проєкт працює як CommonJS, тому module: "commonjs" описує для tsc ту саму модель модулів. Резолюція bundler розуміє поле exports у package.json залежностей, не вимагає розширень у відносних імпортах і підтримує paths. Якщо ваш проєкт має "type": "module", звірте вибір з гайдом TypeScript Choosing Compiler Options.
Що змінилося відносно старих конфігів
Опцію esModuleInterop більше не треба прописувати. У TypeScript 6.0 значення false для неї стало застарілим, тож поведінка з true тепер фактично стандартна. До імпорту модулів Playwright ця опція ніколи не мала стосунку: імпорти з @playwright/test працюють через іменовані експорти.
target не потрібен для підтримки async/await. Він визначає, у який синтаксис tsc перетворив би код, і які вбудовані API (набір lib за замовчуванням) вважаються доступними. Із noEmit лишається тільки друге значення. На виконання тестів target не впливає, бо код трансформує Playwright.
outDir і rootDir прибрано: з noEmit: true компілятор нічого не записує на диск, тож каталог для результату не потрібен. Окрема історія — ручна компіляція тестів, описана в документації Playwright для випадків, коли раннер не може трансформувати дуже нові можливості TypeScript. Там outDir знову знадобиться.
types: ["node"] з'явився через іншу зміну TypeScript 6.0: тепер масив types за замовчуванням порожній, і типи з @types/* не підтягуються автоматично. Release notes прямо радять більшості проєктів задати "types": ["node"]. Без цього tsc не знатиме, що таке process.env чи Buffer. Пакет @types/node має бути в devDependencies.
strict у TypeScript 6.0 став true за замовчуванням. Ми все одно пишемо його явно, щоб намір був видно з конфігу.
Path aliases
Playwright підтримує paths з tsconfig без жодних додаткових пакетів чи реєстраторів у playwright.config.ts. Шляхи в paths вказуються відносно розташування самого tsconfig. baseUrl для цього не потрібен: у TypeScript 6.0 він застарів і більше не вважається коренем для пошуку модулів, а в TypeScript 7.0 дає помилку. Той, хто використовував baseUrl як префікс, просто переносить префікс у кожен запис paths, як у прикладі вище.
Розділ 3 з 10
Базові типи
Невеликий набір типів покриває більшість того, що трапляється в тестах: примітиви, масиви, кортежі, union і literal types, опційні та nullable-значення.
Примітиви, масиви, кортежі
Три примітиви, з якими ви працюватимете постійно, — string, number і boolean. Масив записується як string[] або в generic-формі Array<string>, обидва записи рівнозначні. Кортеж — це масив фіксованої довжини, де тип кожної позиції відомий.
Union і literal types
Union type каже, що значення може бути одним із кількох типів. Коли учасники union — конкретні рядки чи числа, це literal types: змінна приймає лише перелічені значення. Для тестів це ідеально описує назви браузерів, HTTP-методи чи статуси.
Optional і nullable
Знак питання після назви параметра чи поля робить його опційним, і тоді його тип фактично T | undefined. Nullable-значення оголошується явно через union з null. Під strict (точніше, під strictNullChecks, який strict вмикає) TypeScript не дасть використати таке значення як звичайний рядок, доки ви не перевірите його на null чи undefined. Це та сама механіка, через яку textContent() вимагає обробки null (розділ 6).
unknown проти any
any вимикає перевірку типів для значення: з ним можна робити що завгодно, і компілятор мовчатиме навіть тоді, коли код гарантовано впаде. unknown теж приймає будь-яке значення, але не дозволяє нічого з ним робити, доки ви не звузите тип перевірками. Цей процес handbook називає narrowing: typeof, перевірка на null, оператор in. Після кожної перевірки TypeScript уточнює тип у відповідній гілці коду.
Розділ 4 з 10
Interface і type alias
Обидва способи описують форму об'єкта, і в більшості випадків вони взаємозамінні. Різниця проявляється на краях: у розширенні, у злитті оголошень і в тому, що ще можна виразити.
Як обирати
Interface описує форму об'єкта і розширюється через extends. Його можна «відкрити» повторно: два оголошення interface з однаковою назвою зливаються в одне (declaration merging). Type alias — це ім'я для будь-якого типу, не лише об'єктного. Через нього можна назвати union ('admin' | 'user'), кортеж, примітив чи результат utility type. Об'єктні типи комбінуються через перетин &. Після створення type alias не можна доповнити новими полями.
Handbook пропонує просту евристику: здебільшого вибір — справа смаку, тож використовуйте interface, доки не знадобляться можливості, які є лише в type. На практиці в тестовому проєкті це виглядає так. Форма тестових даних і контракти класів описуються через interface. Набори допустимих значень, кортежі та похідні типи на кшталт Omit<User, 'password'> описуються через type.
Типізовані тестові дані
Коли дані для тестів описані інтерфейсами, помилка в назві поля чи тип значення ловиться ще в редакторі, а вкладені структури (сценарій = користувач + товар) читаються як специфікація.
Зверніть увагу на import type: такий імпорт гарантовано зникає після трансформації, бо з файлу беруться лише типи.
Розділ 5 з 10
Generics
Generic — це тип-параметр: функція чи інтерфейс пишеться один раз, а конкретний тип підставляється під час використання. Так код лишається універсальним і не втрачає типобезпеки.
Generic-функції
Функція нижче працює з масивом будь-якого типу й повертає елемент того самого типу. TypeScript сам виводить T з аргументу. Тип повернення чесно містить undefined, бо масив може бути порожнім.
Generic-інтерфейси
Типовий випадок — «обгортка» відповіді API, у якій змінюється лише корисне навантаження. Інтерфейс ApiResponse<T> описує форму JSON-тіла, яке повертає ваш бекенд. Не плутайте його з класом APIResponse з Playwright: той описує саму HTTP-відповідь (статус, заголовки, методи json() і text()), а наш інтерфейс — лише вміст тіла.
Отримувати такі дані в тесті правильно через фікстуру request (APIRequestContext), а не через глобальний fetch: request бере baseURL і extraHTTPHeaders з конфігу, тож відносний шлях працює. Generic-хелпер нижче приймає тип тіла як параметр.
Важливо про as T. Це твердження типу (type assertion), а не перевірка. Компілятор повірить вам на слово, але під час виконання ніхто не перевірить, що бекенд справді повернув таку форму. Якщо поле прийде іншого типу, помилка вилізе далі в тесті. Для критичних полів додайте явні перевірки через expect.
Generic-хелпер на Locator
Хелпер приймає вже готовий Locator, а не рядок-селектор. Так він не залежить від того, як саме знайдено елемент: через getByRole, getByTestId чи інший спосіб. Очікування відбувається через web-first assertion, а функція перетворення визначає тип результату.
Такий хелпер доречний, коли значення потрібне для подальших обчислень, наприклад щоб порівняти суму з ціною товару, помноженою на кількість. Якщо ж треба просто перевірити текст, хелпер зайвий: одразу пишіть await expect(locator).toHaveText('$59.98').
Розділ 6 з 10
Типізація Page, Locator та інших об'єктів Playwright
Playwright експортує типи для всіх своїх об'єктів. Анотуючи ними параметри й поля, ви отримуєте автодоповнення і перевірку для кожного виклику.
Page і навігація
page.goto() вже чекає, доки сторінка досягне стану load (значення waitUntil за замовчуванням). Тому одразу після нього page.waitForURL() на ту саму адресу нічого не додає. waitForURL потрібен після дії, яка сама спричиняє навігацію: клік на кнопку входу, сабміт форми. Для перевірки адреси в тесті ще зручніше використати assertion expect(page).toHaveURL().
Locator як поле класу
Методи page.fill(selector, …), page.click(selector) та інші page-методи з рядком-селектором у документації позначені як discouraged: замість них рекомендовано локатори. Best practices радять будувати локатори на атрибутах, які бачить користувач, і на явних контрактах. Найперше це getByRole, далі getByLabel, getByPlaceholder, getByText. getByTestId підходить, коли команда домовилася про тестові атрибути. CSS і XPath лишаються крайнім варіантом, бо прив'язані до верстки.
Що повертають методи Locator
Кожен метод Locator, що читає стан, повертає Promise з конкретним типом. Найважливіша різниця — між textContent() і innerText(). Перший у сигнатурі має тип null | string, тож під strict TypeScript змусить обробити null. Другий завжди повертає string. Так само getAttribute() повертає null, якщо атрибута немає.
Читати текст вручну чи перевіряти assertion
Проблема. Старий патерн: дочекатися елемента через waitFor(), прочитати textContent(), повернути рядок із Page Object і порівняти його в тесті через toBe.
Чому. Прочитане значення — знімок у конкретну мить. Якщо текст оновлюється асинхронно, порівняння може спрацювати на проміжному стані, і тест стане нестабільним. Документація методів textContent() та innerText() прямо радить для перевірки тексту брати expect(locator).toHaveText().
Рішення. Web-first assertions (toHaveText, toContainText, toBeVisible, toHaveCount) самі повторюють перевірку, доки умова не виконається або не мине тайм-аут.
Коли читати значення. Коли воно потрібне для логіки: розпарсити число, запам'ятати ідентифікатор, передати далі. Тоді й знадобиться знання про string | null.
BrowserContext і перехоплення запитів
Обробник у context.route() може бути асинхронним, а методи route.continue(), route.fulfill() і route.abort() повертають Promise, тому їх треба чекати через await. Інакше обробник завершиться раніше, ніж Playwright отримає рішення щодо запиту, а помилку всередині ніхто не побачить.
Якщо заголовок потрібен на всіх запитах контексту, а не лише на окремому шаблоні URL, простіше скористатися опцією extraHTTPHeaders. Її можна задати в use конфігу чи тесту або передати в browser.newContext(). Вона ж застосовується до запитів фікстури request.
Response і APIResponse — два різні типи
Їх легко сплутати, бо обидва мають status() і json(). Response — це відповідь, яку побачила сторінка в браузері. Її повертають page.waitForResponse() і page.goto(). APIResponse — результат прямого HTTP-запиту через APIRequestContext: request.get(), request.post() тощо. Для другого є web-first assertion expect(response).toBeOK(), що перевіряє статус у діапазоні 200–299.
ElementHandle — discouraged
ElementHandle (його повертають page.$() і page.waitForSelector()) у документації позначений як discouraged. Handle вказує на конкретний DOM-вузол: якщо фреймворк перемалює елемент, handle «протухне». Locator натомість шукає елемент заново під час кожної дії і має вбудоване автоочікування.
Розділ 7 з 10
Utility types
Utility types — це вбудовані generic-типи, що будують новий тип із наявного. У тестах вони рятують від дублювання: одна «повна» модель, а варіанти для створення, логування чи перевизначення конфігів виводяться з неї.
Partial — перевизначення конфігурації
Partial<T> робить усі поля опційними. Класичний випадок — функція, яка бере значення за замовчуванням і накладає на них лише ті поля, що передали.
Required — усе обов'язкове
Required<T> робить протилежне: прибирає опційність з усіх полів. Корисно, коли форма має опційні поля, а конкретний сценарій вимагає заповнити все.
Pick і Omit — підмножини полів
Pick<T, K> лишає тільки перелічені ключі, Omit<T, K> — усі, крім перелічених. Типовий приклад — дані для запиту на створення сутності: у них немає id, бо його призначить сервер.
Record — словник з відомими ключами
Record<K, V> описує об'єкт, де ключі мають тип K, а значення — тип V. Якщо як K взяти union літералів, TypeScript вимагатиме, щоб у словнику були всі ключі, і не пропустить зайвих. Це надійніше за Record<string, …>, де підійде будь-який рядок.
Розділ 8 з 10
Реальні патерни
Зберемо все разом: абстрактна базова сторінка, конкретні Page Objects на локаторах, типізовані фікстури і типізований API-тест.
BasePage
Клас оголошено abstract: створити new BasePage(page) не можна, лише успадкуватися. Поле page має модифікатор protected, тож його бачать нащадки, але не тести. Абстрактний getter url змушує кожну сторінку оголосити свою адресу, і якщо забути, tsc повідомить про це.
Чому з BasePage прибрано waitForLoadState('networkidle')
Проблема. У багатьох шаблонах BasePage є метод на кшталт waitForPageLoad() з page.waitForLoadState('networkidle').
Чому. У документації стан networkidle позначено як discouraged: він чекає, доки мережа не затихне щонайменше на 500 мс, і його не слід використовувати для тестування. На сторінках з опитуванням сервера, аналітикою чи вебсокетами мережа може ніколи не затихнути, а на швидких сторінках тиша не гарантує, що потрібний елемент уже відмальовано.
Рішення. Документація радить оцінювати готовність сторінки через web-first assertions. Перевіряйте те, що справді важливо для тесту, наприклад await expect(dashboard.welcomeHeading).toBeVisible(). Дії над локаторами й так мають автоочікування.
Коли. Щоразу, коли хочеться «дочекатися завантаження», сформулюйте, яку саме видиму ознаку готовності ви чекаєте, і перевірте її assertion.
LoginPage і DashboardPage
Page Object тут повертає локатори, а не прочитані рядки. Перевірки лишаються в тесті й виконуються через web-first assertions.
Типізовані фікстури
Тут показано лише типізацію, детальна тема фікстур іде окремим матеріалом. Тип Pages передається в base.extend<Pages>(), і завдяки цьому в кожному тесті loginPage та dashboardPage мають точні типи. Кожна фікстура отримує параметр use і обов'язково викликає await use(...), передаючи значення в тест.
Типізований API-тест
Фікстура request має тип APIRequestContext, а її методи повертають APIResponse. Відносний шлях працює, якщо в конфігу задано use.baseURL. Статус перевіряємо через toBeOK(), а тіло типізуємо твердженням as Todo. Пам'ятайте, що твердження не перевіряє дані під час виконання.
Розділ 9 з 10
Типові помилки
Сім помилок, які найчастіше трапляються в Playwright-проєктах на TypeScript. Після них — тренажер, де потрібно самостійно знайти проблемний рядок.
1. any замість типів
any вимикає всю перевірку, і тоді TypeScript не допоможе ні з автодоповненням, ні з помилками. Якщо тип справді невідомий, беріть unknown і звужуйте його. Якщо відомий, опишіть інтерфейсом.
2. Ігнорування null з textContent()
textContent() має тип string | null, тож присвоєння в змінну типу string під strict не скомпілюється. Обробіть null через ??, візьміть innerText(), який повертає string, або, якщо мета — перевірити текст, одразу пишіть toHaveText().
3. Відсутні return types в async-функціях
TypeScript виведе тип повернення сам, тож технічно це не помилка компіляції. Але без явного Promise<...> легко не помітити, що функція асинхронна, і забути await під час виклику. Явна сигнатура — це контракт: якщо реалізація почне повертати інше, компілятор вкаже на це в самій функції, а не десь у тесті.
4. ElementHandle замість Locator
page.$() і ElementHandle — discouraged API. Handle прив'язаний до конкретного вузла DOM і не має автоочікування. Локатор щоразу знаходить елемент заново.
5. Вимкнений strict
Без strict (конкретно без strictNullChecks) null і undefined можна присвоїти будь-якому типу. Тоді string | null з textContent() перестає бути сигналом, і помилки переїжджають у час виконання. З TypeScript 6.0 strict увімкнено за замовчуванням, тож небезпека тепер у явному "strict": false.
6. Думати, що Playwright перевіряє типи
Раннер трансформує TypeScript у JavaScript і запускає тести навіть із некритичними помилками типів. Якщо npx tsc --noEmit ніде не запускається, червоні підкреслення в IDE — єдиний захист, і він не працює в CI. Додайте перевірку типів окремим кроком (розділ 1).
7. Deprecated-опції в tsconfig
Конфіги, скопійовані зі старих туторіалів, часто містять "moduleResolution": "node", "baseUrl": "." або "esModuleInterop": false. У TypeScript 6.0 усе це застаріло, а в TypeScript 7.0 дає помилку. Прапорець "ignoreDeprecations": "6.0" працював лише в TypeScript 6.0, TypeScript 7.0 ці опції вже не підтримує. Актуальний варіант конфігу є в розділі 2.
Розділ 10 з 10
Шпаргалка
Усе основне на одному екрані. Кожен блок можна скопіювати. Після шпаргалки — підсумковий квіз для самоперевірки.
Три правила, які варто винести з цього матеріалу. Типи перевіряє tsc, а не раннер. Тестовий код працює з локаторами та web-first assertions, а не з рядками-селекторами й ручним читанням тексту. as T лише каже компілятору, яким має бути значення, і нічого не перевіряє під час виконання.
Самоперевірка
Підсумковий квіз
14 питань за всіма десятьма розділами. Після кожної відповіді побачите пояснення, а в кінці — список тем, які варто повторити.