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

    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 над ним безсилий.

    Так

    Ні

    Так

    Ні

    Помилка сталася в try

    Синхронний throw
    у цьому ж кадрі?

    Впіймано в catch

    Це await
    відхиленого промісу?

    НЕ впіймано:
    floating promise або інший тік

    unhandledRejection
    або падіння в іншому місці

    Так

    Ні

    Так

    Ні

    Помилка сталася в try

    Синхронний throw
    у цьому ж кадрі?

    Впіймано в catch

    Це await
    відхиленого промісу?

    НЕ впіймано:
    floating promise або інший тік

    unhandledRejection
    або падіння в іншому місці

    Класична ілюстрація — 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 / assertion:
    expected vs received

    Так

    Ні

    TypeError, timeout,
    null reference

    Тест червоний

    Що в помилці?

    Перевірка спрацювала

    Очікування правильне?

    Баг продукту → баг-репорт

    Застарілий тест → оновити

    Зламався сам тест

    Дивись на код тесту
    та інфраструктуру

    expect / assertion:
    expected vs received

    Так

    Ні

    TypeError, timeout,
    null reference

    Тест червоний

    Що в помилці?

    Перевірка спрацювала

    Очікування правильне?

    Баг продукту → баг-репорт

    Застарілий тест → оновити

    Зламався сам тест

    Дивись на код тесту
    та інфраструктуру

    Практичне правило: починай діагностику з першого рядка помилки. Побачив 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, а в суворому TypeScript err узагалі має тип 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 — на зламаний тест. Тут інтерв'юер дивиться, чи не побіжиш ти заводити баг на кожен червоний тест.

    Джерела