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

    08 · JavaScript/TypeScript для AQA

    tsconfig і TypeScript у тестовому проєкті

    Зміст

    Ти написав тест, у ньому явна помилка типів — редактор підсвітив червоним, — а npx playwright test спокійно запускає його й проходить зелено. Або навпаки: код виглядає ідеально, але CI падає на кроці tsc з десятком error TS2345, яких у тебе на екрані не було. Обидві ситуації — не магія й не баг інструмента. Це наслідок того, як TypeScript влаштований у тестовому проєкті: типи й виконання живуть окремо, а керує ними файл, у який мало хто заглядає, — tsconfig.json.

    Для AQA це не академічна тема, а питання довіри до власної сюїти. Якщо ранер ігнорує типи, то TypeScript у тестах — лише підказки редактора, а не гарантія; справжня перевірка вмикається окремою командою, і якщо її немає в пайплайні, половина користі від TS просто не працює. Ця глава — про те, хто читає tsconfig, чому їх насправді кілька споживачів, і як зробити так, щоб «зелений тест» і «код без помилок типів» означали одне й те саме.

    Що таке tsconfig.json і хто його читає

    tsconfig.json — це конфігурація компілятора TypeScript. Його наявність у корені папки означає одне: «це коренева тека TypeScript-проєкту, і ось за якими правилами тут трактується код». Правила стосуються двох речей — як перевіряти типи і як перетворювати (транспілювати) TS-синтаксис у звичайний JavaScript, який реально виконає рушій.

    Ключове, що плутає новачків: tsconfig.json читає не один інструмент, а щонайменше троє, і роблять вони з нього різні висновки.

    Твій .ts-файл тесту

    Редактор / tsserver у VS Code

    Ранер: Playwright, tsx, ts-node

    tsc --noEmit

    Показує помилки типів
    червоним. Код не запускає

    Зрізає типи, ВИКОНУЄ код.
    Помилки типів ігнорує

    Повна перевірка типів.
    Падає на помилці. Код не запускає

    Твій .ts-файл тесту

    Редактор / tsserver у VS Code

    Ранер: Playwright, tsx, ts-node

    tsc --noEmit

    Показує помилки типів
    червоним. Код не запускає

    Зрізає типи, ВИКОНУЄ код.
    Помилки типів ігнорує

    Повна перевірка типів.
    Падає на помилці. Код не запускає

    Один і той самий файл, той самий tsconfig — три різні поведінки. Редактор (через мовний сервіс tsserver) показує тобі червоні підкреслення, але нічого не запускає. Ранер бере файл, викидає з нього анотації типів і виконує результат — типи його не цікавлять взагалі. І тільки третій споживач, tsc --noEmit, робить те, заради чого TypeScript існує: проганяє повну перевірку типів і повертає ненульовий код виходу, якщо щось не сходиться. Уся плутанина «редактор червоний, а тест зелений» — це просто різниця між першим і другим споживачем. Решта глави — про те, як їх примирити.

    Ключові опції: strict, target, module, paths

    tsconfig.json має десятки опцій, більшість — у секції compilerOptions. Для тестового проєкту критичних кілька.

    strict — головний перемикач суворості. Це не одна опція, а сімейство: strict: true вмикає одразу strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization та інші. Саме strictNullChecks змушує TS розрізняти User і User | null — і ловить звернення до можливо відсутнього значення ще до запуску. Без strict TypeScript деградує до «JavaScript із коментарями»: any розповзається всюди, і перевірка майже нічого не гарантує. Детальніше про сам суворий режим — у главі «TypeScript: типи, інтерфейси та строгий режим». Для нового тестового проєкту strict: true — розумний дефолт.

    target — версія ECMAScript, до якої TS зводить синтаксис (наприклад, ES2021, ES2022, ESNext). Впливає на те, які сучасні конструкції транспілюються в старіші, а які лишаються як є, і який набір вбудованих типів (lib) доступний. У тестовому проєкті код виконує Node.js, а не старий браузер, тож target ставлять під версію свого Node — немає сенсу зводити код до стародавнього ES, який Node і так розуміє нативно. Покладатися на дефолт не варто: він історично дуже консервативний.

    module — яку модульну систему отримає згенерований код: commonjs, esnext, nodenext. Це визначає, у що перетворяться твої import/export — у require(...) чи в нативні ES-модулі. Тема CommonJS проти ES Modules — окрема й підступна; вона розібрана в главі «Модулі та npm: import/export, package.json». Поруч часто йде moduleResolution (як TS шукає модулі за їхніми іменами) зі значеннями node16, nodenext чи bundler.

    paths — псевдоніми шляхів. Замість ../../../pages/login пишеш @pages/login, а в конфізі мапиш @pages/* на src/pages/* (зазвичай разом із baseUrl). Зручно — але тут схована класична пастка, якій присвячено окремий блок нижче.

    Ще кілька опцій, без яких тестовий проєкт зазвичай не живе:

    ОпціяНавіщо в тестах
    esModuleInteropДозволяє import x from 'cjs-lib' для CommonJS-бібліотек без синтаксичних танців
    skipLibCheckПропускає перевірку типів усередині .d.ts залежностей — помітно швидший tsc, менше чужого шуму
    typesОбмежує, які глобальні @types-пакети підхоплюються (напр., лишити тільки node)
    isolatedModulesЗабороняє конструкції, які не можна транспілювати пофайлово — узгоджує проєкт із швидкими транспіляторами (esbuild, swc)
    noEmitНе генерувати вихідні .js — режим «тільки перевірка»

    Пастка paths: аліас, який працює в редакторі й ламається в рантаймі

    paths — це виключно compile-time-функція TypeScript. Компілятор і редактор розуміють @pages/login і резолвлять його для перевірки типів. Але при транспіляції TS не переписує ці аліаси у згенерованому коді — у виводі лишається буквально @pages/login. А Node.js такого шляху не знає: для нього це не relative-шлях і не пакет у node_modules, тож у рантаймі — Cannot find module '@pages/login'.

    Виглядає як «TypeScript зламався», а насправді — аліаси paths самі собою не працюють під час виконання; їх має розв'язати ще хтось: бандлер, окремий рантайм-резолвер (tsconfig-paths) або відповідний loader у tsx/ts-node. Playwright, наприклад, поважає paths зі свого tsconfig при запуску тестів; довільний скрипт через голий node — ні. Тому перш ніж заводити красиві аліаси, переконайся, що твій спосіб запуску вміє їх розв'язувати.

    Чому ранер виконує TS без перевірки типів

    Ось центральний факт цієї глави. Більшість сучасних інструментів, що запускають TS-тести, роблять транспіляцію без перевірки типів (transpile-only). Технічно прибрати з файлу анотації типів — це майже суто синтаксична операція: пройтися по файлу й викинути : string, interface, as User. Її можна зробити швидко й пофайлово, не будуючи всю карту типів проєкту. А от повна перевірка типів — операція дорога: щоб зрозуміти, чи сумісні два типи, компілятор має розгорнути весь граф типів, дженерики, імпорти. Тому інструменти, заточені на швидкість запуску, свідомо цей крок пропускають.

    Хто саме працює в transpile-only:

    • Playwright Test має вбудовану підтримку TS: він транспілює тести на льоту (під капотом Babel із пресетом @babel/preset-typescript) і типи не перевіряє. Тест із помилкою типів запуститься й може пройти.
    • tsx побудований на esbuild і принципово не робить перевірки типів ніколи.
    • ts-node у типовому режимі типи перевіряє (використовує сам компілятор TS), але його часто вмикають у transpileOnly або через swc заради швидкості — і тоді перевірка теж зникає.
    • CodeceptJS зазвичай реєструє ts-node для запуску .ts — тобто успадковує його налаштування.

    Практичний висновок жорсткий: не покладайся на ранер як на гарантію типів. Зелений прогін не означає, що код типобезпечний, — він означає, що код синтаксично коректний і його логіка спрацювала. Помилка типів могла просто поїхати в рантайм разом зі зрізаними анотаціями.

    Саме тут потрібен tsc --noEmit. Це запуск повного компілятора TypeScript у режимі «нічого не генеруй, лише перевір»:

    npx tsc --noEmit

    tsc прочитає tsconfig.json, збере весь проєкт, прожене повну перевірку типів і поверне ненульовий код виходу при першій же помилці — а завдяки --noEmit не насмітить купою .js-файлів поряд із тестами. Це та сама перевірка, що дає редактор, але для всього проєкту одразу й придатна для автоматизації.

    Твій TS-код

    Ранер: transpile-only
    швидко ВИКОНАТИ

    tsc --noEmit
    повільно ПЕРЕВІРИТИ

    Тест пройшов чи впав
    по логіці

    Типи сходяться
    чи ні

    Обидва потрібні: разом дають
    і робочий, і типобезпечний код

    Твій TS-код

    Ранер: transpile-only
    швидко ВИКОНАТИ

    tsc --noEmit
    повільно ПЕРЕВІРИТИ

    Тест пройшов чи впав
    по логіці

    Типи сходяться
    чи ні

    Обидва потрібні: разом дають
    і робочий, і типобезпечний код

    Через це tsc --noEmit кладуть окремим npm-скриптом (часто його звуть typecheck) і роблять обов'язковим кроком у CI — поруч із самим прогоном тестів, а не замість нього. Один запускає код, інший стереже типи; жоден не покриває роботу другого.

    @types/*: звідки беруться декларації

    TypeScript перевіряє типи не лише твого коду, а й усього, що ти імпортуєш. Щоб знати сигнатуру expect чи page.click, йому потрібні декларації типів — файли .d.ts. Є два шляхи, звідки вони беруться.

    Сучасні бібліотеки постачають типи прямо в собі: Playwright, Axios, Zod уже містять свої .d.ts у пакеті, тож import — і все типізовано, нічого доставляти не треба. Але чимало пакетів (особливо старіших чи спершу написаних на чистому JS) типів у собі не мають. Для них існує DefinitelyTyped — величезний спільнотний репозиторій декларацій, які публікуються в npm під скоупом @types. Тому й з'являються @types/node, @types/lodash тощо: сам пакет — окремо, його типи — окремо, у devDependencies.

    Для тестового проєкту на Node майже завжди обов'язковий один пакет — @types/node. Він типізує середовище Node: process, fs, path, Buffer, __dirname, таймери. Якщо його немає, TS не знає, що таке process, і видасть Cannot find name 'process' — хоча код чудово виконається, бо в рантаймі Node усе це є.

    За замовчуванням TypeScript автоматично підхоплює всі @types-пакети з node_modules/@types як глобальні. Опція types у tsconfig дозволяє звузити цей список, якщо чужі глобальні типи починають конфліктувати.

    ts-node і tsx: запуск TS напряму

    Node.js сам по собі виконує JavaScript, не TypeScript. Щоб запустити .ts-файл без окремого кроку компіляції в .js, потрібен посередник, який компілює на льоту.

    ts-node — класичний варіант: реєструє хук у Node, який компілює TS у пам'яті перед виконанням. У типовому режимі перевіряє типи (тому може бути повільним на великих проєктах); має transpileOnly для швидкості.

    tsx — новіша й швидша альтернатива на esbuild: транспілює миттєво, типи не перевіряє ніколи, дружніша до ES-модулів. Запуск простий:

    npx tsx scripts/seed.ts

    Де це реально треба в тестовому проєкті? Не для самих тестів — Playwright і Jest мають власну транспіляцію й ts-node/tsx для запуску тестів не потребують. А от для допоміжних скриптів на TS — засіяти тестові дані, дернути API, згенерувати фікстури — зручно мати можливість запустити .ts-файл однією командою через tsx, не заводячи окремий білд. Правило вибору просте: якщо потрібна швидкість і не потрібна перевірка типів у момент запуску — tsx; якщо історично проєкт на ts-node (як типовий CodeceptJS) — лишаєш ts-node. Перевірку типів у будь-якому разі забезпечує окремий tsc --noEmit, а не рантайм.

    Типові помилки компіляції та як їх читати

    Повідомлення tsc мають сталий формат:

    tests/login.spec.ts(42,7): error TS2345: Argument of type 'string | null'
    is not assignable to parameter of type 'string'.

    Читається зліва направо: файл(рядок,колонка) — куди стрибати; TSxxxx — код помилки, за яким її легко загуглити й зрозуміти категорію; далі — суть неспівпадіння. У фразах «is not assignable» напрямок важливий: TS каже, що перше не влазить у друге, а не навпаки.

    Найчастіші коди й що вони насправді означають:

    • TS2307: Cannot find module 'X' or its corresponding type declarations — виглядає як «пакет не встановлено», а насправді причин три: або справді немає пакета, або пакет є, але без типів і не встановлено @types/X, або поламаний paths/moduleResolution. Спочатку перевір, чи є @types, і лише потім підозрюй сам імпорт.
    • TS2339: Property 'X' does not exist on type 'Y' — виглядає як «в об'єкті немає поля», а насправді часто це друкарська помилка в назві або занадто вузький тип (TS вивів не той тип, який ти уявляєш). Наведись у редакторі й подивись, який тип TS насправді бачить.
    • TS7006: Parameter 'x' implicitly has an 'any' type — це не про твою логіку, а про noImplicitAny зі strict: параметр без анотації. Лікується анотацією типу, а не вимиканням суворості.
    • TS2531 / TS18047 / TS18048: Object is possibly 'null'/'undefined' — прямий наслідок strictNullChecks. Виглядає як прискіпування, а насправді TS показує реальну діру: значення може бути відсутнім, і в рантаймі ти отримав би Cannot read properties of null. Це саме той баг, який TS ловить наперед.
    • TS2304: Cannot find name 'process' (або describe, expect) — виглядає як помилка коду, а насправді бракує декларацій оточення: для process@types/node, для глобальних describe/test — правильно підключений тестовий фреймворк у types/lib.

    Головне при читанні помилок TS: код (TSxxxx) називає категорію, повідомлення — конкретне неспівпадіння, а координати ведуть до місця. Не поспішай гасити помилку через any чи @ts-ignore — здебільшого вона вказує на справжню невідповідність, яку інакше довелося б ловити впав-тестом. Про те, як читати вже рантаймові стек-трейси (а не помилки компіляції), — окрема глава «Помилки та винятки: try/catch і стек-трейси».

    Підсумок

    • tsconfig.json читають кілька споживачів по-різному: редактор і tsc перевіряють типи, а ранер їх лише зрізає й виконує код. «Редактор червоний, тест зелений» — це не суперечність, а ця різниця.
    • Ранери (Playwright, tsx, часто ts-node) працюють у transpile-only: зелений прогін не гарантує типобезпеки. Гарантію дає окремий tsc --noEmit, і його місце — у CI поруч із тестами, а не замість них.
    • strict: true — базовий дефолт; без нього TypeScript майже нічого не перевіряє. target/module ставлять під своє середовище Node, а не на дефолт.
    • paths — compile-time-аліаси; у рантаймі їх мусить розв'язувати бандлер, tsconfig-paths або loader ранера, інакше буде Cannot find module.
    • Типи залежностей беруться або з самого пакета, або з @types/* (DefinitelyTyped); @types/node у Node-проєкті потрібен майже завжди.

    Що питають на співбесіді

    • «У вас тести на TypeScript — коли перевіряються типи?» Інтерв'юер перевіряє, чи розумієш ти, що ранер типи не перевіряє. Сильна відповідь: ранер транспілює й виконує без перевірки, а типи стереже окремий tsc --noEmit у пайплайні.
    • «Навіщо tsc --noEmit, якщо тести й так проходять?» Дивляться, чи не плутаєш ти «код виконався» з «типи коректні». Суть: --noEmit — це чиста перевірка типів без генерації файлів; вона ловить те, що ранер пропускає.
    • «Що робить strict і чому його вмикають?» Очікують, що назвеш кілька прапорців сімейства (strictNullChecks, noImplicitAny) і поясниш, що без нього any знецінює перевірку.
    • «Що таке @types/node і навіщо він?» Перевірка базового розуміння, звідки TS бере декларації для бібліотек без власних типів.
    • «Налаштував paths, а Node каже Cannot find module — чому?» Класична пастка: аліаси compile-time, у рантаймі їх ніхто не переписав. Дивляться, чи розрізняєш ти перевірку типів і виконання.

    Джерела