vyvchy
    Теми розділу

    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.jsonCommonJS (історичний дефолт)
    .js + "type": "module" у package.jsonESM
    .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.x2.0.0) — ламальні зміни, старий код може перестати працювати.
    • MINOR (1.48.x1.49.0) — нова функціональність, зворотно сумісна.
    • PATCH (1.48.01.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: стерти й поставити рівно за локом

    package.json — діапазони ^ ~ (неточно)

    Резолвер обирає конкретні версії

    package-lock.json — точні версії та хеші

    node_modules заповнюється

    node_modules, ідентичний скрізь

    npm install

    npm ci: стерти й поставити рівно за локом

    package.json — діапазони ^ ~ (неточно)

    Резолвер обирає конкретні версії

    package-lock.json — точні версії та хеші

    node_modules заповнюється

    node_modules, ідентичний скрізь

    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 installnpm 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-бамп залежності ламає тести без жодної зміни твого коду, і причину шукають годинами не там.
    • private vs public плутають із dependencies vs devDependencies — виглядає схоже, а це різні осі: перше про публікацію пакета, друге про те, коли залежність потрібна. У тестовому наборі майже все — 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-скриптах він не потрібен.

    Джерела

    Ця глава — про мову й інструментарій, а не про процес тестування, тож прямого відповідника в силабусі ISTQB CTFL 4.0 вона не має.