08 · JavaScript/TypeScript для AQA
Помилки та винятки: try/catch і стек-трейси
Зміст
Червоний тест — це ще не інформація. Червоний тест буває двох принципово різних сортів: або перевірка не зійшлася (продукт повівся не так, як ти очікував), або сам тестовий код упав з винятком (locator виявився undefined, ти звернувся до поля неіснуючого об'єкта, забув await). Плутати ці два випадки — типова помилка джуна, яка коштує годин: людина заводить баг на продукт, а насправді зламаний її власний тест. Щоб розрізняти їх з першого погляду, треба розуміти, як у JavaScript влаштовані винятки: що таке throw, що ловить try/catch, чому в асинхронному коді він раптом перестає ловити, і як читати стек-трейс (stack trace), щоб за півхвилини знайти рядок-винуватець.
Друга причина, чому AQA це болить, — стабільність прогону. Одна неопрацьована помилка в асинхронному колбеку здатна завалити не той тест, у якому вона сталася, а наступний — або взагалі обірвати весь ран. Розберемося, чому так і як цього не допустити.
throw і об'єкт Error
Виняток (exception) у JavaScript породжує оператор throw. Він негайно зупиняє поточну функцію й починає «розкрутку стека» (stack unwinding): керування вилітає вгору по ланцюжку викликів, доки не натрапить на try/catch, який його впіймає. Якщо не натрапить ніде — програма аварійно завершується.
Кинути технічно можна що завгодно — рядок, число, об'єкт. Але ідіоматично кидають екземпляр Error, і на це є вагома причина:
throw new Error("Користувач не залогінений");
Об'єкт Error несе три корисні поля: message (текст), name (тип, для базового — "Error") і, найголовніше, stack — зафіксований стек-трейс місця, де помилку створили. Якщо кинути голий рядок (throw "щось пішло не так"), стека не буде взагалі: ти отримаєш повідомлення без жодного натяку, звідки воно прилетіло. Тому в чужому коді throw "string" — це майже завжди баг, який маскує проблему.
Вбудовані типи помилок
Рушій сам кидає помилки конкретних підтипів, і за їхнім name часто видно природу поломки ще до читання тексту. Усі вони успадковані від Error, тож instanceof Error для будь-якого з них істинний.
| Тип | Коли виникає | Типове повідомлення |
|---|---|---|
TypeError | Значення не того типу; звернення до властивості null/undefined; виклик не-функції | Cannot read properties of undefined (reading 'click') |
ReferenceError | Використано змінну, якої не існує | x is not defined |
RangeError | Значення поза допустимим діапазоном; нескінченна рекурсія | Maximum call stack size exceeded |
SyntaxError | Некоректний синтаксис, зокрема при JSON.parse побитого рядка | Unexpected token < in JSON |
AggregateError | Кілька помилок разом (наприклад, Promise.any, коли всі проміси відхилились) | несе масив errors |
Для AQA найчастіший гість — TypeError з текстом «Cannot read properties of undefined». У тестовому коді це майже завжди означає, що локатор чи змінна не отримали значення: селектор нічого не знайшов, а ти вже кличеш на результаті метод. Це поломка тесту, а не продукту (докладніше — в кінці глави).
try/catch/finally: що саме ловиться
Конструкція проста: код у try виконується; якщо всередині щось кинуло виняток, керування стрибає в catch зі спійманою помилкою; блок finally виконується завжди — і після нормального завершення, і після впійманої помилки, і навіть якщо в try був return.
async function fetchUser(id: string) {
try {
const res = await api.get(`/users/${id}`);
return res.data;
} catch (err) {
logger.error("Не вдалося отримати користувача", err);
throw err; // прокидаємо далі, не ковтаємо
} finally {
await api.dispose(); // прибирання станеться в будь-якому разі
}
}
finally — місце для гарантованого прибирання (закрити з'єднання, звільнити ресурс). Одна пастка: return (чи throw) усередині finally перекриває те, що повертав або кидав try. Тобто return у finally мовчки з'їсть щойно кинуту помилку — так її можна випадково загубити.
Головне обмеження, через яке ламаються половина початківців: try/catch ловить лише синхронний throw, що стався в тому самому кадрі стека, поки виконується блок try. Щойно виконання виходить за межі синхронного потоку — у колбек setTimeout, у неочікуваний проміс — try/catch над ним безсилий.
Класична ілюстрація — setTimeout: колбек виконається пізніше й в іншому кадрі, тож зовнішній try/catch до нього не має стосунку.
try {
setTimeout(() => {
throw new Error("вибух"); // НЕ буде впіймано зовнішнім catch
}, 100);
} catch (err) {
// сюди керування не зайде ніколи
}
Механіку черг мікро- і макрозадач, через яку колбек «відривається» від try, докладно розібрано в главі про event loop.
Помилки в async-коді і unhandled rejection
Асинхронний код грається за тими самими правилами, але з одним містком: await перетворює відхилений (rejected) проміс на звичайний throw. Саме тому try/catch навколо await працює як очікуєш — а без await розсипається.
try {
await doAsyncThing(); // якщо проміс відхилиться — впіймаємо
} catch (err) {
// сюди
}
try {
doAsyncThing(); // немає await → catch нічого не побачить
} catch (err) {
// мертвий код
}
У другому випадку doAsyncThing() повертає проміс, який десь потім відхилиться — але try/catch уже давно завершився. Такий «нічий» проміс без обробника називають floating promise, а його відхилення — неопрацьованим відхиленням (unhandled rejection).
Чому це критично для тестів. Починаючи з Node.js 15, поведінка за замовчуванням — аварійно завершити процес при неопрацьованому відхиленні (раніше був лише варнінг). На практиці це означає: забутий await у тесті A може обвалити весь прогін уже під час тесту B — і ти шукатимеш причину зовсім не там, де вона є. У браузері аналог ловиться подією unhandledrejection на window, у Node — process.on('unhandledRejection', ...).
Окрема пастка — Array.prototype.forEach з async-колбеком: forEach не чекає промісів і не збирає їхніх помилок, тож кожен кинутий усередині виняток стає floating rejection. Про це й про різницю await у циклі vs Promise.all — у главі про асинхронність.
Кастомні помилки і Error.cause
Коли своя логіка потребує розрізняти помилки програмно (не рядком порівнювати повідомлення, а catch-ити конкретний клас), створюють власний тип, успадкований від Error:
class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = "ApiError"; // інакше name лишиться "Error"
}
}
throw new ApiError("Доступ заборонено", 403);
Два моменти. Перший: обов'язково виставляй this.name — інакше в стеку й логах твоя помилка виглядатиме як безлика Error. Другий: тепер її можна відрізнити — if (err instanceof ApiError && err.status === 403).
Часто помилку перехоплюють на нижньому рівні й прокидають вище вже обгорнутою в змістовнішу — і тут легко втратити оригінал. Щоб зберегти ланцюжок, у ECMAScript 2022 з'явився стандартний Error.cause: другим аргументом конструктора передають об'єкт із полем cause.
try {
await db.query(sql);
} catch (err) {
throw new Error("Не вдалося завантажити профіль", { cause: err });
}
Тепер зовнішня помилка каже, що концептуально пішло не так, а .cause тримає технічний першопричинний виняток із його власним стеком — і в логах видно весь ланцюг, а не лише верхівку. Синтаксис класів extends/super, на якому тримаються кастомні помилки, розібрано в главі про класи та this.
Читання stack trace
Стек-трейс — це знімок ланцюжка викликів на момент, коли помилку створили. Формально формат .stack не стандартизований (це рядок), але у V8 (Node.js, Chromium) він однаковий і читається зверху вниз:
TypeError: Cannot read properties of undefined (reading 'click')
at LoginPage.submit (src/pages/login.page.ts:42:18)
at Object.<anonymous> (tests/login.spec.ts:15:22)
at processTicksAndRejections (node:internal/process/task_queues:95:5)
Читати так:
- Перший рядок — тип і повідомлення (
TypeError: ...). Уже тут видно природу поломки. - Перший кадр
at ...— точне місце, де кинуто помилку: файл, рядок, колонка (login.page.ts:42:18). Це найважливіший рядок. - Кадри нижче — хто кого викликав, аж до входу в процес. Кадри з
node_modulesчиnode:internal— це нутрощі рушія й бібліотек; свій рядок-винуватець шукай у першому кадрі, що вказує на твій код.
Дві типові складнощі AQA. Перша: у TypeScript-проєкті стек показує номери рядків скомпільованого JavaScript, а не твого .ts, — і числа не збігаються. Лікує це source map: із ним налагоджувач і рушій «перекладають» кадри назад у .ts (детальніше — у главі про дебаг JS/TS). Друга: в асинхронному коді стек історично «обривався» на межі await — сучасні рушії й Playwright дороблюють async-трейси, але глибина не завжди повна, тому Error.cause як спосіб зберегти першопричину лишається цінним.
Assertion error vs помилка коду
Це той розподіл, заради якого варта вся глава. Коли тест червоний, виняток, що його завалив, належить до одного з двох таборів — і кожен указує в свій бік.
Assertion error — це впала перевірка. Фреймворки перевірок (Playwright expect, chai, вбудований node:assert) при розбіжності кидають спеціальну помилку з відформатованим порівнянням: очікувалося одне, отримали інше.
Error: expect(received).toHaveText(expected)
Expected string: "Замовлення оформлено"
Received string: "Помилка сервера"
Такий фейл — це сигнал тесту: перевірка спрацювала за призначенням. Тепер питання лише в тому, хто винен: продукт (реальний баг — очікування було правильне) чи саме очікування (застаріле, тест треба оновити). У будь-якому разі дивишся на застосунок.
Помилка коду — це виняток самого тестового коду: TypeError, ReferenceError, таймаут очікування елемента, null там, де мав бути об'єкт. Вона каже: перевірка навіть не дійшла до порівняння — зламався інструмент. Тут винен, як правило, тест або інфраструктура, а не продукт: поганий локатор, гонка без очікування, забутий await, невалідні тестові дані.
Практичне правило: починай діагностику з першого рядка помилки. Побачив expect(...) з expected/received — йди перевіряти продукт руками. Побачив TypeError чи TimeoutError — спершу підозрюй власний тест. Це та сама думка, що й канонічне «фейл автотесту ≠ баг застосунку»: перш ніж заводити дефект, відтвори флоу вручну. Механіку таймаутів і флаку через синхронізацію розібрано в главі про практичні сценарії AQA.
Типові помилки
- Виглядає як «try/catch не працює», а насправді помилка сталася поза синхронним потоком
try— у колбекуsetTimeout, у промісі безawait, уforEachз async-функцією.try/catchтут ні до чого; требаawaitабо обробник на самому промісі. - Виглядає як безпечний catch, а насправді
catch (err) { console.log(err.message) }впаде своїмTypeError, якщо кинули неError, а рядок (err.messageбудеundefined, а в суворому TypeScripterrузагалі має типunknownі його треба звузити черезinstanceof Error). - Виглядає як зелений тест, а насправді порожній або надто широкий
catch {}проковтнув реальну помилку — тест не перевіряє нічого й дає фальшивий зелений. Ковтати виняток дозволено лише свідомо й з коментарем чому. - Виглядає як стабільний прогін, а насправді забутий
awaitстворив floating promise: тест A завершується зеленим ще до відхилення, а падає через unhandled rejection пізніший тест B. - Виглядає як «помилка в бібліотеці», а насправді перший кадр стека показав
node_modules, бо ти не догорнув до першого кадру свого коду; винуватець — рядок нижче, у твоєму файлі. - Виглядає як загублена помилка, а насправді
return(чиthrow) у блоціfinallyперекрив виняток, що летів ізtry.
Підсумок
- Кидай
Error(чи його підтип), а не рядок, — інакше втрачаєш стек-трейс і назву помилки. try/catchловить лише синхроннийthrowу своєму кадрі; асинхронну помилку він бачить тільки черезawaitвідхиленого промісу.- Неопрацьоване відхилення (unhandled rejection) у Node.js ≥ 15 за замовчуванням валить процес, тому floating promise у тестах — прихована бомба; жодних промісів без
awaitчи обробника. - Assertion error → перевіряй продукт (баг або застаріле очікування);
TypeError/таймаут/null→ підозрюй свій тест та інфраструктуру. instanceofдля класифікації помилок,Error.cause— щоб при обгортанні зберегти першопричинний виняток і його стек.
Що питають на співбесіді
- «У чому різниця між
throw new Error("...")іthrow "..."?» Інтерв'юер перевіряє, чи розумієш ти цінність стек-трейса: голий рядок його не несе, тож діагностика стає майже неможливою. - «Чи зловить
try/catchпомилку, кинуту в колбекуsetTimeout(або в промісі безawait)?» Правильна відповідь — ні, і чому саме ні (інший кадр / інший тік event loop). Це маркер того, чи бачиш ти межу синхронного й асинхронного коду. - «Що таке unhandled rejection і що з ним робить Node.js?» Чекають згадку, що з Node 15 процес аварійно завершується, і зв'язок із забутим
awaitяк причиною нестабільних прогонів. - «Як зробити власний тип помилки?»
class extends Error,super(message), обов'язковийthis.name; бонусом — навіщоError.cause. - «Тест упав з
TypeError, а сусідній — з невдалимexpect. Який із них імовірніше баг продукту?» Улюблене питання на зрілість:expectуказує на продукт,TypeError— на зламаний тест. Тут інтерв'юер дивиться, чи не побіжиш ти заводити баг на кожен червоний тест.
Джерела
- MDN — Control flow and error handling (
try/catch/finally,throw). - MDN — Error (властивості
message,name,stack,cause; вбудовані підтипи). - Node.js —
processeventunhandledRejection(поведінка за замовчуванням, обробка). - TypeScript — 4.4 Release Notes:
useUnknownInCatchVariables(чомуcatch (err)типізується якunknown). - Playwright — Assertions (формат повідомлень невдалого
expect).
Що таке виняток (exception) і що робить оператор throw?
Виняток — це сигнал «далі так виконувати не можна», який миттєво перериває нормальний хід функції. Оператор throw запускає цей сигнал: щойно він спрацював, поточна функція обривається, а керування починає «спливати» вгору ланцюжком викликів у пошуках обробника. Цей рух угору називають розкруткою стека (stack unwinding): рушій по черзі виходить із функцій, доки не знайде try/catch, готовий помилку прийняти. Якщо на всьому шляху жодного обробника немає, програма (чи тестовий процес) аварійно завершується. Для AQA це базова модель: червоний тест — це або спрацьована перевірка, або десь усередині невпійманий throw.
Чому кидати треба об'єкт Error, а не рядок?
Тому що рядок не несе стек-трейса, а Error — несе. Технічно throw приймає будь-що: число, рядок, об'єкт. Але коли кидаєш голий рядок (throw "щось зламалось"), ти отримуєш повідомлення без жодної прив'язки до місця, де воно виникло, — рушію нема звідки взяти ланцюжок викликів. Екземпляр Error фіксує стек у момент створення, тож у логах видно точний файл і рядок. Через це в чужому коді throw "рядок" — майже завжди прихований баг: він свідомо викидає найцінніше для діагностики. Правило просте: кидай new Error(...) або його підтип, ніколи не примітив.
Які корисні поля має об'єкт Error?
Три ключові: message, name і stack. message — це людський текст помилки, той, що передають у конструктор. name — рядкова назва типу: для базового це "Error", для вбудованих — "TypeError", "ReferenceError" тощо; за нею часто видно природу поломки ще до читання тексту. stack — найцінніше: знімок ланцюжка викликів на момент, коли помилку створили, саме він показує рядок-винуватця. У ECMAScript 2022 додали ще четверте поле — cause, для збереження першопричини при обгортанні. Коли пишеш власний клас помилки, важливо не забути виставити name, інакше твоя помилка виглядатиме безлико.
Назви кілька вбудованих типів помилок і поясни, коли вони виникають.
Рушій сам кидає помилки конкретних підтипів, і всі вони успадковані від Error, тож instanceof Error для них істинний. TypeError — значення не того типу: звернення до властивості null чи undefined, спроба викликати не-функцію. ReferenceError — використано змінну, якої не існує в області видимості. RangeError — значення поза допустимим діапазоном, класика — нескінченна рекурсія з текстом Maximum call stack size exceeded. SyntaxError — некоректний синтаксис, зокрема при JSON.parse побитого рядка. AggregateError — коли кілька помилок треба віддати разом, наприклад із Promise.any, коли всі проміси відхилились. Знати їх варто, бо name одразу звужує коло причин.
Ти бачиш TypeError: Cannot read properties of undefined у тестовому коді. Про що це найчастіше говорить?
Що змінна чи локатор не отримали значення, а ти вже викликав на них метод. У тестах цей TypeError майже завжди означає, що селектор нічого не знайшов і повернув undefined, а наступний рядок звертається до неіснуючого об'єкта. Ключовий висновок для AQA: це поломка самого тесту чи тестових даних, а не продукту. Заводити баг на застосунок тут — типова помилка джуна. Спершу треба перевірити локатор, синхронізацію, підготовку фікстур — і лише потім думати про продукт. Це та сама дисципліна, що й «фейл автотесту ще не дорівнює багу застосунку».
Як працює зв'язка try/catch/finally і що гарантує finally?
Код у try виконується звичайно; якщо всередині щось кинуло виняток, керування стрибає в catch, куди приходить об'єкт помилки. Блок finally виконується завжди — і коли try завершився нормально, і коли помилку впіймали, і навіть коли в try був return. Саме тому finally — місце для гарантованого прибирання: закрити з'єднання, звільнити ресурс, зробити teardown. Обробник catch можна опустити, якщо тобі потрібне лише прибирання без реакції на помилку. Головне пам'ятати, що finally спрацює навіть тоді, коли ти виходиш із функції достроково, — це його суть.
Яка небезпечна пастка з return усередині finally?
return (як і throw) у блоці finally тихо перекриває те, що try збирався повернути чи кинути. Уяви, що try кидає помилку, а finally містить return: помилка просто зникне, функція поверне значення, і зовнішній код навіть не дізнається, що щось пішло не так. Так випадково губляться реальні винятки, а тест стає фальшиво зеленим. Правило: finally призначений для прибирання, а не для повернення значень; тримай його вільним від return і throw, якщо не робиш це свідомо. Це один із тих багів, які потім годинами шукають.
Чи зловить try/catch помилку, кинуту в колбеку setTimeout? Чому?
Ні. Колбек setTimeout виконається пізніше і в іншому кадрі стека, коли зовнішній try/catch уже давно завершився. try/catch ловить лише те, що кинуто синхронно, поки виконується його блок try; він не «чекає» на майбутні тіки event loop. Тому throw усередині відкладеного колбека вилетить у порожнечу — його ніхто не впіймає, і він піде як неопрацьована помилка. Це маркерне питання на співбесіді: воно перевіряє, чи бачиш ти межу між синхронним і асинхронним потоком. Щоб таку помилку опрацювати, обробник треба ставити всередині самого колбека, а не навколо setTimeout.
Сформулюй головне обмеження try/catch.
try/catch ловить лише синхронний throw, що стався в тому самому кадрі стека, поки виконується блок try. Щойно виконання виходить за межі синхронного потоку — у відкладений колбек, у проміс без await, у чергу мікрозадач — обгортка над ним уже безсила. Причина в тому, що до моменту, коли асинхронна помилка справді виникне, блок try фізично завершений, стек згорнутий, ловити нема кому. Єдиний місток назад у синхронну модель — await. Тому будь-яка асинхронна операція, яку ти хочеш захистити try/catch, має бути під await, інакше захист декоративний.
Як await пов'язаний із try/catch в асинхронному коді?
await перетворює відхилений (rejected) проміс на звичайний синхронний throw у точці очікування. Саме через це try/catch навколо await someAsync() працює так, як очікуєш: відхилення промісу стає винятком, який летить у catch. Прибери await — і магія зникає: виклик просто повертає проміс, try миттєво добігає кінця, а відхилення станеться згодом уже поза будь-яким обробником. Тобто try { doAsync() } catch {} без await — це мертвий catch, який ніколи не спрацює. Практичний висновок: у async-функції захищати треба саме await-вирази, і забутий await руйнує весь обробник помилок навколо.
Що таке floating promise і unhandled rejection?
Floating promise — це проміс, який ніхто не «тримає»: його не чекають через await і не вішають на нього .catch(). Поки він резолвиться успішно, проблеми немає. Але якщо він відхилиться, це відхилення нема кому обробити — воно стає неопрацьованим (unhandled rejection). Найчастіша причина в тестах — забутий await перед асинхронним викликом. Небезпека в тому, що floating promise відривається від коду, який його породив: функція, де стояв виклик, давно завершилась, а бомба спрацьовує згодом. У браузері це ловиться подією unhandledrejection на window, у Node — через process.on('unhandledRejection', ...).
Що робить Node.js із неопрацьованим відхиленням і з якої версії?
Починаючи з Node.js 15, поведінка за замовчуванням — аварійно завершити процес; раніше був лише попереджувальний варнінг. Для тестів це має прямий і підступний наслідок: забутий await у тесті A може обвалити весь прогін під час тесту B, бо відхилення спливе пізніше, вже в чужому кейсі. Ти шукатимеш причину зовсім не там, де вона є, — падатиме тест, який ні в чому не винен. Саме тому floating promise у тестах називають прихованою бомбою: жодних промісів без await чи власного обробника. Поведінку можна переозначити глобальним хендлером, але покладатись на це замість акуратного await — погана ідея.
Чому Array.prototype.forEach з async-колбеком небезпечний?
Бо forEach не вміє чекати на проміси. Він викликає колбек для кожного елемента, ігноруючи те, що async-колбек повертає проміс, — не збирає ці проміси й не чекає на них. У результаті кожна помилка, кинута всередині такого колбека, стає floating rejection: цикл давно «пройшовся», а відхилення десь у повітрі. Зовнішній try/catch навколо forEach тут не допоможе, бо на момент відхилення він завершений. Коли треба послідовно чекати на кожну ітерацію — використовуй for...of з await, коли паралельно — Promise.all над масивом промісів. forEach для асинхронщини не годиться.
Як створити власний тип помилки й навіщо там this.name?
Успадкуванням від Error: клас extends Error, у конструкторі викликаєш super(message), додаєш свої поля (наприклад, status для HTTP). Це потрібно, коли логіка має розрізняти помилки програмно — не порівнювати рядки повідомлень, а ловити конкретний клас через instanceof. Виставляти this.name у конструкторі обов'язково: без цього рядка твоя помилка в стеку й логах виглядатиме як безлика базова Error, і сенс власного типу частково втрачається. Далі її легко відрізнити, наприклад if (err instanceof ApiError && err.status === 403). Це чистіше й надійніше, ніж вгадувати тип за текстом message.
Що таке Error.cause і яку проблему він розв'язує?
Error.cause — стандартний спосіб (ECMAScript 2022) зберегти першопричину, коли обгортаєш одну помилку в іншу. Часто помилку ловлять на нижньому рівні й прокидають вище вже змістовнішою — «не вдалося завантажити профіль» замість сирого збою бази. Проблема в тому, що при такому обгортанні легко втратити оригінал разом із його стеком. cause це лікує: другим аргументом конструктора передають об'єкт із полем cause, куди кладуть початковий виняток. Тоді зовнішня помилка каже, що концептуально пішло не так, а .cause тримає технічну першопричину з її власним стеком — і в логах видно весь ланцюг, а не лише верхівку. Особливо цінно в асинхронному коді, де стек історично обривався на межі await.
Як читати stack trace і який кадр найважливіший?
Стек-трейс — це знімок ланцюжка викликів на момент створення помилки; у V8 (Node.js, Chromium) читається зверху вниз. Перший рядок — тип і повідомлення (TypeError: ...), уже тут видно природу поломки. Далі йдуть кадри at ...: перший із них показує точне місце, де кинуто помилку — файл, рядок і колонку, це і є найважливіший рядок. Кадри нижче — хто кого викликав, аж до входу в процес. Кадри з node_modules чи node:internal — це нутрощі рушія та бібліотек, у них винуватця шукати не варто; треба догорнути до першого кадру, що вказує на твій власний код. Типова помилка — прочитати верхній кадр із бібліотеки й вирішити, що баг у ній.
У TypeScript-проєкті номери рядків у стеку не збігаються з .ts. Чому і що лікує?
Бо рушій виконує скомпільований JavaScript, а не твій вихідний TypeScript, тож стек показує рядки згенерованого .js — і числа не сходяться з твоїм файлом. Лікує це source map: спеціальний файл-«словник», який зіставляє позиції в скомпільованому коді з позиціями в оригіналі. Із коректно налаштованим source map налагоджувач і рушій «перекладають» кадри стека назад у твій .ts, і ти бачиш правильні рядки. Друга суміжна складність — асинхронні трейси, які історично обривались на межі await; сучасні рушії й Playwright їх дороблюють, але глибина не завжди повна. Тому Error.cause лишається корисним способом не загубити першопричину.
У чому різниця між assertion error і помилкою коду, і чому це важливо?
Це два принципово різні сорти червоного тесту, і кожен указує в свій бік. Assertion error — це впала перевірка: фреймворк (expect у Playwright, chai, node:assert) при розбіжності кидає спеціальну помилку з форматованим порівнянням «очікували одне, отримали інше». Такий фейл означає, що перевірка спрацювала за призначенням; тепер питання лише, хто винен — продукт (реальний баг) чи застаріле очікування (тест треба оновити). У будь-якому разі дивишся на застосунок. Помилка коду — це виняток самого тестового коду: TypeError, ReferenceError, таймаут очікування елемента, null замість об'єкта. Вона каже, що перевірка навіть не дійшла до порівняння — зламався інструмент, і винен, як правило, тест або інфраструктура. Практичне правило: почни діагностику з першого рядка помилки.
Тест A упав із TypeError, тест B — із невдалим expect. Який імовірніше вказує на баг продукту?
Тест B із невдалим expect. expect (assertion error) означає, що перевірка дійшла до порівняння й побачила розбіжність — а отже, або продукт справді повівся не так, або очікування застаріле; обидва варіанти ведуть до застосунку. TypeError натомість каже, що тест зламався ще до перевірки: локатор повернув undefined, забули await, крива фікстура — усе це поломка інструмента, а не продукту. Тому на кожен TypeError бігти заводити баг на застосунок — ознака незрілості. Інтерв'юер цим питанням якраз перевіряє, чи вмієш ти читати перший рядок помилки й не плутати зламаний тест зі зламаним продуктом. Порядок дій: expect — перевіряй продукт руками; TypeError/TimeoutError — спершу підозрюй власний тест.
Чому catch (err) { console.log(err.message) } може сам впасти, і як err типізується у строгому TypeScript?
Бо catch ловить будь-що, не лише Error. Якщо десь у коді кинули рядок (throw "помилка"), то в блоці catch звернення err.message дасть undefined, а спроба викликати на такому err метод чи властивість об'єкта може кинути вже власний TypeError — і ти маскуєш початкову проблему новою. Саме тому в строгому TypeScript (опція useUnknownInCatchVariables, з версії 4.4) змінна err типізується як unknown, а не any: компілятор змушує спершу звузити тип, зазвичай через if (err instanceof Error), і лише тоді дозволяє чіпати .message. Це не прискіпливість, а захист: він не дає наосліп припускати, що спіймали саме Error.
Три кейси, де вміння читати винятки економить години: розбір стек-трейса, щоб за півхвилини знайти рядок-винуватця й зрозуміти, тест це чи продукт; забутий await, що завалює чужий тест через unhandled rejection, — як відтворити й полагодити; і власний тип помилки з Error.cause, щоб при обгортанні не втратити першопричину. Скрізь — на що дивитися і чому.
Кейс 1. Читаємо стек-трейс: тест чи продукт?
Прогін червоний, у консолі — стек. Перше, що робимо, це не ліземо в код застосунку, а читаємо перший рядок і шукаємо перший кадр свого коду.
TypeError: Cannot read properties of undefined (reading 'click')
at LoginPage.submit (src/pages/login.page.ts:42:18)
at Object.<anonymous> (tests/login.spec.ts:15:22)
at processTicksAndRejections (node:internal/process/task_queues:95:5)
Розбір по рядках:
- Перший рядок —
TypeError, а неexpect. Уже тут ясно: перевірка навіть не дійшла до порівняння, зламався сам інструмент. Це сигнал «підозрюй тест», а не «заводь баг на продукт». Cannot read properties of undefined (reading 'click')— щось булоundefined, а на ньому викликали.click(). Класика: локатор нічого не знайшов і повернувundefined.- Перший кадр
at LoginPage.submit (login.page.ts:42:18)— це і є рядок-винуватець: файл, рядок 42, колонка 18. Відкриваємоlogin.page.ts:42— там звернення до елемента, який не відрендерився або має інший селектор. - Останній кадр
node:internal/...— нутрощі рушія, не наш код. Якби ми прочитали лише його, вирішили б, що баг «десь у Node», і пішли б хибним шляхом. Винуватця завжди шукаємо в першому кадрі, що вказує на власний файл.
Тобто діагноз — зламаний тест (поганий локатор), а не баг застосунку. Тепер порівняйте з іншим фейлом того самого прогону:
Error: expect(locator).toHaveText(expected)
Expected string: "Замовлення оформлено"
Received string: "Помилка сервера"
at tests/checkout.spec.ts:28:44
Тут перший рядок — expect із парою expected/received: перевірка спрацювала за призначенням і побачила розбіжність. Це вже привід іти дивитися на продукт руками: або реальний баг (сервер справді віддав помилку), або застаріле очікування. Один прогін, дві червоні смуги — і кожна веде в протилежний бік.
Кейс 2. Забутий await: тест A валить тест B
Найпідступніший клас нестабільності: тест зелений, а падає наступний. Причина — floating promise через пропущений await.
import { test, expect } from '@playwright/test';
// проблемний тест: await загубився
test('A: створення чернетки', async ({ request }) => {
// немає await — виклик повертає проміс, який ніхто не тримає
request.post('/api/drafts', { data: { title: 'x' } });
expect(true).toBe(true); // тест добігає кінця зеленим
}); // якщо POST відхилиться (напр. 500), відхилення спливе ПІЗНІШЕ
Що тут відбувається і чому це небезпечно:
- Тест A завершується зеленим ще до відповіді сервера.
request.post(...)безawaitвіддає проміс і йде далі; перевірка тривіальна, тест «проходить». - Якщо POST відхилиться, обробника вже немає. Функція тесту A завершена,
try/catch(якби він був) давно закритий. Відхилення стає unhandled rejection. - З Node.js 15 це валить процес. Смуга червоніє не там, де баг: падає тест B, який виконувався в момент, коли спливло відхилення. Ти годинами дивишся на невинний тест B.
Відтворення й діагностика: увімкни глобальний хендлер, щоб побачити, звідки насправді летить відхилення.
process.on('unhandledRejection', (reason) => {
// reason тримає оригінальний стек — покаже саме той рядок без await
console.error('UNHANDLED:', reason);
});
Полагодження — просто повернути await і дати перевірці сенс:
test('A: створення чернетки', async ({ request }) => {
const res = await request.post('/api/drafts', { data: { title: 'x' } });
expect(res.status()).toBe(201); // тепер відхилення ловиться тут і зараз
});
Що дивитися і чому:
- Симптом «падає не той тест» — майже завжди async-проблема. Якщо результат залежить від порядку чи паралелізму, перший підозрюваний — floating promise, а не продукт.
forEachз async-колбеком дає той самий ефект. Він не чекає промісів, тож кожна помилка всередині стає floating rejection; заміни наfor...ofзawaitабо збери вPromise.all.- Лінтер ловить це статично. Правило на кшталт
no-floating-promises(typescript-eslint) підсвічує забутийawaitдо того, як він стане нічним падінням CI.
Кейс 3. Власний тип помилки + Error.cause: не загубити першопричину
Коли хелпер ловить низькорівневий збій і прокидає вище щось змістовніше, легко втратити оригінал. Власний клас плюс cause зберігають обидва рівні.
class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
options?: { cause?: unknown },
) {
super(message, options);
this.name = 'ApiError'; // без цього в логах буде безлика "Error"
}
}
async function loadProfile(id: string) {
try {
return await db.query(`SELECT * FROM users WHERE id = ${id}`);
} catch (err) {
// обгортаємо в змістовну помилку, але тримаємо першопричину
throw new ApiError('Не вдалося завантажити профіль', 500, { cause: err });
}
}
Тепер у тесті помилку можна класифікувати програмно, не порівнюючи текст:
import { test, expect } from '@playwright/test';
test('недоступний профіль повертає ApiError 500', async () => {
try {
await loadProfile('999');
throw new Error('очікували ApiError, а виклик пройшов'); // страховка
} catch (err) {
// звужуємо unknown до конкретного класу
if (!(err instanceof ApiError)) throw err;
expect(err.status).toBe(500);
expect(err.cause).toBeDefined(); // технічна першопричина на місці
}
});
Що дивитися і чому:
this.nameобов'язковий. Без ньогоinstanceof ApiErrorусе одно спрацює, але в стеку й логах помилка виглядатиме якError— важче шукати очима.instanceofзамість порівняння message. Текст повідомлення змінюють при рефакторингу, клас — стабільний контракт; лови саме клас..causeрятує в асинхронному коді. Стек історично обривається на межіawait, тож зовнішня помилка сама по собі показує лише верхівку;causeтримає першопричинний виняток із його власним стеком, і в логах видно весь ланцюг.- У строгому TypeScript
errмає типunknown. Компілятор (зuseUnknownInCatchVariables) не дасть чіпати.status, доки не звузиш черезinstanceof— це захист від наосліп-припущення, що спіймали самеError.
throw і об'єкт Error
- Розумію, що
throwмиттєво обриває функцію й запускає розкрутку стека (stack unwinding) у пошукахtry/catch, а без обробника процес падає. - Знаю, чому кидати треба
Error(чи його підтип), а не рядок: примітив не несе стек-трейса, і діагностика стає майже неможливою. - Пам'ятаю три поля
Error:message(текст),name(тип) іstack(знімок ланцюжка викликів), плюсcauseз ES2022.
Вбудовані типи помилок
- Можу пояснити
TypeErrorvsReferenceError: перший — не той тип чи звернення до властивостіnull/undefined, другий — неоголошена змінна. - Розумію, що
TypeError: Cannot read properties of undefinedу тесті майже завжди означає порожній локатор, а не баг продукту.
try/catch/finally
- Можу пояснити, що
finallyвиконується завжди — після успіху, після впійманої помилки й навіть післяreturnуtry. - Знаю пастку:
returnчиthrowуfinallyтихо перекриває виняток, що летів ізtry, — так губляться реальні помилки.
Синхронне vs асинхронне ловлення
- Знаю головне обмеження:
try/catchловить лише синхроннийthrowу тому самому кадрі, поки виконується блокtry. - Розумію, що
awaitперетворює відхилений проміс на звичайнийthrow, тому безawaitобробник навколо async-виклику мертвий.
Floating promise і unhandled rejection
- Знаю, що floating promise — проміс без
awaitі без.catch(), а його відхилення стає unhandled rejection. - Пам'ятаю, що з Node.js 15 неопрацьоване відхилення за замовчуванням валить процес, тож забутий
awaitу тесті A може обвалити тест B. - Розумію, чому
forEachз async-колбеком небезпечний: він не чекає промісів, і помилки стають floating rejection — требаfor...ofзawaitабоPromise.all.
Кастомні помилки і Error.cause
- Можу створити власний тип:
class extends Error,super(message), обов'язковийthis.name, і пояснити, навіщо кожен крок. - Знаю, навіщо
instanceofдля класифікації — щоб ловити конкретний клас, а не порівнювати текст повідомлення. - Розумію, що
Error.causeзберігає першопричинний виняток при обгортанні, і в логах видно весь ланцюг, а не лише верхівку.
Стек-трейси
- Умію читати stack trace зверху вниз: перший рядок — тип і повідомлення, перший кадр
at— точне місце поломки. - Знаю, що кадри з
node_modules/node:internal— це нутрощі, і винуватця шукаю в першому кадрі свого коду. - Розумію, чому в TypeScript номери рядків не збігаються з
.tsі що це лікує source map.
Assertion error vs помилка коду
- Можу з першого рядка відрізнити assertion error (expected/received) від помилки коду (
TypeError, таймаут,null). - Знаю правило маршрутизації:
expectведе до продукту (баг або застаріле очікування),TypeError/таймаут — до свого тесту й інфраструктури.
Чому ідіоматично кидати екземпляр Error, а не рядок?
Питання
Що робить оператор throw?