08 · JavaScript/TypeScript для AQA
Рядки, дати й регулярні вирази
Зміст
Рядки, дати й числа — це те, що автотест перевіряє щодня: чи показує поле правильний формат телефону, чи збігається дата в API з тим, що на екрані, чи округлилася сума в чеку. Три теми, які на вигляд елементарні, а насправді дають найпідступніші баги в тестовому коді: регулярний вираз, який матчить забагато; дата, що «переходить» на інший день між твоєю машиною і CI; і сума 0.1 + 0.2, яка чомусь не дорівнює 0.3.
Ця глава — про механіку цих трьох інструментів саме під кутом тестування. Не «як влаштований рядок у пам'яті», а «чому твоя асерція зелена на застарілих даних», «чому тест падає лише о 23:50» і «як правильно порівняти гроші». Вага теми на співбесідах середня, але питання про таймзони й toFixed ставлять регулярно — бо саме на них видно, чи людина розуміє, що робить її код, чи просто копіює зі Stack Overflow.
Методи рядків
Головне, що треба засвоїти про рядок у JavaScript: він незмінний (immutable). Жоден метод не змінює рядок на місці — усі повертають новий рядок. Тому text.trim() без присвоєння результату не робить нічого; це класична помилка новачка.
Для асерцій найкорисніші кілька методів:
| Метод | Що робить | Типове застосування в тесті |
|---|---|---|
includes(sub) | чи є підрядок, повертає boolean | перевірка фрагмента повідомлення |
startsWith / endsWith | префікс / суфікс | валідація формату, розширення файлу |
trim() | прибирає пробіли по краях | текст із UI майже завжди «брудний» |
toLowerCase / toUpperCase | зміна регістру | порівняння без урахування регістру |
slice(start, end) | вирізає частину | дістати шматок за позицією |
split(sep) | ділить на масив | розібрати CSV-рядок, шлях, дату |
replace / replaceAll | заміна | нормалізація тексту перед звіркою |
padStart / padEnd | доповнення до довжини | форматування чисел, "7" → "07" |
includes віддає boolean, тому в тестах він читабельніший за старий indexOf(...) !== -1 (той повертає позицію або -1). Текст, отриманий з інтерфейсу, майже завжди варто пропустити через trim() перед порівнянням — верстка легко додає невидимі пробіли й переноси рядків.
const raw = await page.locator('.order-total').innerText(); // " Разом: 1 234,50 грн "
const text = raw.trim();
expect(text.startsWith('Разом')).toBeTruthy();
expect(text.includes('1 234,50')).toBeTruthy();
Одна пастка глибше рівня джуна, але про неї корисно знати: length рахує не «символи», а одиниці кодування UTF-16. Емодзі та деякі рідкісні символи займають дві одиниці, тому '😀'.length дорівнює 2, а не 1. Якщо треба порахувати саме видимі символи, беруть ітератор: [...'😀'].length дасть 1. Так само порівняння рядків з діакритикою (наприклад, é як один символ проти e плюс комбінований наголос) може несподівано провалитись — рятує normalize('NFC') перед звіркою.
Шаблонні рядки
Шаблонні літерали (template literals) пишуться у зворотних лапках і дозволяють вставляти вирази прямо в текст через синтаксис долара з фігурними дужками, а також робити багаторядкові рядки без конкатенації. Для тестового коду це щоденний інструмент: динамічні URL, повідомлення про помилку з контекстом, генерація унікальних тестових даних.
const userId = 42;
const env = process.env.BASE_URL ?? 'https://staging.example.com';
await page.goto(`${env}/users/${userId}/profile`);
const uniqueEmail = `qa+${Date.now()}@example.com`; // унікальний на кожен прогін
Зворотні лапки читабельніші за склейку через плюс і не сплутають типи: усе всередині приводиться до рядка. Але саме тут ховається дрібна пастка — об'єкт у шаблоні перетвориться на [object Object], а не на свій вміст; для об'єктів потрібен JSON.stringify. Про приведення типів детальніше — у главі «Змінні, типи даних і приведення типів».
Регулярні вирази: синтаксис, флаги, test і match
Регулярний вираз (regular expression, regex) — це шаблон для пошуку й перевірки тексту. У JavaScript його створюють двома способами: літералом між слешами /шаблон/флаги або через конструктор new RegExp('шаблон', 'флаги'). Літерал зручніший для сталих шаблонів; конструктор потрібен, коли шаблон збирається з рядка динамічно.
Мінімальний синтаксис, якого вистачає для 90% задач AQA:
| Конструкція | Значення |
|---|---|
\d \w \s | цифра / буквено-цифровий символ / пробіл |
. | будь-який символ (крім переносу рядка) |
^ $ | початок / кінець рядка |
* + ? | нуль-або-більше / один-або-більше / нуль-або-один |
a{2,4} | від 2 до 4 повторень |
[abc] [^abc] | один із набору / будь-який, крім набору |
(...) | група із захопленням |
a|b | «або» |
Флаги (прапорці) змінюють поведінку всього виразу:
g— глобальний пошук, знаходить усі збіги, а не лише перший;i— без урахування регістру;m— багаторядковий режим:^і$матчать межі кожного рядка;s— крапка починає матчити й переноси рядків;u— коректна робота з Unicode.
Два методи, які використовують найчастіше:
regex.test(str)повертаєboolean— «чи є збіг». Це основний інструмент валідації формату.str.match(regex)повертає збіги. Без флагаg— перший збіг разом із захопленими групами; з флагомg— масив усіх збігів без груп. Коли потрібні і всі збіги, і групи, берутьstr.matchAll(regex)(обов'язково зg), що повертає ітератор.
const isIsoDate = /^\d{4}-\d{2}-\d{2}$/;
expect(isIsoDate.test('2026-07-18')).toBe(true);
expect(isIsoDate.test('18.07.2026')).toBe(false);
// дістати id із URL через захоплену групу
const url = 'https://app.example.com/orders/10582/items';
const orderId = url.match(/orders\/(\d+)/)?.[1]; // "10582"
Головна пастка regex — жадібність (greedy). Квантифікатори за замовчуванням хапають якнайбільше. Вираз /".*"/ на рядку "a" та "b" захопить усе від першої до останньої лапки, а не окреме "a". Щоб зробити квантифікатор «лінивим», додають ?: /".*?"/. Друга типова пастка стосується test із флагом g: об'єкт regex запам'ятовує позицію останнього збігу в lastIndex, і повторні виклики test на тому самому об'єкті даватимуть різний результат. Для простої валідації флаг g просто не потрібен.
Regex у роботі AQA: асерції, локатори, валідація
Три місця, де regex реально економить час тестувальнику.
Асерції за патерном, а не за точним рядком. Коли частина тексту динамічна (число, дата, id), точне порівняння крихке. Playwright приймає regex у більшості текстових асерцій:
// зайве уточнення: точний текст зламається на будь-якій новій сумі
await expect(page.locator('.balance')).toHaveText(/^Баланс: \d[\d\s]*,\d{2} грн$/);
// "замовлення №<будь-яке число> створено"
await expect(page.locator('.toast')).toContainText(/замовлення №\d+ створено/i);
Локатори за текстом. page.getByText(/.../) і page.getByRole('button', { name: /зберегти/i }) дозволяють знаходити елемент за гнучким шаблоном — корисно, коли текст містить змінну частину або коли треба ігнорувати регістр. Про вибір елементів за текстом і стабільність селекторів загалом — глава «DOM, селектори та події».
Валідація формату тестових даних. Regex зручний, щоб перевірити, що згенерований email, телефон чи ISO-дата виглядають правильно, перш ніж відправляти їх в API.
Тут важлива тверезість: не намагайтеся описати регуляркою те, що має складну специфікацію. Канонічний приклад — email: «правильний» вираз за стандартом настільки монструозний, що на практиці перевіряють лише наявність @ і крапки, а решту віддають бекенду. Для перевірки структури JSON-відповіді regex теж не інструмент — там працюють зі схемою (JSON Schema, zod), а розбір даних детальніше розглянуто в главі «Об'єкти, масиви та методи роботи з даними». Regex — для рядків простої, регулярної структури, не для вкладених даних.
Date та ISO 8601
Об'єкт Date усередині — це одне число: кількість мілісекунд від «епохи» (1 січня 1970 року, 00:00 UTC). Усе інше — методи, що це число форматують чи розбирають.
Найнадійніший спосіб задати дату — стандарт ISO 8601: 2026-07-18T10:30:00.000Z. Літера Z наприкінці означає UTC («зулу-час»). Специфікація мови гарантує, що рядок у форматі ISO 8601 буде розібрано однаково в будь-якому рушії. Будь-який інший формат ("07/18/2026", "18.07.2026") розбирається на розсуд конкретного рушія — десь спрацює, десь дасть Invalid Date, десь переплутає день і місяць. Правило просте: в API і тестах передавайте й порівнюйте дати в ISO 8601.
const d = new Date('2026-07-18T10:30:00.000Z');
d.getTime(); // 1784...000 — мілісекунди, зручно порівнювати
d.toISOString(); // "2026-07-18T10:30:00.000Z" — назад у канонічний рядок
// порівняння дат — через getTime(), а не через самі об'єкти
const a = new Date('2026-07-18T00:00:00Z');
const b = new Date('2026-07-18T00:00:00Z');
expect(a.getTime()).toBe(b.getTime()); // === на об'єктах Date завжди false
Дві вбудовані пастки Date. Перша: у конструкторі new Date(2026, 6, 18) місяць рахується від нуля, тобто 6 — це липень, а не червень. Так само getMonth() повертає число від 0 до 11. Друга: Date змінний — метод setDate мутує сам об'єкт, а не повертає новий, тож спільний об'єкт дати легко зіпсувати між тестами.
Стандартний Date незручний для арифметики й форматування, тому в реальних проєктах часто беруть бібліотеку (date-fns, Luxon, Day.js). У мову також додають сучасніший API Temporal — але це вже поза межами джуна.
Таймзони і «тест падає лише вночі»
Це найчастіше «магічне» падіння, пов'язане з датами. Симптом: тест зелений цілий день, а вночі — або в CI — раптом червоний, хоч код не мінявся.
Причина в тому, що одна й та сама мить часу має різну календарну дату залежно від таймзони. toISOString() завжди віддає час у UTC, а локальні методи (getDate(), getHours(), toLocaleString()) — у таймзоні тієї машини, де виконується код. Твій ноутбук у Києві живе в UTC+2/+3 і випереджає UTC, а CI-агент майже завжди в самому UTC. Тому в перші години після місцевої півночі локальна календарна дата вже нова, а в UTC ще триває попередня доба: о 01:30 за Києвом у UTC це ще 18 липня, 22:30. Асерція, яка порівнює «локальну» дату з датою в UTC, збігається вдень і розходиться в ці нічні години.
Як лікувати, за зростанням надійності:
- Порівнюйте в одній системі відліку. Якщо бекенд віддає UTC, то й у тесті беріть
toISOString()чиgetTime(), а не локальніgetDate/getHours. Не змішуйте локальний і UTC-час в одній асерції. - Фіксуйте («заморожуйте») час. Тест, що залежить від «сьогодні», недетермінований за визначенням. Playwright має
page.clockдля підміни часу в браузері; у юніт-тестах час мокають (наприклад, фейковими таймерами). Тоді «сьогодні» завжди те саме, і межа доби не має значення. - Не обчислюйте очікувану дату тим самим кодом, що й продукт — інакше тест повторить його баг. Краще підставити дату як фікстуру.
// Playwright: зафіксувати час, щоб тест не залежав від реального «зараз»
await page.clock.setFixedTime(new Date('2026-07-18T09:00:00Z'));
Ця ж тема — дати, таймзони, межі — розглядається під кутом тест-дизайну в розділі «Тест-дизайн» (тестові дані та граничні значення) і під кутом бази даних у розділі «Бази даних і SQL» (тип TIMESTAMP з таймзоною і без).
Числа: parseInt, toFixed і чому 0.1 + 0.2 не дорівнює 0.3
parseInt(str, radix) розбирає ціле число з початку рядка й зупиняється на першому нецифровому символі: parseInt('10px', 10) дасть 10, а parseInt('px10', 10) — NaN. Завжди передавайте другий аргумент — основу системи числення (10). Без нього рядок, що починається з 0x, буде розібрано як шістнадцятковий; історично провідний нуль міг означати вісімкове число. Якщо потрібне дробове — це parseFloat; якщо треба сувора перевірка «весь рядок є число» — краще Number(str), що на смітті дає NaN замість мовчазного обрізання.
toFixed(n) округлює до n знаків після коми й повертає рядок, а не число. Це часта пастка: (2).toFixed(2) дає "2.00", і подальше === 2 провалиться. Друга тонкість — округлення тут не завжди «математичне» через двійкове представлення: (1.005).toFixed(2) дає "1.00", а не "1.01", бо 1.005 насправді зберігається як трохи менше число.
І головне питання співбесід: чому 0.1 + 0.2 !== 0.3? Бо числа в JavaScript — це числа з рухомою комою подвійної точності за стандартом IEEE 754 (той самий тип, що double). У двійковій системі дроби 0.1 і 0.2 не мають точного представлення — так само, як 1/3 не записати скінченним десятковим дробом. Тому вони зберігаються з крихітною похибкою, і сума дає 0.30000000000000004. Це не баг JavaScript — так само поводяться майже всі мови з типом double.
expect(0.1 + 0.2).toBe(0.3); // ПРОВАЛИТЬСЯ: 0.30000000000000004
expect(0.1 + 0.2).toBeCloseTo(0.3); // OK: порівняння з допуском
Практичні наслідки для тестів:
- Ніколи не порівнюйте дробові числа на точну рівність. У Playwright/Jest для цього є
toBeCloseTo, у ручній логіці — перевірка, що різниця менша за малий поріг (epsilon). - Гроші не тримають у
float. Правильно — рахувати в найдрібнішій одиниці (копійки, центи) цілими числами або спеціальним decimal-типом. Тому й у базі суму зберігають якDECIMAL, а неFLOAT(деталі — у розділі «Бази даних і SQL»). toFixed— для показу, не для розрахунків. Якщо порівнюєте суму з UI, порівнюйте або рядок з рядком (обидва форматовані), або число з числом (обидва як числа), не змішуючи.
Типові помилки
- Виглядає як «
trim()не працює», а насправді ви не присвоїли результат. Рядок незмінний — усі методи повертають новий, а старий лишається брудним. - Виглядає як баг продукту «дата на день раніше», а насправді тест змішав UTC і локальний час. Продукт віддає UTC, тест дістав
getDate()у місцевій таймзоні — і на межі доби вони розходяться. - Виглядає як «regex не знаходить», а насправді флаг
gлишивlastIndexпосеред рядка від попереднього викликуtest, або квантифікатор виявився жадібним і захопив зайве. - Виглядає як «сума неправильна», а насправді
toFixedповернув рядок, і ви порівнюєте"2.00"з числом2— типи різні, асерція падає. - Виглядає як «математика зламалась», а насправді це IEEE 754:
0.1 + 0.2дає0.30000000000000004, і точна рівність з0.3неможлива. - Виглядає як «валідація email пропускає сміття», а насправді ви намагаєтесь однією регуляркою покрити те, що має складну специфікацію; таку перевірку лишають бекенду.
Підсумок
- Рядки незмінні: методи повертають новий рядок, тож результат треба присвоювати; текст із UI перед звіркою
trim-те. - Regex
testдаєbooleanдля валідації,match/matchAll— для витягання; стережіться жадібності та стануlastIndexпри флазіg. - Передавайте й порівнюйте дати в ISO 8601 та в одній системі відліку (UTC); не змішуйте локальні й UTC-методи.
- «Тест падає лише вночі» — майже завжди таймзони; лікується фіксацією часу й порівнянням у UTC, а не затримками.
- Дробові числа мають похибку IEEE 754: не порівнюйте їх на точну рівність, гроші рахуйте цілими,
toFixedповертає рядок і потрібен лише для показу.
Що питають на співбесіді
- «Чому
0.1 + 0.2 !== 0.3?» — найпоширеніше. Інтерв'юер хоче почути про двійкове представлення й IEEE 754, а не «це баг JS». Сильна відповідь одразу додає, як із цим жити:toBeCloseTo, гроші в копійках. - «Твій тест зелений локально, а в CI червоний і тільки для дат — у чому річ?» — перевірка розуміння таймзон. Очікують згадку UTC проти локального часу, CI в UTC і фіксацію часу як рішення.
- «Чим
testвідрізняється відmatch? Що повернеmatchіз флагомgі без?» — базове знання regex API; заразом можуть спитати про жадібні квантифікатори. - «Як перевірити, що рядок відповідає формату дати / телефону?» — тут дивляться і на вміння написати простий regex, і на тверезість не переускладнювати (email — не регуляркою).
- «
parseInt('08')?(2).toFixed(2)— це число чи рядок?» — маленькі пастки на уважність; головне — не плутати, щоtoFixedповертає рядок.
Інтерв'юер за цими питаннями перевіряє не знання синтаксису напам'ять, а чи розумієте ви, чому код поводиться саме так, — бо саме нерозуміння механіки й породжує флакі-тести на датах і числах.
Джерела
- MDN — String (методи рядків)
- MDN — Regular expressions (посібник)
- MDN — Date
- ISO — ISO 8601 Date and time format
- MDN — Number (числа з рухомою комою, IEEE 754)
Ця глава — програмний фундамент, а не розділ силабусу ISTQB CTFL 4.0; аспекти тестових даних (дати, таймзони, межі, спецсимволи) розкриває розділ «Тест-дизайн» (тестові дані й граничні значення).
Чому виклик text.trim() окремим рядком не прибирає пробіли з рядка?
Бо рядок у JavaScript незмінний (immutable) — жоден метод не редагує його на місці. trim(), replace(), toUpperCase() та решта повертають новий рядок, а вихідний лишається таким, як був. Тому text.trim(); окремим виразом виконується, створює очищену копію й одразу її викидає, бо результат нікуди не присвоєно. Правильно — const clean = text.trim(); або порівнювати одразу text.trim(). Це класична пастка новачка: людина «викликала trim», бачить у рядку пробіли й вирішує, що метод зламаний, хоча зламана логіка присвоєння.
Коли в тесті брати includes, а коли ще трапляється indexOf?
includes(sub) відповідає на питання «чи є підрядок» і повертає boolean, тому в асерціях читається прямо: expect(text.includes('Разом')).toBeTruthy(). Старіший indexOf(sub) повертає позицію збігу або -1, тож для перевірки наявності його доводилося писати як indexOf(sub) !== -1 — менш очевидно й легше помилитися зі знаком. indexOf досі доречний, коли потрібна саме позиція, а не факт наявності. Для звичайної перевірки фрагмента повідомлення з UI бери includes.
Чому '😀'.length дорівнює 2, а не 1?
Тому що length рахує не «символи», а одиниці кодування UTF-16. Більшість звичних символів займають одну одиницю, але емодзі та рідкісні символи — дві (сурогатна пара), тож '😀'.length дає 2. Якщо треба порахувати саме видимі символи, беруть ітератор рядка: [...'😀'].length поверне 1, бо ітерація йде по кодових точках. У тестах це вилазить, коли перевіряєш довжину поля, куди користувач вставив емодзі, або ріжеш рядок через slice і випадково розрубуєш пару навпіл, отримуючи «биті» символи.
Навіщо потрібні шаблонні літерали й чому об'єкт у них стає [object Object]?
Шаблонні літерали (template literals) пишуться у зворотних лапках і дозволяють вставляти вирази прямо в текст через долар із фігурними дужками, а також робити багаторядкові рядки без склейки плюсом. У тестовому коді це щоденний інструмент: динамічний URL ${base}/users/${id}, унікальний email qa+${Date.now()}@example.com, повідомлення з контекстом помилки. Усе всередині приводиться до рядка, і саме тут пастка: об'єкт перетвориться на [object Object], а не на свій вміст. Щоб побачити дані об'єкта, його треба обгорнути в JSON.stringify. Масив, до речі, склеїться через кому — теж не завжди те, що очікуєш побачити в логах.
Якими двома способами створюють регулярний вираз і коли який доречний?
Літералом між слешами /шаблон/флаги або конструктором new RegExp('шаблон', 'флаги'). Літерал компактніший і читабельніший, тому для сталих шаблонів беруть його. Конструктор потрібен, коли шаблон збирається динамічно з рядка — наприклад, підставляєш у нього значення з тестових даних. У конструкторі спецсимволи доводиться екранувати подвійно (\\d замість \d), бо рядок спершу обробляє парсер рядкового літерала. Тому якщо шаблон відомий заздалегідь, літерал майже завжди зручніший і безпечніший.
Що таке жадібність квантифікатора і як зробити його лінивим?
За замовчуванням квантифікатори (*, +, ?, {n,m}) жадібні (greedy) — хапають якнайбільше тексту. Вираз, що шукає щось у лапках через .*, на рядку з двома парами лапок захопить усе від першої до останньої лапки, а не окрему пару, бо .* тягнеться до кінця й лише потім віддає назад мінімум. Щоб зробити квантифікатор лінивим (lazy) — «бери якнайменше» — після нього ставлять ?: тоді збіг зупиниться на найближчій закривній лапці. Це найчастіша причина скарги «regex захоплює зайве». У тестах жадібність підводить, коли витягаєш перше значення з розмітки, а отримуєш усе аж до останнього однакового символу.
Чим test відрізняється від match і що поверне match із флагом g і без нього?
regex.test(str) повертає boolean — просто «чи є збіг», це основний інструмент валідації формату. str.match(regex) повертає самі збіги: без флага g — перший збіг разом із захопленими групами (масив, де [0] — увесь збіг, [1] — перша група), а з флагом g — плаский масив усіх збігів, але вже без груп. Коли потрібні одночасно і всі збіги, і їхні групи, беруть str.matchAll(regex) з обов'язковим g — він повертає ітератор об'єктів-збігів. Тобто вибір методу диктує потрібний вихід: факт (test), одне значення з групою (match без g), список (match з g) чи список із групами (matchAll).
Чому повторний test із флагом g на тому самому regex дає різний результат?
Об'єкт регулярного виразу з флагом g зберігає стан — властивість lastIndex, позицію, з якої почнеться наступний пошук. Після успішного test lastIndex зсувається за місце збігу, тож наступний виклик на тому самому об'єкті шукатиме далі по рядку й може повернути false, хоча збіг у рядку є. Це породжує «мерехтливі» баги, коли один regex перевикористовують у циклі чи між тестами. Для простої валідації формату флаг g просто не потрібен — без нього стану немає. Якщо g таки треба, або створюй regex заново на кожен виклик, або скидай lastIndex вручну.
Чому email не варто перевіряти однією регуляркою?
Повна специфікація адреси email настільки складна, що «правильний» за стандартом вираз виходить монструозним і все одно має винятки. На практиці таку перевірку регуляркою не роблять: у клієнтському тесті обмежуються грубою санітарною перевіркою на наявність собаки й крапки, а сувору валідацію лишають бекенду, який і так зобов'язаний її робити. Це загальний принцип: regex годиться для рядків простої, регулярної структури — ISO-дата, простий телефон, id у відомому форматі, — а не для того, що має складну граматику. З тієї ж причини регуляркою не розбирають JSON чи HTML: там працюють зі схемою або справжнім парсером.
Як regex економить час в асерціях і локаторах Playwright?
Коли частина тексту динамічна — число, дата, id — точне порівняння рядків крихке й червоніє на кожному новому значенні. Playwright приймає regex у більшості текстових асерцій, тож замість точного тексту пишеш патерн: toContainText(/замовлення №\d+ створено/i) пройде за будь-якого номера. Так само локатори за текстом приймають шаблон — getByRole('button', { name: /зберегти/i }) знайде кнопку незалежно від регістру чи змінної частини, і флаг i тут рятує від чутливості до регістру. Головне — тримати баланс: патерн має бути настільки жорстким, щоб ловити регресію, і настільки гнучким, щоб не падати на легітимній зміні даних.
Що насправді зберігає об'єкт Date усередині?
Одне число — кількість мілісекунд від епохи, тобто від 1 січня 1970 року, 00:00 UTC. Усі методи Date — це лише способи відформатувати чи розібрати це число. Через таке представлення дати найнадійніше порівнювати саме як числа: a.getTime() === b.getTime(), а різницю двох дат рахувати звичайним відніманням мілісекунд. Розуміння, що всередині просто число в UTC, одразу пояснює, чому локальні методи форматування можуть показувати іншу календарну дату, ніж toISOString().
Чому дати в API й тестах передають у форматі ISO 8601?
ISO 8601 (2026-07-18T10:30:00.000Z) — єдиний формат, який специфікація мови зобов'язує розбирати однаково в будь-якому рушії; літера Z наприкінці означає UTC. Будь-який інший запис ("07/18/2026", "18.07.2026") розбирається на розсуд конкретного рушія: десь спрацює, десь дасть Invalid Date, а десь переплутає день і місяць. Тобто той самий рядок дати може дати різний результат у браузері й у Node, і тест почне «плавати» між середовищами. Правило просте: і передавай, і порівнюй дати в ISO 8601, а на екрані форматуй уже під локаль користувача.
Чому new Date('...') === new Date('...') завжди false і як тоді порівнювати дати?
Оператор === для об'єктів перевіряє не значення, а ідентичність посилання — чи це буквально той самий об'єкт у пам'яті. Два окремих new Date(...) — це два різних об'єкти, тож === між ними завжди false, навіть якщо вони описують ту саму мить. Порівнювати треба примітиви, які вони представляють: a.getTime() === b.getTime() або a.toISOString() === b.toISOString(). У Jest/Playwright під це є матчер toEqual, але для дат надійніше явно звести до числа чи ISO-рядка. Це та сама пастка, що й порівняння двох об'єктів або масивів через ===.
Яка пастка ховається в конструкторі new Date(2026, 6, 18)?
У покомпонентному конструкторі місяць рахується від нуля: 0 — січень, 6 — липень, 11 — грудень. Тому new Date(2026, 6, 18) — це 18 липня, а не червня, і саме тут люди помиляються на місяць. Так само getMonth() повертає число від 0 до 11, тож для показу до нього додають 1. День місяця й рік, до речі, рахуються звично — від одиниці й повного числа, — що робить нульовий місяць ще підступнішим. Сусідня пастка: Date змінний, і setDate/setMonth мутують сам об'єкт, а не повертають новий, тож спільну дату легко зіпсувати між тестами.
Чому тест на датах буває зелений удень і червоний уночі або в CI?
Бо одна й та сама мить часу має різну календарну дату залежно від таймзони. toISOString() завжди віддає час у UTC, а локальні методи (getDate(), getHours(), toLocaleString()) — у таймзоні машини, де біжить код. Ноутбук у Києві живе в UTC+2/+3 і випереджає UTC, а CI-агент майже завжди в самому UTC. Тому в перші години після місцевої півночі локальна дата вже нова, а в UTC ще триває попередня доба: о 01:30 за Києвом у UTC це ще вчора, 22:30. Асерція, що порівнює локальну дату з датою в UTC, збігається вдень і розходиться саме в ці нічні години — звідси «падає лише вночі».
Як правильно лікувати таймзонні падіння тестів?
Найперше — не змішувати системи відліку в одній асерції: якщо бекенд віддає UTC, то й у тесті бери toISOString() чи getTime(), а не локальні getDate/getHours. Найнадійніше — зафіксувати («заморозити») час, бо тест, який залежить від реального «сьогодні», недетермінований за визначенням; у Playwright для цього є page.clock.setFixedTime(...), у юніт-тестах час мокають фейковими таймерами. Тоді «сьогодні» завжди те саме, і межа доби вже не має значення. І окреме правило: не обчислюй очікувану дату тим самим кодом, що й продукт, — інакше тест повторить його баг; краще підставити дату як фікстуру. Затримками (sleep) це не лікується — вони лише маскують проблему.
Що робить parseInt і навіщо йому другий аргумент?
parseInt(str, radix) розбирає ціле число з початку рядка й зупиняється на першому нецифровому символі: parseInt('10px', 10) дасть 10, а parseInt('px10', 10) — NaN. Другий аргумент — основа системи числення, і його варто передавати завжди (зазвичай 10): без нього рядок, що починається з 0x, буде розібрано як шістнадцятковий, а історично провідний нуль міг означати вісімкове число. Якщо потрібне дробове — це parseFloat; якщо треба сувора перевірка «весь рядок є числом» — краще Number(str), бо на смітті він дає NaN замість мовчазного обрізання, як у parseInt. Тобто вибір між ними — це вибір між «витягни число з початку» і «весь рядок мусить бути числом».
(2).toFixed(2) повертає число чи рядок і чому це важливо в асерціях?
toFixed(n) округлює до n знаків після коми й повертає рядок, а не число: (2).toFixed(2) дає "2.00". Тому наступне === 2 провалиться — порівнюються різні типи, рядок і число. Це часта пастка при звірці суми з UI: або порівнюй рядок із рядком (обидва форматовані), або число з числом (обидва як числа), не змішуючи. Друга тонкість — округлення тут не завжди «математичне»: (1.005).toFixed(2) дає "1.00", а не "1.01", бо 1.005 насправді зберігається як трохи менше число. Висновок: toFixed — інструмент показу, а не розрахунку.
Чому 0.1 + 0.2 !== 0.3 і як із цим жити в тестах?
Числа в JavaScript — це числа з рухомою комою подвійної точності за стандартом IEEE 754 (той самий тип, що double). У двійковій системі дроби 0.1 і 0.2 не мають скінченного точного представлення — так само, як 1/3 не записати скінченним десятковим дробом. Тому вони зберігаються з крихітною похибкою, і сума дає 0.30000000000000004. Це не баг мови — так поводяться майже всі мови з типом double. Практичний висновок: дробові числа ніколи не порівнюють на точну рівність — у Jest/Playwright беруть toBeCloseTo, у ручній логіці перевіряють, що різниця менша за малий поріг (epsilon), а гроші взагалі рахують цілими копійками.
Три кейси, де рядок, дата чи число вирішують, зелений тест чи полювання за примарою: датa, що «переїжджає» між локальним часом і UTC уночі; regex, який захоплює зайве й тягне за собою стан lastIndex; і сума, що не сходиться через toFixed та IEEE 754. Скрізь — що зробити й чому.
Кейс 1. «Дата на день раніше»: зелено вдень, червоно вночі
Симптом упізнаваний: тест на дату створення тижнями зелений, а якось у нічному прогоні CI (або якщо запустити о першій ночі локально) червоніє, хоч код не мінявся. Причина майже завжди — очікувану дату обчислюють локальним часом, а продукт показує UTC.
// бекенд віддав дату створення в UTC, продукт показав "2026-07-18"
const shown = (await page.locator('.created-date').innerText()).trim();
// ХИБНО: очікуване "сьогодні" беремо з локального годинника машини
const now = new Date();
const expected =
`${now.getFullYear()}-` +
`${String(now.getMonth() + 1).padStart(2, '0')}-` +
`${String(now.getDate()).padStart(2, '0')}`;
expect(shown).toBe(expected);
Удень у Києві локальна календарна дата збігається з UTC — асерція зелена. Але о 01:30 за Києвом getDate() уже віддає 19, тоді як у UTC (і в бекенда) ще триває 18. Два рядки розходяться на день, і тест падає «на рівному місці». Лікування — прибрати залежність від реального годинника, заморозивши час:
// час зафіксовано — "сьогодні" більше не залежить від того, коли біжить тест
await page.clock.setFixedTime(new Date('2026-07-18T09:00:00Z'));
await page.goto('/orders/new');
// ...сценарій створення замовлення...
await expect(page.locator('.created-date')).toHaveText('2026-07-18');
Що дивитися і чому:
getMonth() + 1— не косметика, а виправлення нульового місяця.getMonth()віддає 0–11, тож без+1липень став би червнем. День і рік такої поправки не потребують — це робить пастку особливо підступною.- Не змішуй UTC і локальний час в одній асерції. Продукт показує UTC — бери
toISOString(), а неgetDate(). Розходяться вони рівно на межі доби, тому баг і «нічний». - Заморожений час детермінований,
sleep— ні. Фіксація прибирає межу доби взагалі; штучна затримка нічого не гарантує й лише додає флаку. - Не рахуй очікувану дату тим самим кодом, що й продукт. Інакше тест повторить його баг. Надійніше підставити дату як фікстуру, як
2026-07-18вище.
Кейс 2. Regex: жадібність, стан lastIndex і асерція за патерном
Regex економить час на динамічному тексті, але має дві типові пастки — жадібний квантифікатор і стан при флазі g. Спершу — витягання значення з розмітки:
const markup = '<b>"Готово"</b> та <b>"Скасовано"</b>';
markup.match(/"(.*)"/)?.[1]; // 'Готово"</b> та <b>"Скасовано' — жадібно, захопило все
markup.match(/"(.*?)"/)?.[1]; // 'Готово' — лінивий квантифікатор бере до першої лапки
Коли треба всі збіги разом із групами, беруть matchAll (обов'язково з g):
const path = '/orders/10582/items /orders/10600/items';
const ids = [...path.matchAll(/orders\/(\d+)/g)].map((m) => m[1]); // ['10582', '10600']
А ось пастка стану — той самий об'єкт regex із g «пам'ятає» позицію:
const re = /\d+/g;
re.test('id 42'); // true
re.test('id 42'); // false — lastIndex лишився в кінці рядка від попереднього виклику
У Playwright-асерціях патерн рятує, коли частина тексту змінна:
await expect(page.locator('.toast')).toContainText(/замовлення №\d+ створено/i);
await expect(page.locator('.balance')).toHaveText(/^Баланс: \d[\d\s]*,\d{2} грн$/);
Що дивитися і чому:
.*жадібний за замовчуванням. На повторюваному символі (лапки, теги) він тягнеться до останнього збігу;?після квантифікатора робить його лінивим і зупиняє на найближчому.- Для валідації флаг
gне потрібен. Він додає станlastIndex, через який повторнийtestбреше. Хочеш просту перевірку формату — пиши regex безg. matchAllвимагаєg. Без нього кине помилку; він і потрібен саме тоді, коли треба і список збігів, і їхні захоплені групи.- Патерн має бути в міру жорстким.
\d+замість конкретного числа ловить регресію й не червоніє на новому номері — але не роби його настільки вільним, щоб він пропускав справжню поломку.
Кейс 3. Гроші й дробові: чому асерція на суму бреше
Дві пастки в одному місці: toFixed повертає рядок, а 0.1 + 0.2 не дорівнює 0.3. Обидві дають червоний тест, який виглядає як «математика зламалась».
const price = 0.1 + 0.2;
expect(price).toBe(0.3); // ПАДАЄ: 0.30000000000000004
const total = (2).toFixed(2); // "2.00" — це РЯДОК, не число
expect(total).toBe(2); // ПАДАЄ: "2.00" !== 2 (різні типи)
Правильні варіанти залежать від того, що з чим порівнюєш:
expect(0.1 + 0.2).toBeCloseTo(0.3); // дробове — з допуском
expect(Number((2).toFixed(2))).toBe(2); // якщо потрібне число — явно зведи назад
const shown = (await page.locator('.total').innerText()).trim(); // "2,00"
expect(shown).toBe('2,00'); // рядок з UI — з форматованим рядком
| Що порівнюєш | Як правильно |
|---|---|
| дробове з дробовим | toBeCloseTo або поріг epsilon |
| суму з UI (текст) | рядок із рядком, обидва форматовані однаково |
| гроші в розрахунках | цілі копійки/центи або спеціальний decimal-тип |
результат toFixed | пам'ятай: це рядок і лише для показу |
Що дивитися і чому:
toFixedповертає рядок."2.00" === 2завждиfalse— типи різні. Або зводь назад черезNumber(...), або порівнюй рядок із рядком, не змішуючи.0.1 + 0.2— це IEEE 754, а не баг мови. Точна рівність із0.3неможлива;toBeCloseToпорівнює з допуском і саме для цього й існує.- Гроші не тримай у
float. Рахуй у найдрібнішій одиниці цілими числами або decimal — тому й у базі суму зберігають якDECIMAL, а неFLOAT. - Округлення
toFixedнепередбачуване на межі.(1.005).toFixed(2)дає"1.00", бо1.005зберігається трохи меншим — ще одна причина не вести на ньому розрахунки.
Рядки та шаблонні літерали
- Розумію, що рядок незмінний:
trim(),replace(),toLowerCase()повертають новий рядок, тож без присвоєння результату нічого не міняється. - Знаю різницю
includes(даєboolean, читабельний в асерціях) vsindexOf(позиція або-1). - Можу пояснити, чому
'😀'.lengthдорівнює 2 (одиниці UTF-16) і як порахувати видимі символи через[...str]. - Розумію, що в шаблонному літералі об'єкт перетворюється на
[object Object], тож його треба обгортати вJSON.stringify.
Регулярні вирази
- Знаю два способи створення (літерал
/.../vsnew RegExp('...')) і коли потрібен саме конструктор. - Можу пояснити флаги
g,i,m,s,u— що кожен змінює. - Знаю різницю
test(boolean) vsmatchvsmatchAllі щоmatchізgвіддає всі збіги без груп, а безg— перший збіг із групами. - Розумію жадібність (greedy) vs лінивість (lazy) і як
?після квантифікатора робить його лінивим. - Знаю пастку
lastIndex:testіз флагомgна тому самому об'єкті дає різний результат при повторних викликах. - Розумію, чому складні формати (email) не валідують регуляркою, а лишають бекенду чи схемі.
Regex у роботі AQA
- Можу застосувати regex в асерціях Playwright (
toContainText(/.../)), коли частина тексту динамічна. - Знаю, що локатори за текстом приймають шаблон (
getByRole('button', { name: /.../i })) і навіщо тут флагi.
Date та ISO 8601
- Розумію, що
Dateусередині — мілісекунди від епохи (1970 UTC), тож===між двомаDateзавждиfalse; порівнювати треба черезgetTime(). - Можу пояснити, чому дати передають в ISO 8601 і що
Zнаприкінці означає UTC. - Знаю дві пастки
Date: місяць уnew Date(y, m, d)іgetMonth()рахується від нуля, а сам об'єкт змінний (setDateмутує).
Таймзони
- Можу пояснити «тест падає лише вночі»:
toISOString()дає UTC, локальні методи — таймзону машини, і на межі доби вони розходяться. - Знаю рецепт лікування: не змішувати UTC і локальний час, заморожувати час (
page.clock.setFixedTime), підставляти дату як фікстуру, а не глушитиsleep.
Числа й точність
- Знаю, що
parseIntзупиняється на першому нецифровому символі, навіщо radix10, і чим від нього різнятьсяparseFloatтаNumber. - Розумію, що
toFixedповертає рядок і округлює непередбачувано на межі ((1.005).toFixed(2)→"1.00"). - Можу пояснити, чому
0.1 + 0.2 !== 0.3(IEEE 754, не баг мови), тож дробові порівнюють черезtoBeCloseTo/epsilon, а гроші рахують цілими копійками чи decimal.
Чому виклик text.trim() окремим рядком не прибирає пробіли з рядка?
Питання
Рядок незмінний (immutable) — що це означає на практиці?