QA Interview Hub · База знань · Тести
TypeScript
JS-пастки: числа і гроші, рядки, дати, обʼєкти й масиви
Числа з плаваючою крапкою і гроші
number у JS - це 64-бітне число з плаваючою крапкою (IEEE 754). Десяткові дроби на кшталт 0.1 не мають точного двійкового представлення.
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
1.005 * 100; // 100.49999999999999
(1.005).toFixed(2); // '1.00', а не '1.01'
toFixed округлює двійкове значення, а не те десяткове, яке ви бачите в коді, тому результат буває неочікуваним. Крім того, toFixed повертає рядок.
Як правильно працювати з грошима:
- Зберігати суми в мінорних одиницях (копійки, центи) як цілі числа:
10050 замість 100.50. Цілі числа до Number.MAX_SAFE_INTEGER (2^53 - 1) представлені точно. - Знати кількість мінорних одиниць валюти: у більшості 2, але в JPY 0, а в деяких валютах 3 (наприклад, KWD).
- Для дробових розрахунків (відсотки, курси, комісії) використовувати decimal-бібліотеку (
decimal.js, big.js) з явним режимом округлення. Банки часто використовують банківське округлення (half-even), і тест має знати, яке саме правило в специфікації. - Для дуже великих цілих -
BigInt, але він не змішується з number в арифметиці.
const toMinor = (amount: string): number => Math.round(Number(amount) * 100);
// краще через бібліотеку:
// new Decimal('100.505').toDecimalPlaces(2, Decimal.ROUND_HALF_EVEN)
У тестах: порівнюю суми в копійках через toBe, а не дробові через toBe. Якщо треба порівняти дробові значення, використовую toBeCloseTo з точністю. Для відображення - Intl.NumberFormat, а не ручну конкатенацію.
На співбесіді: 0.1 + 0.2 у JS не дорівнює 0.3 через двійкове представлення, і toFixed теж округлює неточно. Тому гроші я порівнюю в мінорних одиницях як цілі числа, а комісії і курси рахую decimal-бібліотекою з явним правилом округлення зі специфікації.
Перетворення рядків у числа
Є кілька способів перетворити рядок на число, і вони поводяться по-різному:
Number('42'); // 42
Number(''); // 0 - пастка!
Number(' '); // 0
Number('12px'); // NaN
Number('1e3'); // 1000
Number(null); // 0
Number(undefined); // NaN
parseInt('12px', 10); // 12 - бере префікс
parseInt('1e3', 10); // 1
parseFloat('1,5'); // 1 - кома не роздільник
parseFloat('1 000.50');// 1
Ключові пастки:
Number('') повертає 0. Порожнє поле суми на UI перетвориться на нуль, і тест може "успішно" перевірити нульову суму.parseInt і parseFloat мовчки відкидають все після першого невалідного символу. Сума '1 250,50 грн' з UI стане 1.parseInt завжди викликаю з основою 10.['1', '2', '3'].map(parseInt) дає [1, NaN, NaN], бо map передає індекс як другий аргумент (основу).NaN !== NaN. Перевірка - Number.isNaN(x), а не x === NaN.
Для сум з UI я пишу явний парсер під конкретний формат: прибираю пробіли (включно з нерозривним пробілом, який Intl.NumberFormat ставить як роздільник тисяч), валюту, замінюю кому на крапку, перевіряю регуляркою і тільки потім конвертую. А ще краще - порівнювати з очікуваним рядком, відформатованим тим самим Intl.NumberFormat з потрібною локаллю.
function parseUiAmount(text: string): number {
const clean = text.replace(/[\s ]/g, '').replace('грн', '').replace(',', '.');
if (!/^-?\d+(\.\d{1,2})?$/.test(clean)) throw new Error(`Bad amount: ${text}`);
return Math.round(Number(clean) * 100); // копійки
}
Мій принцип для сум з UI: парсер має падати на неочікуваному форматі, а не повертати частину числа.
На співбесіді: Number('') дає нуль, а parseInt мовчки обрізає рядок до першого невалідного символу, тому для сум з UI я пишу суворий парсер, який падає на неочікуваному форматі. NaN перевіряю через Number.isNaN, а parseInt викликаю тільки з основою 10.
Порівняння: == і ===, ||, ?? і ?.
== виконує приведення типів перед порівнянням, === - ні. Правила приведення неінтуїтивні:
'' == 0; // true
'0' == false; // true
null == undefined; // true
null == 0; // false
NaN == NaN; // false
[1] == 1; // true
Я завжди використовую ===. Єдиний поширений виняток - x == null, що перевіряє одночасно null і undefined, але і там я віддаю перевагу явності. ESLint правило eqeqeq це контролює. TS частково ловить безглузді порівняння різних типів, але не всі.
Оператор || повертає правий операнд, якщо лівий falsy. Falsy значення: false, 0, -0, 0n, '', null, undefined, NaN. Тому || для значень за замовчуванням ламає валідні нулі і порожні рядки:
const fee = response.fee || 500; // fee = 0 стане 500 - баг
const fee2 = response.fee ?? 500; // 0 залишиться 0
const timeout = Number(env.TIMEOUT) || 30_000; // тут || свідомо ловить і NaN
?? (nullish coalescing) підставляє значення тільки для null і undefined. Для грошей, лімітів, лічильників це критична різниця: нульова комісія - валідний бізнес-кейс.
?. (optional chaining) зупиняє обчислення і повертає undefined, якщо ліва частина null або undefined: res.data?.items?.[0]?.amount. Пастка в тестах: надмірний ?. маскує проблему. Якщо items має бути завжди, то expect(body.data?.items?.length).toBeFalsy() пройде і для порожнього масиву, і для відсутнього поля. Тому я спочатку явно перевіряю структуру, а потім значення.
На співбесіді: Я завжди використовую ===, бо == робить неочевидне приведення типів. Для значень за замовчуванням беру ??, а не ||, бо || замінить валідний нуль, наприклад нульову комісію, на дефолт.
Рядки, дати, мутації і сортування
Рядки. Оператори < і > порівнюють рядки по кодах UTF-16: 'B' < 'a' - true, '10' < '9' - true, а українські літери з і, ї, є стоять не на місцях алфавіту. Для перевірки сортування на UI використовую a.localeCompare(b, 'uk') або Intl.Collator. Для натурального сортування чисел у рядках - опція { numeric: true }.
Сортування. Array.prototype.sort() без компаратора сортує як рядки і змінює масив на місці:
[10, 1, 2].sort(); // [1, 10, 2]
[10, 1, 2].sort((a, b) => a - b); // [1, 2, 10]
const sorted = amounts.toSorted((a, b) => a - b); // ES2023, без мутації
Дати. Місяці в Date нумеруються з нуля: new Date(2026, 0, 15) - це 15 січня. Рядок тільки з датою new Date('2026-01-15') парситься як UTC, а з датою і часом без зони new Date('2026-01-15T10:00') - як локальний час. getDate() повертає локальний день, і тест, що працює в Києві, падає на CI в UTC біля півночі. Переповнення: 31 січня плюс setMonth(1) дає березень. Мої правила: обмінюватися датами в ISO 8601 з Z або зсувом, фіксувати TZ у CI і в playwright.config.ts через timezoneId, для календарної логіки брати бібліотеку (date-fns, Luxon), а час у тестах контролювати через page.clock.
Мутації і копії. Об'єкти і масиви передаються за посиланням. Spread { ...obj } і [...arr] роблять поверхневу копію: вкладені об'єкти спільні.
const base = { amount: 100, meta: { tags: ['a'] } };
const copy = { ...base };
copy.meta.tags.push('b'); // змінили і base
const deep = structuredClone(base); // глибока копія, Node 17+
structuredClone не копіює функції (кидає помилку) і втрачає прототип класу. JSON.parse(JSON.stringify(x)) втрачає undefined, перетворює Date на рядок і ламається на BigInt.
На співбесіді: sort() без компаратора сортує як рядки і мутує масив, тому я передаю компаратор або використовую toSorted. Місяці в Date з нуля, а часові зони я фіксую в CI. Spread робить лише поверхневу копію, для глибокої беру structuredClone.