08 · JavaScript/TypeScript для AQA
Модулі та npm: import/export, package.json
Зміст
Ти клонуєш тестовий репозиторій, відкриваєш його — і перше, що бачиш, це package.json, тека node_modules на кількасот мегабайтів, купа рядків import { test, expect } from '@playwright/test' угорі кожного файлу і команда npm ci десь у CI-конфізі. Усе це — не декорація, а система керування чужим кодом: звідки він береться, якої саме версії, і як зробити так, щоб на твоїй машині й на CI-агенті зібралося рівно те саме.
Для AQA це не «інфраструктурні дрібниці». Половина болю новачка в автоматизації — це не Playwright і не локатори, а Cannot find module, Cannot use import statement outside a module і класика «локально зелено, у CI червоно», де винна не логіка тесту, а розбіжність версій залежностей. Хто розуміє модулі та npm, той відрізняє баг у своєму коді від зламаного оточення — і не витрачає півдня на полювання за примарою.
Модуль: навіщо код узагалі ділять на файли
Модуль (module) — це окремий файл зі своєю областю видимості. Змінна, оголошена в модулі, за замовчуванням не витікає назовні: щоб інший файл міг нею скористатися, її треба явно експортувати (export), а на іншому боці — імпортувати (import).
Навіщо це тестувальнику. Ти описуєш Page Object один раз у файлі pages/LoginPage.ts, експортуєш клас — і підтягуєш його в десятках тестів одним рядком. Змінюється локатор — правиш в одному місці. Без модулів увесь код жив би в глобальному просторі імен, де будь-яка змінна page чи user з одного тесту мовчки перетирала б таку саму з іншого. Модулі — це ізоляція й перевикористання, той самий принцип, що й інкапсуляція в ООП (див. this, класи та ООП), тільки на рівні файлів.
CommonJS проти ES Modules
У світі JavaScript історично склалося дві системи модулів, і це джерело левової частки плутанини.
CommonJS (CJS) закріпився в Node.js, ще коли в самій мові модулів не було. Синтаксис — require() для імпорту й module.exports для експорту. Він синхронний і динамічний: require('./x') — це звичайний виклик функції, який виконується в момент, коли до нього дійшло виконання, тож шлях можна навіть зібрати рядком на льоту.
ES Modules (ESM) — це стандарт самої мови (з ES2015). Синтаксис — import / export. Він статичний: усі імпорти аналізуються до виконання коду, тому шлях має бути літералом, зате інструменти можуть вирізати невикористане (tree-shaking) і будувати граф залежностей наперед. ESM підтримує верхньорівневий await (top-level await) — див. асинхронність.
Як Node розуміє, який файл яка система:
| Ознака | Трактування |
|---|---|
Розширення .mjs | Завжди ESM |
Розширення .cjs | Завжди CommonJS |
.js без "type" у package.json | CommonJS (історичний дефолт) |
.js + "type": "module" у package.json | ESM |
.ts | Вирішує компілятор/ранер за tsconfig, не Node напряму |
Головне для AQA: у тестовому проєкті на TypeScript ти майже завжди пишеш ESM-синтаксис (import/export), а те, у що він перетвориться під час виконання, визначає ранер і поле module у tsconfig.json (див. tsconfig у тестовому проєкті). Тому помилку Cannot use import statement outside a module майже ніколи не лікують у самому тесті — це неузгодженість між кодом (ESM) і оточенням, яке чекало на CommonJS.
Експорт і імпорт: named проти default
Є два способи щось експортувати, і різниця між ними — улюблене питання на співбесіді.
Named export (іменований) — експортуєш сутність під конкретним іменем, і під тим самим іменем її імпортують (у фігурних дужках):
// helpers/format.ts
export const toISO = (d: Date): string => d.toISOString();
export function slugify(s: string): string { /* ... */ return s; }
import { toISO, slugify } from './helpers/format';
Default export (експорт за замовчуванням) — один на модуль, імпортується без дужок і під будь-яким іменем:
// pages/LoginPage.ts
export default class LoginPage { /* ... */ }
import LoginPage from './pages/LoginPage'; // ім'я довільне
Named import можна перейменувати через as (import { test as base } from '@playwright/test'), а кілька named-експортів зручно зібрати в один «бочковий» файл index.ts, який лише реекспортує решту (export * from './LoginPage').
Чому в тестовому коді зазвичай віддають перевагу named: ім'я фіксоване, тож рефакторинг і автодоповнення в редакторі працюють точно, а один і той самий клас не назветься LoginPage в одному файлі та Login у сусідньому. Не випадково сам Playwright віддає свій API саме named-експортами: import { test, expect } from '@playwright/test'.
package.json: маніфест проєкту
package.json — це паспорт проєкту: хто він, що йому потрібно й що вміє запускати. Ключові поля для AQA:
{
"name": "e2e-tests",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "playwright test",
"test:smoke": "playwright test --grep @smoke",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"typescript": "^5.6.0",
"@types/node": "^22.7.0"
}
}
scripts — іменовані команди. npm run test:smoke виконає те, що записано праворуч. Кілька імен — test, start, stop — можна запускати скорочено (npm test), решту лише через npm run <ім'я>. Усередині скриптів node_modules/.bin уже є в PATH, тому там пишуть просто playwright test, без npx.
dependencies проти devDependencies. Перше — те, без чого застосунок не працює в проді; друге — те, що потрібне лише для розробки й тестування. У тестовому проєкті майже все — це devDependencies (Playwright, TypeScript, @types/*), бо сам набір тестів нікуди не «постачається»; він інструмент, а не продукт. Є ще peerDependencies — «мені потрібно, щоб той, хто мене ставить, уже мав ось цей пакет такої версії»; для авторів плагінів, не для типового набору тестів.
Semver і діапазони ^ ~
Версія на кшталт 1.48.0 — це не просто число. Це семантичне версіонування (semantic versioning, semver) у форматі MAJOR.MINOR.PATCH, і кожна частина щось обіцяє:
- MAJOR (
1.x.x→2.0.0) — ламальні зміни, старий код може перестати працювати. - MINOR (
1.48.x→1.49.0) — нова функціональність, зворотно сумісна. - PATCH (
1.48.0→1.48.1) — виправлення без нової поведінки.
Значок перед версією в package.json — це не сама версія, а дозволений діапазон оновлень:
| Запис | Що дозволяє | Розгортається у |
|---|---|---|
1.48.0 | лише цю версію, точно | 1.48.0 |
~1.48.0 | лише патчі | >=1.48.0 <1.49.0 |
^1.48.0 | мінор і патчі | >=1.48.0 <2.0.0 |
^0.2.3 | лише патчі (особливий випадок) | >=0.2.3 <0.3.0 |
* | будь-яку версію | небезпечно |
Тобто ^ (caret) тримається в межах одного мажора, а ~ (tilde) — у межах одного мінора. Пастка, про яку часто не знають: для версій 0.x caret поводиться як tilde й пускає лише патчі, бо до релізу 1.0.0 кожен мінор вважається потенційно ламальним. Практичний наслідок для тестів: ^ на всьому означає, що завтрашній npm install може мовчки підтягнути новий мінор залежності — і твій тест «раптом» почне падати без жодної зміни у твоєму коді.
package-lock.json: чому тести падають лише в CI
Діапазони ^ і ~ зручні, але роблять package.json неточним: два npm install з різницею в тиждень легко приведуть різні фактичні версії. Саме цю дірку закриває package-lock.json (лок-файл).
Лок-файл фіксує повне дерево залежностей: точну версію кожного пакета — і твого прямого, і транзитивного (залежність твоєї залежності), — разом із хешами цілісності (integrity) й адресами, звідки їх узято. Це знімок «що саме встановилося», який роблять один раз і комітять у git.
Навіщо це AQA напряму. Класична ситуація: тест зелений на твоїй машині й червоний у CI. Перша підозра — флак; насправді часто винен лок-файл, який не закомітили або зігнорували, тож CI поставив трохи інші версії. Лок-файл — це і є гарантія, що «те саме дерево» відтвориться скрізь однаково.
npm install проти npm ci
Це майже гарантоване питання на співбесіді, і воно прямо про відтворюваність.
npm install читає package.json, розрулює діапазони, за потреби оновлює package-lock.json, доставляє відсутнє в node_modules, не чіпаючи вже наявного. Це команда для розробки: додати пакет, оновити щось, працювати локально.
npm ci (від clean install) читає лише package-lock.json, вимагає, щоб той існував і був узгоджений із package.json (інакше падає з помилкою, а не «полагодить» тишком), стирає node_modules перед установкою й ставить дерево рівно за локом, ніколи не записуючи лок назад. Вона детермінована й швидша — тому це стандарт для CI.
| Аспект | npm install | npm ci |
|---|---|---|
| Джерело істини | package.json + діапазони | package-lock.json, точно |
| Оновлює лок-файл | Так, за потреби | Ніколи |
| Потребує наявного лок-файлу | Ні | Так (інакше помилка) |
node_modules перед стартом | Доповнює наявний | Стирає й ставить з нуля |
| Розбіжність lock ↔ package.json | Пере‑резолвить | Падає з помилкою |
| Типове застосування | Локальна розробка | CI/CD, відтворювані прогони |
Коротке правило: локально — npm install, у пайплайні (докладніше в розділі про Git і CI/CD) — завжди npm ci. Друга команда і швидша, і не дасть CI-агенту тихцем узяти інші версії.
npx: запуск без глобальної установки
npx — це запуск виконуваного файлу пакета. Спершу він шукає бінарник у локальному node_modules/.bin, а якщо там немає — може тимчасово завантажити пакет, виконати й не лишати його в системі.
Навіщо: не засмічувати машину глобальними установками й гарантовано запускати саме ту версію інструмента, що прописана в проєкті. Типове для AQA:
npx playwright test # запустити тести локальним Playwright
npx playwright install # доставити браузери
npx playwright show-trace # відкрити trace viewer
Нюанс, який плутають: усередині npm-скриптів npx не потрібен — там node_modules/.bin уже в PATH, тож у scripts пишуть playwright test, а npx playwright test набирають руками в терміналі поза скриптами.
Типова структура тестового проєкту
Зібравши все докупи, ось як виглядає кістяк проєкту на Playwright + TypeScript:
e2e-tests/
├── package.json # маніфест: залежності і scripts
├── package-lock.json # лок точних версій (у git)
├── node_modules/ # встановлені залежності (НЕ в git)
├── playwright.config.ts # конфіг ранера
├── tsconfig.json # налаштування TypeScript
├── .gitignore # ігнорує node_modules, звіти, .env
├── tests/ # самі тести
│ └── login.spec.ts
├── pages/ # Page Objects
│ └── LoginPage.ts
└── fixtures/ # тестові дані й кастомні фікстури
└── users.ts
У CodeceptJS замість playwright.config.ts буде codecept.conf.ts і тека pages/ з тими самими Page Object, підключеними через конфіг, — розкладка концептуально та сама.
Ключове: node_modules не комітять у git (вона в .gitignore), а package-lock.json — комітять. Логіка проста: лок-файл — це рецепт, за яким npm ci детерміновано відновить node_modules на будь-якій машині; тримати в репозиторії сотні мегабайтів згенерованих файлів (частина яких ще й скомпільована під конкретну ОС) немає сенсу й шкідливо.
Типові помилки
Cannot use import statement outside a module— виглядає як синтаксична помилка в коді, а насправді неузгодженість module-систем: ESM-синтаксис виконується там, де оточення чекало CommonJS. Лікується не в тесті, а вtsconfig/ранері.Cannot find moduleодразу післяgit pull— виглядає як зламаний код колеги, а насправді хтось додав нову залежність, а ти не переставив пакети. Рецепт:npm install(абоnpm ci).- «Локально зелено, у CI червоно» — виглядає як флак, а насправді часто розбіжність версій: CI зробив
npm installзамістьnpm ciабо лок-файл не закомічений. Детермінований прогін дає лишеnpm ciз актуальним локом. - Закомічений
node_modules— виглядає як «надійно, все зі мною», а насправді роздуває репозиторій і не гарантує відтворюваності: нативні модулі збираються під конкретну ОС, тож чужийnode_modulesможе просто не запуститися. ^на всіх залежностях і без лок-файлу — виглядає як «завжди свіже й добре», а насправді тихий minor-бамп залежності ламає тести без жодної зміни твого коду, і причину шукають годинами не там.privatevspublicплутають ізdependenciesvsdevDependencies— виглядає схоже, а це різні осі: перше про публікацію пакета, друге про те, коли залежність потрібна. У тестовому наборі майже все —devDependencies.
Підсумок
- Модуль — це файл зі своєю областю видимості; назовні видно лише те, що явно
export, і підтягується черезimport. - У Node живуть дві системи модулів: CommonJS (
require, синхронний, динамічний) та ES Modules (import/export, стандарт мови, статичний); у TS-проєкті пишуть ESM, а транспіляцію вирішує ранер іtsconfig. package.jsonописує залежності, версійні діапазони таscripts;^дозволяє мінор+патч,~— лише патч.package-lock.jsonфіксує точне дерево версій і його комітять; він і є гарантія відтворюваності «те саме скрізь».- Локально —
npm install, у CI —npm ci(детерміновано, за локом, зі стираннямnode_modules);npxзапускає інструмент проєкту без глобальної установки.
Що питають на співбесіді
- «Чим CommonJS відрізняється від ES Modules?» — інтерв'юер перевіряє, чи розумієш, що це дві різні системи, а не стилі написання:
requireпротиimport, синхронний/динамічний проти статичного, і що вибір диктують розширення файлу та поле"type". - «Named чи default export — що обереш і чому?» — слухають аргумент про рефакторинг, автодоповнення й фіксовані імена, а не «як звик».
- «У чому різниця
npm installіnpm ci?» — найчастіше питання блоку; сильна відповідь одразу пояснює, чому самеnpm ciу CI: детермінізм, звірка з локом, чистаnode_modules. - «Навіщо
package-lock.jsonі чи комітиш його?» — перевіряють розуміння відтворюваності й уміння пояснити класику «зелено локально, червоно в CI». - «Що таке
^1.2.3?» — очікують точний діапазон (>=1.2.3 <2.0.0) і бонусом — знання про особливий випадок0.x. - «Що робить
npx?» — запускає бінарник пакета з локальногоnode_modules/.bin(або тимчасово завантажує), і чому в npm-скриптах він не потрібен.
Джерела
- Node.js — Modules: CommonJS та ECMAScript modules — офіційний опис обох систем і правил, за якими Node обирає одну з них.
- MDN — JavaScript modules —
import/export, named vs default. - npm Docs — package.json і npm ci — поля маніфесту та поведінка команд.
- semver.org і node-semver — ranges — правила версіонування та точна семантика
^і~.
Ця глава — про мову й інструментарій, а не про процес тестування, тож прямого відповідника в силабусі ISTQB CTFL 4.0 вона не має.
Що таке модуль у JavaScript і навіщо код узагалі ділять на файли?
Модуль (module) — це окремий файл із власною областю видимості: те, що в ньому оголошено, за замовчуванням зовні не видно. Щоб віддати сутність назовні, її треба явно export, а на приймальному боці — import. Без цього механізму весь код жив би в одному глобальному просторі імен, де змінна page з одного тесту мовчки затирала б однойменну з іншого. Для AQA це щоденний інструмент: Page Object описується один раз у файлі, експортується й підтягується в десятки тестів одним рядком, а правка локатора робиться в єдиному місці. По суті це та сама ізоляція й перевикористання, що й інкапсуляція в ООП, тільки на рівні файлів.
Чим CommonJS відрізняється від ES Modules?
Це дві різні системи модулів, а не два стилі одного й того самого. CommonJS (CJS) — історична система Node.js: імпорт через require(), експорт через module.exports; вона синхронна й динамічна, бо require — це звичайний виклик функції під час виконання, тож шлях можна навіть зібрати рядком на льоту. ES Modules (ESM) — стандарт самої мови з ES2015: import / export; вона статична, всі імпорти аналізуються ще до виконання, тому шлях мусить бути літералом. Плата за статичність окупається: інструменти будують граф залежностей наперед і вирізають невикористане (tree-shaking), а ESM підтримує верхньорівневий await. На співбесіді цінують саме розуміння, що це різні механізми з різними правилами резолву, а не питання смаку.
Як Node розуміє, який файл трактувати як CommonJS, а який як ESM?
Вирішує розширення файлу та поле "type" у найближчому package.json. Файл .mjs завжди ESM, .cjs завжди CommonJS — тут двозначності немає. Файл .js трактується за полем "type": без нього діє історичний дефолт CommonJS, а з "type": "module" той самий .js стає ESM. TypeScript-файли .ts Node напряму не виконує — що з них вийде, вирішує компілятор чи ранер за налаштуваннями tsconfig. Практичний наслідок: помилку про модульну систему майже ніколи не правлять у самому тесті — вона про неузгодженість між кодом і оточенням, а не про синтаксис.
Named export чи default export — що обереш у тестовому коді і чому?
У більшості випадків named, і аргумент тут інженерний, а не «як звик». Named export віддає сутність під конкретним іменем, і під тим самим іменем її імпортують у фігурних дужках; default — один на модуль, імпортується без дужок і під будь-яким іменем. Через фіксоване ім'я named краще дружить з рефакторингом і автодоповненням: один клас не назветься LoginPage в одному файлі й Login у сусідньому, а перейменування підхоплюється по всьому проєкту. Named-імпорт за потреби перейменовують через as, а кілька named-експортів зручно збирати у barrel-файл. Не випадково сам Playwright віддає API саме named-експортами — import { test, expect } from '@playwright/test'.
Що таке barrel-файл і навіщо він потрібен?
Barrel-файл (зазвичай index.ts) — це модуль, який нічого не оголошує сам, а лише реекспортує сутності з інших файлів теки, наприклад export * from './LoginPage'. Сенс — дати одну «вхідну точку» до групи модулів: замість кількох імпортів з різних шляхів споживач бере все з однієї теки. Для набору Page Object це означає, що тести імпортують сторінки з pages, не знаючи внутрішньої розкладки файлів, і при перенесенні файлу правиться лише barrel. Мінус — надто великий barrel може тягнути в граф зайве й шкодити tree-shaking, тож у бібліотеках його застосовують обачно.
Які поля package.json важливі для тестового проєкту?
package.json — це паспорт проєкту: name і version ідентифікують його, "type" задає модульну систему для .js, scripts описує команди запуску, а блоки залежностей перелічують, що і якої версії потрібно. Для набору тестів найчастіше живуть devDependencies — Playwright, TypeScript, @types/* — бо самі тести нікуди не постачаються. scripts перетворює довгі команди на короткі імена, які запускають через npm run. Це той файл, який відкривають першим, коли хочуть зрозуміти, як зібрати й прогнати чужий проєкт.
Що таке scripts і чому npm test працює, а npm smoke — ні?
scripts у package.json — це словник іменованих команд: ліворуч ім'я, праворуч те, що виконається. Запускають їх через npm run <ім'я>, наприклад npm run test:smoke. Кілька зарезервованих імен — test, start, stop — мають скорочення без run (npm test), а всі інші, як-от smoke чи report, працюють лише з повним npm run. Усередині скриптів тека node_modules/.bin уже додана в PATH, тому там пишуть просто playwright test, без npx. Це зручно й для читабельності CI-конфігу: одна назва інкапсулює довгий рядок прапорців.
Чим dependencies відрізняються від devDependencies і де в тестовому наборі що?
dependencies — те, без чого продукт не працює в проді; devDependencies — те, що потрібне лише під час розробки й тестування. У тестовому проєкті майже все потрапляє в devDependencies, бо набір тестів — це інструмент, а не продукт, який кудись «постачається»: Playwright, TypeScript, типи @types/node нікому не їдуть у рантайм застосунку. Є ще peerDependencies — вимога до того, хто ставить пакет, уже мати певну залежність потрібної версії; вона потрібна авторам плагінів, а не типовому набору тестів. Плутати цю вісь із private/публічністю пакета не варто: це різні речі — одна про момент, коли залежність потрібна, інша про те, чи публікується пакет.
Що таке semver і що обіцяє кожна частина у 1.48.0?
Semver (semantic versioning) — домовленість про сенс номера у форматі MAJOR.MINOR.PATCH, де кожна частина щось гарантує споживачеві. Зростання MAJOR (1.x.x → 2.0.0) сигналить про ламальні зміни: старий код може перестати працювати. MINOR (1.48.x → 1.49.0) додає нову функціональність, зберігаючи зворотну сумісність. PATCH (1.48.0 → 1.48.1) — виправлення без зміни поведінки. Сенс домовленості в тому, що за номером можна передбачити ризик оновлення, не читаючи весь changelog; на цьому й будуються версійні діапазони в package.json.
Що означає запис ^1.48.0 у package.json і чим він відрізняється від ~1.48.0?
Значок перед версією — це не сама версія, а дозволений діапазон оновлень. Caret ^1.48.0 дозволяє мінор і патч у межах одного мажора, тобто розгортається в >=1.48.0 <2.0.0. Tilde ~1.48.0 вужчий — лише патчі в межах одного мінора, тобто >=1.48.0 <1.49.0. Практичний наслідок для тестів: ^ на всіх залежностях означає, що завтрашня установка може мовчки підтягнути свіжий мінор, і тест «раптом» почервоніє без жодної зміни у твоєму коді. Саме тому одного package.json для відтворюваності замало — потрібен лок-файл, що фіксує конкретику.
Чому для версій 0.x caret поводиться інакше, ніж зазвичай?
Для 0.x caret звужується до поведінки tilde: ^0.2.3 пускає лише патчі й розгортається в >=0.2.3 <0.3.0, а не до наступного мажора. Логіка в тому, що за semver реліз 0.x вважається ще нестабільним, тож кожен мінор до 1.0.0 трактується як потенційно ламальний — і caret обережно тримається в межах поточного мінора. Це класична пастка: інженер очікує, що ^0.2.3 пустить 0.3.0, а він не пускає. На співбесіді знання цього окремого випадку — бонус, що відрізняє поверхневе розуміння діапазонів від справжнього.
Навіщо потрібен package-lock.json і чи треба його комітити?
Комітити треба обов'язково — це і є гарантія відтворюваності. Діапазони ^ і ~ роблять package.json неточним: дві установки з різницею в тиждень легко дадуть різні фактичні версії. Лок-файл закриває цю дірку, фіксуючи повне дерево залежностей — точну версію кожного пакета, і прямого, і транзитивного (залежності залежності), — разом із хешами цілісності й адресами джерел. Це знімок «що саме встановилося», який роблять один раз і кладуть у git. Без нього виникає класика «локально зелено, у CI червоно», де винна не логіка тесту, а те, що агент поставив трохи інші версії.
У чому різниця між npm install і npm ci?
Це майже гарантоване питання, і воно прямо про детермінізм. npm install орієнтується на package.json, розрулює діапазони, за потреби оновлює лок-файл і доставляє відсутнє в node_modules, не чіпаючи вже наявного — це команда для розробки. npm ci (clean install) читає лише package-lock.json, вимагає його наявності й узгодженості з package.json (інакше падає з помилкою, а не «лагодить» тишком), стирає node_modules перед установкою й ставить дерево рівно за локом, ніколи не записуючи лок назад. Через це npm ci детермінований і швидший — тому це стандарт для CI. Коротке правило: локально install, у пайплайні ci.
Що робить npx і чому в npm-скриптах він не потрібен?
npx запускає виконуваний файл (бінарник) пакета: спершу шукає його в локальному node_modules/.bin, а якщо не знаходить — може тимчасово завантажити пакет, виконати й не лишати в системі. Сенс — не засмічувати машину глобальними установками й гарантовано запускати саме ту версію інструмента, що прописана в проєкті, наприклад npx playwright test чи npx playwright install. Усередині scripts він зайвий, бо node_modules/.bin там уже в PATH — тому в маніфесті пишуть просто playwright test, а npx набирають руками в терміналі поза скриптами. Плутанина зазвичай саме тут: люди дублюють npx у скриптах, де він нічого не додає.
Тест зелений локально, а в CI червоний. Як модулі й npm можуть бути причиною?
Перша підозра новачка — флак, але дуже часто винне оточення, а не логіка тесту. Найтиповіший сценарій: лок-файл не закомічений або зігнорований, CI зробив npm install замість npm ci — і поставив трохи інші версії залежностей, на яких поведінка змінилась. Другий варіант — неузгодженість модульних систем: код на ESM запускається там, де ранер чекав CommonJS, і падає ще до тестової логіки. Діагностика йде від оточення до коду: чи закомічений лок, чи стоїть у CI npm ci, чи збігаються версії Node і залежностей. Хто розуміє цей рівень, той не витрачає півдня, «полюючи за примарою» у власному коді тесту.
Помилка «Cannot use import statement outside a module» — де її насправді лікують?
Не в самому тесті — це не синтаксична помилка, а неузгодженість модульних систем. Вона означає, що ESM-синтаксис (import/export) виконується в оточенні, яке очікувало CommonJS: розширення файлу, відсутнє "type": "module" або поле module у tsconfig не збігаються з тим, як написаний код. Тому шукати причину треба в конфігурації — tsconfig.json, ранер, поле "type" у package.json, — а не переписувати імпорти в тесті. Це гарний приклад загального принципу: повідомлення виглядає як помилка коду, а насправді вказує на розбіжність між кодом і середовищем виконання.
Чому node_modules не комітять у git, а package-lock.json — комітять?
Бо лок-файл — це компактний рецепт, а node_modules — громіздкий і непереносний результат. package-lock.json фіксує точні версії й хеші, за якими npm ci детерміновано відновить node_modules на будь-якій машині, тож його тримають у репозиторії. Саму теку node_modules кладуть у .gitignore: це сотні мегабайтів згенерованих файлів, частина яких ще й скомпільована під конкретну ОС — нативні модулі, зібрані на macOS, можуть просто не запуститися на Linux-агенті. Тому закомічений node_modules не додає надійності, а лише роздуває репозиторій і створює хибне відчуття відтворюваності. Відтворюваність дає саме пара «закомічений лок + npm ci».
Три кейси, де модулі й npm вирішують, зелений тест чи «полювання за примарою»: читання чужого package.json рядок за рядком і рішення за версійними діапазонами, діагностика «локально зелено, у CI червоно» через лок-файл, і практичний розклад named-експортів із barrel-файлом у наборі Page Object.
Кейс 1. Читаємо package.json тестового проєкту
Ти клонував незнайомий репозиторій. Перше, що дає цілісну картину, — маніфест. Розберемо його по полях, бо кожен рядок відповідає на конкретне питання «як це зібрати й прогнати».
{
"name": "e2e-tests",
"version": "1.0.0",
"type": "module",
"scripts": {
"test": "playwright test",
"test:smoke": "playwright test --grep @smoke",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"typescript": "~5.6.0",
"@types/node": "^22.7.0"
}
}
Що читати і чому:
"type": "module"— усі файли.jsу цьому проєкті трактуються як ESM. Тобто якщо десь виконається CommonJS-код без явного.cjs, він упаде на модульній системі, а не на логіці.scripts—npm testзапуститься скорочено, а отtest:smokeіreportтільки черезnpm run test:smoke/npm run report. Усередині цих рядків немаєnpx, боnode_modules/.binуже вPATH.- Усе в
devDependencies— сигнал, що це набір тестів, а не продукт: Playwright, TypeScript і типи потрібні для розробки й прогону, нікуди не «постачаються». Порожнійdependenciesтут — норма, а не недогляд. - Значки перед версіями — це діапазони, а не версії.
^1.48.0пустить мінор і патч у межах1.x(від 1.48.0),~5.6.0— лише патчі5.6.x. Що саме встановиться, вирішує не цей файл, а лок.
Швидка таблиця, як читати діапазон і що він реально дозволяє:
| Запис | Дозволяє | Розгортається у |
|---|---|---|
1.48.0 | тільки цю версію | 1.48.0 |
~1.48.0 | лише патчі | >=1.48.0 <1.49.0 |
^1.48.0 | мінор і патч | >=1.48.0 <2.0.0 |
^0.2.3 | лише патчі (нульовий мажор) | >=0.2.3 <0.3.0 |
* | будь-яку | небезпечно |
Практичний висновок: ^ на всіх залежностях без закоміченого лок-файлу — це запрошення до тихого minor-бампу, після якого тест червоніє «сам по собі». Побачив таке в чужому проєкті — перевір, чи є поруч package-lock.json у git.
Кейс 2. «Локально зелено, у CI червоно»: винен лок, а не логіка
Класична картина: у себе на машині весь набір зелений, а пайплайн стабільно валить кілька тестів у тому самому місці. Перша підозра новачка — флак; насправді часто винна розбіжність фактичних версій залежностей.
Швидкий чек-лист діагностики, від оточення до коду:
# 1) Чи закомічений лок-файл узагалі?
git ls-files package-lock.json # порожньо = його немає в git, це і є діра
# 2) Що робить CI — install чи ci?
grep -R "npm install\|npm ci" .github/ # у пайплайні має бути npm ci, не install
# 3) Локально відтворюємо СЕРЕДОВИЩЕ CI, а не свій node_modules
rm -rf node_modules
npm ci # ставить рівно за локом; впаде, якщо лок неузгоджений
npm test
Що дивитися і чому:
npm ciпадає з помилкою про розбіжність — це діагноз, а не перешкода. Він вимагає, щоб лок був узгоджений ізpackage.json; якщо ні — значить, хтось правив маніфест і не оновив лок. Саме цю неузгодженість CI і ловить, аnpm installмовчки «полагодив» би її іншими версіями.npm installу пайплайні — червоний прапор. Він розрулює діапазони заново, тож агент легко візьме свіжий мінор, якого не було в тебе локально. Заміна наnpm ciчасто прибирає «флак» одним рядком.- Незакомічений лок = кожна машина ставить своє. Локально в тебе давнє дерево, на чистому агенті — найновіше в межах
^. Рецепт: закомітитиpackage-lock.jsonі скрізь ставити черезnpm ci. - Відтворюй CI локально стиранням
node_modules. Поки ти ганяєш тести по своєму давньомуnode_modules, різницю не побачиш.rm -rf node_modules && npm ciставить те саме дерево, що й агент, — і червоне відтворюється у тебе.
Кейс 3. Named-експорти й barrel-файл у наборі Page Object
Тут видно, чому named-експорти дружніші до тестового коду. Опишемо сторінку named-експортом класу, зберемо теку через barrel-файл і підтягнемо в тест однією точкою входу.
// pages/LoginPage.ts — named export класу (не default)
import { Page, expect } from '@playwright/test';
export class LoginPage {
constructor(private readonly page: Page) {}
async openLoginForm(): Promise<void> {
await this.page.goto('/login');
}
async clickOnSubmit(email: string, password: string): Promise<void> {
await this.page.getByTestId('email').fill(email);
await this.page.getByTestId('password').fill(password);
await this.page.getByRole('button', { name: 'Sign in' }).click();
}
async checkThatErrorIsVisible(text: string): Promise<void> {
await expect(this.page.getByRole('alert')).toHaveText(text);
}
}
// pages/index.ts — barrel: сам нічого не оголошує, лише реекспортує
export * from './LoginPage';
export * from './DashboardPage';
// tests/login.spec.ts — імпорт з однієї точки входу, а не з кожного файлу
import { test } from '@playwright/test';
import { LoginPage, DashboardPage } from '../pages';
test('невірний пароль показує помилку', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.openLoginForm();
await loginPage.clickOnSubmit('qa@example.com', 'wrong');
await loginPage.checkThatErrorIsVisible('Invalid credentials');
});
Що дивитися і чому:
- Named-експорт тримає ім'я фіксованим.
LoginPageне перетвориться наLoginу сусідньому файлі, тож автодоповнення й перейменування по проєкту працюють точно. З default-експортом кожен імпорт міг би назвати клас по-своєму — і рефакторинг це не підхопить. - Barrel-файл ховає розкладку тек. Тест бере сторінки з
../pages, не знаючи, у якому файлі кожна лежить; перенесенняLoginPage.tsв іншу підтеку правиться лише вindex.ts, а не в десятках тестів. - Імпорт із самого Playwright — теж named.
import { test } from '@playwright/test'— не випадковість: бібліотека свідомо віддає API named-експортами саме заради стабільних імен і автодоповнення. - Не зловживай barrel'ом. Надто великий реекспорт тягне в граф зайве й шкодить tree-shaking; для набору Page Object це прийнятно, але в бібліотеці, що публікується, barrel застосовують обачно.
Модулі та import/export
- Можу пояснити, що модуль — це файл із власною областю видимості, і назовні видно лише те, що явно
export. - Знаю різницю між named і default export: named — під фіксованим іменем у фігурних дужках, default — один на модуль і під будь-яким іменем.
- Розумію, чому в тестовому коді зазвичай віддають перевагу named (рефакторинг, автодоповнення, фіксовані імена), і що Playwright віддає API саме так.
- Можу перейменувати named-імпорт через
asі знаю, навіщо потрібен barrel-файл (index.ts, що реекспортує решту).
CommonJS проти ES Modules
- Знаю, що це дві РІЗНІ системи модулів, а не стилі:
require/module.exportsпротиimport/export. - Можу пояснити, що CommonJS синхронний і динамічний (шлях можна зібрати рядком), а ESM статичний (шлях — літерал, зате можливий tree-shaking і top-level await).
- Розумію, як Node обирає систему:
.mjsзавжди ESM,.cjsзавжди CJS,.js— за полем"type", а.tsвирішує компілятор/ранер.
package.json і scripts
- Розумію, що
package.json— маніфест проєкту:name,version,"type",scripts, блоки залежностей. - Знаю різницю
dependenciesvsdevDependenciesі чому в тестовому наборі майже все —devDependencies(плюс що такеpeerDependencies). - Розумію, чому
npm testпрацює скорочено, а власне ім'я запускають лише черезnpm run <ім'я>, і що всередині скриптівnode_modules/.binуже вPATH.
Semver і діапазони версій
- Можу розкласти версію на
MAJOR.MINOR.PATCHі сказати, що обіцяє зростання кожної частини. - Знаю різницю
^vs~: caret пускає мінор+патч (>=1.48.0 <2.0.0), tilde — лише патч (>=1.48.0 <1.49.0). - Пам'ятаю особливий випадок
^0.x: для нульового мажора caret звужується до патчів, як tilde. - Розумію практичний ризик
^на всьому: тихий minor-бамп залежності ламає тести без зміни мого коду.
Лок-файл і відтворюваність
- Можу пояснити, чому
^/~роблятьpackage.jsonнеточним і що саме цю дірку закриваєpackage-lock.json. - Знаю, що лок фіксує повне дерево — точні версії прямих і транзитивних залежностей плюс хеші цілісності.
- Знаю різницю
npm installvsnpm ci: перший оновлює лок і доповнюєnode_modules(розробка), другий читає лише лок, стираєnode_modules, падає при розбіжності й тому детермінований (CI).
npx і структура проєкту
- Можу пояснити, що
npxзапускає бінарник ізnode_modules/.bin(або тимчасово тягне пакет) і чому вscriptsвін зайвий. - Знаю, що
package-lock.jsonкомітять, аnode_modules— ні, і чому (рецепт проти громіздкого нативного результату), плюс звідки береться «локально зелено, у CI червоно». - Можу з голови накидати кістяк проєкту:
package.json, лок,node_modules(в ignore), конфіг ранера,tsconfig, текиtests/,pages/,fixtures/.
Що таке модуль у JavaScript?
Питання
Що таке модуль (module) у JavaScript?