08 · JavaScript/TypeScript для AQA
Об'єкти, масиви та методи роботи з даними
Зміст
Робота AQA — це переважно робота з даними. Відповідь API прилітає обʼєктом, тестові дані лежать масивом фікстур, локатор віддає колекцію елементів, а перевірка зводиться до «дістань з цієї структури потрібне поле й порівняй». Обʼєкти, масиви та методи роботи з ними — це той інструмент, яким ти щодня розбираєш і збираєш дані у тестовому коді. Не знаєш його — пишеш цикли на пів екрана там, де вистачило б одного filter, або, гірше, ловиш плаваючий баг, бо «скопіював» обʼєкт, а насправді лишив на нього посилання.
Саме тут ховається найпідступніше питання цієї глави, яке обожнюють на співбесідах: чому {} !== {} і чому «копія» масиву раптом змінює оригінал. Відповідь — у посилальній семантиці (reference semantics), і без неї решта методів залишаються магією. Тож почнемо з механізму, а не з переліку функцій.
Значення vs посилання: чому {} не дорівнює {}
У JavaScript є два світи. Примітиви (primitives) — рядки, числа, boolean, null, undefined, symbol, bigint — зберігаються й копіюються за значенням. Обʼєкти ж (а масив, функція, Date, Map — усе це обʼєкти) зберігаються за посиланням (by reference): змінна тримає не сам обʼєкт, а «адресу», за якою він лежить у памʼяті.
Звідси все й випливає. Кожен літерал {} чи [] створює новий обʼєкт за новою адресою. Оператори === і !== для обʼєктів порівнюють не вміст, а посилання — чи це фізично той самий обʼєкт.
const a = { id: 1 };
const b = { id: 1 };
const c = a;
a === b; // false — різні обʼєкти, хоч вміст однаковий
a === c; // true — c тримає те саме посилання, що й a
Практичний наслідок для тестів: обʼєкти й масиви не можна порівнювати через === чи toBe. expect(actual).toBe(expected) для двох різних обʼєктів завжди впаде, навіть якщо вони ідентичні. Для порівняння за вмістом існують toEqual (Playwright, Jest) або глибокі порівняння в бібліотеках асертів. І навпаки: якщо дві змінні тримають одне посилання, зміна через одну «магічно» видно через іншу — це не баг, це та сама причина.
Глибше про передачу за значенням vs за посиланням і про == vs === — у главі Змінні, типи даних і приведення типів.
Безпечний доступ: ?. і ??
Відповідь API рідко буває ідеальною: поле може бути null, вкладений обʼєкт — відсутнім, масив — порожнім. Спроба дістати user.profile.avatar.url, коли profile дорівнює null, валить тест не асертом, а падінням: Cannot read properties of null. Класичний захист «якщо є, то візьми» роздувався у драбину перевірок user && user.profile && user.profile.avatar.
Optional chaining (?.) розвʼязує це одним оператором. Якщо ліворуч від ?. стоїть null або undefined — весь вираз коротко замикається (short-circuit) й повертає undefined, не намагаючись читати далі.
const url = response.user?.profile?.avatar?.url; // undefined, а не падіння
response.items?.[0]; // безпечний доступ до елемента масиву
response.onLoad?.(); // виклик методу, лише якщо він існує
Ключова тонкість: ?. реагує саме на null/undefined, а не на «порожнє». 0, порожній рядок, false — валідні значення, ланцюжок їх пропускає далі.
Поруч працює nullish coalescing (??) — «підстав дефолт, якщо ліворуч null або undefined». Його часто плутають з ||, і це джерело реальних багів. || спрацьовує на будь-яке хибне (falsy) значення, а до них належать і 0, і '', і false:
const timeout = config.timeout ?? 30_000; // 0 лишається 0
const timeoutBug = config.timeout || 30_000; // 0 підміниться на 30_000 — баг
Якщо config.timeout дорівнює 0 (миттєвий таймаут — цілком валідне налаштування), || мовчки замінить його на 30 секунд. ?? цього не робить, бо 0 — не null і не undefined. Правило: для дефолтів, де 0/''/false — легітимні значення, бери ??, а не ||.
Деструктуризація
Деструктуризація (destructuring) — це розпакування полів обʼєкта чи елементів масиву в окремі змінні одним рядком. Вона не робить нічого нового щодо семантики, лише скорочує шум і робить наміри явними.
const { status, body } = await request.get('/api/users');
const { id, name = 'anonymous' } = body; // дефолт, якщо name === undefined
const { profile: { city } } = body; // вкладена
const [first, second] = await Promise.all([a, b]); // масивна, за позицією
Найчастіше AQA бачить її у сигнатурах фікстур Playwright: async ({ page, request }) => {} — це деструктуризація обʼєкта фікстур у параметрі функції. Дефолт = 'anonymous' підставляється лише коли значення суворо undefined (не null, не 0). Перейменування profile: { city } дістає вкладене поле й одразу дає йому імʼя. Механіку параметрів функцій розбираємо у главі Функції, замикання та область видимості.
Spread і rest: копіювання й збирання
Три крапки ... роблять дві протилежні речі залежно від контексту.
Spread — «розсип» елементи існуючої структури в нову. Це основний спосіб зробити копію масиву чи обʼєкта й найпоширеніший спосіб зібрати нову структуру з існуючих:
const copy = [...items]; // нова копія масиву
const merged = { ...defaults, ...overrides }; // злиття; праве перекриває ліве
const withExtra = [...base, newItem]; // base не мутується
Rest — навпаки, «збери решту» в один масив чи обʼєкт при деструктуризації або в параметрах функції:
const [head, ...tail] = items; // head — перший, tail — масив решти
const { id, ...rest } = user; // rest — обʼєкт без поля id
function log(...args: unknown[]) {} // будь-яка кількість аргументів
Критичне застереження, яке веде нас до наступної теми: spread робить поверхневу копію. Верхній рівень новий, а вкладені обʼєкти — ті самі за посиланням.
Методи масивів: map / filter / reduce / find / some / every
Це серце обробки даних і найчастіша тема live coding. Усі шість приймають функцію-колбек і не мутують вихідний масив — вони повертають нове значення (reduce згортає масив в одне значення, решта — новий масив, елемент або boolean). Це робить їх безпечними, а map/filter — ще й ланцюжковими.
| Метод | Що повертає | Навіщо |
|---|---|---|
map | новий масив тієї ж довжини | перетворити кожен елемент |
filter | новий масив ⩽ довжини | лишити елементи, що проходять умову |
reduce | одне значення | згорнути масив у число/обʼєкт/рядок |
find | перший елемент або undefined | знайти один за умовою |
some | boolean | чи є хоч один, що проходить умову |
every | boolean | чи всі проходять умову |
На реальних тестових даних це виглядає так:
const users = [
{ name: 'Ann', role: 'admin', active: true },
{ name: 'Bob', role: 'user', active: false },
{ name: 'Cid', role: 'user', active: true },
];
const activeNames = users.filter((u) => u.active).map((u) => u.name); // ['Ann', 'Cid']
const hasAdmin = users.some((u) => u.role === 'admin'); // true
const allActive = users.every((u) => u.active); // false
const bob = users.find((u) => u.name === 'Bob'); // обʼєкт Bob
const total = users.reduce((acc, u) => acc + (u.active ? 1 : 0), 0); // 2
reduce лякає новачків, але механізм простий: акумулятор (acc) — це «підсумок, що переноситься з кроку на крок», а другий аргумент (0) — його початкове значення. Ним будують і числа, і згруповані обʼєкти (наприклад, розкласти масив за роллю в обʼєкт role → список). Важлива пастка: reduce без початкового значення на порожньому масиві кидає помилку — завжди задавай початок явно.
Ці методи — те, чим AQA щодня перетворює відповідь API в очікуваний для асерту вигляд: витягнути id усіх елементів (map), відкинути видалені (filter), перевірити, що всі повернуті записи належать одному користувачу (every).
sort і мутація: тиха пастка
А ось sort поводиться інакше за попередні методи, і на цьому провалюються навіть досвідчені. sort мутує масив на місці й повертає посилання на той самий масив, а не на копію.
const original = [3, 1, 2];
const sorted = original.sort();
sorted === original; // true — це один масив; original теж уже [1, 2, 3]
Друга пастка — компаратор за замовчуванням. Без функції порівняння sort перетворює елементи на рядки й сортує лексикографічно. Тому числа сортуються неправильно:
[1, 2, 10, 21].sort(); // [1, 10, 2, 21] — рядкове порівняння!
[1, 2, 10, 21].sort((a, b) => a - b); // [1, 2, 10, 21] — правильно
Компаратор має повертати відʼємне число (a перед b), додатне (b перед a) або нуль. Для чисел це a - b; для рядків — localeCompare, який ще й коректно впорається з українськими літерами.
Загальне правило корисно тримати в голові — які методи мутують масив, а які повертають новий:
| Мутують оригінал | Повертають нове (безпечні) |
|---|---|
sort, reverse, splice, push, pop, shift, fill | map, filter, slice, concat, flat, toSorted |
Примітка: toSorted, toReversed, toSpliced, with — немутуючі версії, доступні в сучасних середовищах (Node 20+). Якщо треба відсортувати, не чіпаючи джерело, у старіших середовищах роби копію перед сортуванням: [...original].sort(...).
Set і Map
Обʼєкт і масив — не єдині колекції. Set і Map розвʼязують задачі, де вони незручні.
Set — колекція унікальних значень. Найчастіше AQA бере його для дедуплікації й перевірки унікальності (класика: чи немає дублікатів id у відповіді):
const ids = users.map((u) => u.id);
const unique = [...new Set(ids)]; // дублікати прибрано
unique.length === ids.length; // true, якщо дублікатів не було
new Set(ids).has(42); // швидка перевірка наявності
Map — колекція пар «ключ → значення», де ключем може бути будь-що (зокрема обʼєкт), а не лише рядок. На відміну від звичайного обʼєкта, Map гарантує порядок вставлення, має чесний .size і не має успадкованих ключів на кшталт toString.
const byId = new Map(users.map((u) => [u.id, u]));
byId.get(2); // обʼєкт користувача з id === 2
byId.has(99); // false
byId.size; // кількість записів
Коли обʼєкт, а коли Map? Обʼєкт — для фіксованої, відомої наперед структури (конфіг, DTO). Map — для динамічного словника, який часто наповнюється, читається за ключем і де важливі порядок, розмір чи нерядкові ключі.
Копіювання: поверхнева vs глибока
Найдорожчий за наслідками розділ. Усі звичні способи копіювання — spread, Object.assign, Array.from — роблять поверхневу копію (shallow copy): новий контейнер верхнього рівня, але вкладені обʼєкти лишаються спільними за посиланням.
const original = { name: 'test', config: { retries: 2 } };
const shallow = { ...original };
shallow.name = 'other'; // ок, original.name не змінився
shallow.config.retries = 5; // а це змінило й original.config.retries!
original.config.retries; // 5 — вкладений обʼєкт спільний
Глибока копія (deep copy) рекурсивно клонує всі рівні — жодного спільного посилання. Сучасний і правильний спосіб — вбудований structuredClone:
const deep = structuredClone(original);
deep.config.retries = 5; // оригінал недоторканий
structuredClone доступний у браузерах і в Node 17+, коректно клонує вкладені обʼєкти, масиви, Date, Map, Set, навіть циклічні посилання. Обмеження: не клонує функції й прототипи класів (кине помилку на функції).
Старий трюк — JSON.parse(JSON.stringify(obj)) — теж дає глибоку копію, але тихо втрачає й спотворює дані, тому як універсальний метод небезпечний. Про це — далі.
JSON.parse і JSON.stringify
Це міст між обʼєктами в памʼяті й текстом: JSON.stringify серіалізує обʼєкт у рядок (тіло запиту, збереження у файл, лог), JSON.parse розбирає рядок назад в обʼєкт (відповідь сервера). У тестах ти постійно робиш і те, і те, часто неявно — HTTP-клієнт серіалізує тіло за тебе.
Небезпека в тому, що втрачається при серіалізації, бо JSON знає лише обʼєкти, масиви, рядки, числа, boolean і null:
JSON.stringify({ a: undefined, b: () => 1, c: NaN, d: new Date(), e: new Set([1]) });
// {"c":null,"d":"2026-07-18T...","e":{}}
Що відбувається по пунктах:
undefinedі функції зникають з обʼєкта (у масивіundefinedстаєnull).NaNтаInfinityперетворюються наnull.Dateстає рядком ISO 8601 — назадparseповерне рядок, а неDate.Map,Setперетворюються на порожній{}— вміст втрачено.- Циклічне посилання кидає
TypeError. BigIntкидає помилку серіалізації.
Тому JSON.parse(JSON.stringify(x)) як «глибока копія» руйнує будь-що складніше за примітивні поля. Для копіювання бери structuredClone; JSON лишай для того, для чого він є, — обміну текстом.
Два корисні аргументи stringify: третій — відступ для читабельного логу (JSON.stringify(obj, null, 2)), другий — replacer-функція або список полів для фільтрації (зручно приховати токен у логах). У JSON.parse симетрично є reviver для пост-обробки — ним, наприклад, перетворюють рядки-дати назад у Date.
Типові помилки
Виглядає як «порівняв обʼєкти», а насправді порівняв посилання. expect(actual).toBe({ id: 1 }) завжди падає — toBe/=== для обʼєктів звіряють адресу, а не вміст. Для вмісту — toEqual.
Виглядає як «скопіював масив», а насправді дав друге імʼя тому самому. const copy = original не копіює — обидві змінні тримають одне посилання, і copy.push() міняє original. Копія — це [...original] або structuredClone.
Виглядає як «дефолт спрацює лише коли значення немає», а насправді || зʼїдає 0 і ''. port || 3000 підмінить валідний 0. Для «немає значення» бери ??.
Виглядає як «sort повернув відсортовану копію», а насправді змінив оригінал і зіпсував порядок чисел. sort мутує масив, а без компаратора сортує як рядки: [1, 10, 2]. Завжди передавай (a, b) => a - b і копіюй джерело, якщо його не можна чіпати.
Виглядає як «?. захистить від усього», а насправді лише від null/undefined. На порожньому рядку чи 0 ланцюжок піде далі — це не «відсутність».
Виглядає як «глибоко скопіював через JSON», а насправді втратив Date, undefined, Map і функції. JSON.parse(JSON.stringify(x)) — не універсальна глибока копія. Бери structuredClone.
Підсумок
- Примітиви живуть за значенням, обʼєкти — за посиланням;
===для обʼєктів звіряє посилання, тому{} !== {}, а асерти вмісту — цеtoEqual, неtoBe. ?.коротко замикається лише наnull/undefined;??підставляє дефолт лише дляnull/undefined, тоді як||спрацьовує на будь-яке falsy.map/filter/reduce/find/some/everyне мутують джерело й ланцюжкуються;sort,reverse,splice— мутують масив на місці.- Spread,
Object.assign, JSON-трюк — поверхнева копія зі спільними вкладеними обʼєктами; справжня глибока копія —structuredClone. JSON.stringifyгубитьundefined, функції,Map/Setі перетворюєDateна рядок — це формат обміну, а не інструмент копіювання.
Що питають на співбесіді
- «Чому
{} === {}даєfalse?» — очікують згадку про посилальну семантику: кожен літерал — новий обʼєкт за новою адресою,===звіряє адресу, а не вміст. Сильний кандидат одразу додасть, що для порівняння за вмістом у тестах єtoEqual. - «Чим
??відрізняється від||?» — інтервʼюер перевіряє розуміння falsy: чи назве кандидат пастку з0,'',false. Це прямий індикатор, чи писав людина реальні дефолти конфігів. - «Як зробити копію обʼєкта? А глибоку?» — дивляться, чи розрізняє поверхневу й глибоку копію й чи знає про спільні вкладені посилання. Плюс — назвати
structuredCloneі пояснити, чомуJSON.parse(JSON.stringify())ненадійний. - «Що робить
sortбез аргументів з масивом чисел?» — класична пастка на лексикографічне сортування й на мутацію оригіналу. - Live coding: «прибери дублікати / знайди спільні елементи / згрупуй масив обʼєктів». Тут дивляться на вибір інструмента (
Setдля унікальності,reduce/Mapдля групування) і на те, чи не мутує кандидат вхідні дані. Детальніше формати таких задач — у главі Live coding для AQA: типові задачі та підходи.
Джерела
- MDN — Working with objects та Indexed collections (arrays).
- MDN — Optional chaining (
?.) і Nullish coalescing (??). - MDN —
structuredClone()(глибоке клонування й межі його застосування). - MDN —
Array.prototype.sort(),MapтаSet. - ECMA-262 — специфікація мови ECMAScript (tc39.es/ecma262) як першоджерело семантики рівності та методів.
Чому два однакових обʼєктних літерали не рівні через ===?
Бо для обʼєктів оператор рівності звіряє не вміст, а посилання — фізичну адресу в памʼяті. Кожен літерал {} чи [] створює окремий обʼєкт за новою адресою, тож { id: 1 } === { id: 1 } дає false, хоч поля збігаються. Рівним самому собі обʼєкт стає лише тоді, коли дві змінні тримають одне й те саме посилання (const b = a). Для AQA це базовий факт: асерт toBe над двома різними обʼєктами завжди почервоніє, тому вміст перевіряють через toEqual. Сильний кандидат одразу згадає посилальну семантику, а не просто «ну вони різні».
Чим відрізняється зберігання примітивів і обʼєктів у памʼяті?
Примітиви (рядок, число, boolean, null, undefined, symbol, bigint) живуть за значенням: змінна тримає саме значення, і присвоєння копіює його. Обʼєкти, а разом з ними масиви, функції, Date, Map, зберігаються за посиланням — змінна тримає адресу, а не сам обʼєкт. Тому присвоєння обʼєкта другій змінній не копіює його, а дає друге імʼя тій самій структурі: зміна через одну змінну видима через іншу. Це не спецефект, а прямий наслідок того, як влаштована модель памʼяті. Розуміння цієї межі — фундамент, на якому тримаються копіювання, порівняння й половина плаваючих багів у тестах.
Як безпечно дістати вкладене поле з відповіді API, якщо проміжного обʼєкта може не бути?
Через optional chaining — оператор ?.. Якщо ліворуч від нього стоїть null або undefined, весь вираз коротко замикається й повертає undefined, не намагаючись читати далі. Це рятує від класичного падіння «Cannot read properties of null», коли колись працював ланцюжок a.b.c, а сервер раптом віддав b порожнім. Замість драбини resp && resp.user && resp.user.profile пишеться resp.user?.profile?.city. Той самий оператор працює для елементів масиву (items?.[0]) і викликів методів (onLoad?.()). Важлива тонкість: ?. реагує лише на null/undefined, а не на будь-яке «порожнє» значення.
У чому різниця між ?? і ||, і чому це джерело реальних багів?
Обидва підставляють запасне значення, але за різних умов. || вважає «відсутнім» будь-що falsy — а це не тільки null/undefined, а ще й 0, порожній рядок та false. ?? (nullish coalescing) навпаки звужує умову рівно до null й undefined. Різниця вилазить боком там, де нуль або порожній рядок — легітимне налаштування: config.timeout || 30000 мовчки підмінить валідний 0 на тридцять секунд, а config.timeout ?? 30000 залишить нуль недоторканим. Тому правило просте: якщо 0, '' чи false — це нормальні значення поля, дефолт ставлять через ??. Питання на співбесіді прямо перевіряє, чи писав кандидат реальні дефолти конфігів, а не теорію.
Що таке деструктуризація і де AQA зустрічає її найчастіше?
Це синтаксис розпакування полів обʼєкта або елементів масиву в окремі змінні одним виразом. Семантично вона нічого нового не робить — лише прибирає шум і робить наміри явними: const { status, body } = await request.get(url) замість двох окремих звернень до полів. Підтримує значення за замовчуванням ({ name = 'anon' }), перейменування ({ profile: { city } } дістає вкладене й одразу дає імʼя) і позиційне розпакування масивів. У щоденній практиці AQA впізнає її насамперед у параметрах Playwright-тестів: запис async ({ page, request }) => розпаковує обʼєкт фікстур прямо в сигнатурі функції. Тонкість: дефолт підставляється лише коли значення строго undefined, а не null чи 0.
Що роблять три крапки ... — spread і rest?
Один синтаксис, дві протилежні операції залежно від контексту. Spread «розсипає» елементи існуючої структури в нову: [...items] робить копію масиву, { ...a, ...b } зливає два обʼєкти (праве перекриває ліве). Rest, навпаки, «збирає решту» в одну структуру — при деструктуризації (const [head, ...tail] = arr) або в параметрах функції (function f(...args) приймає будь-яку кількість аргументів). Найпоширеніше застосування spread у тестах — зібрати новий обʼєкт/масив, не мутуючи джерело. Критичне застереження: spread робить лише поверхневу копію, тож вкладені обʼєкти лишаються спільними за посиланням.
Розкажи про map, filter, reduce — що вони повертають і чи мутують масив?
Це трійця для обробки даних, і жоден з них не мутує вихідний масив — усі повертають нове значення. map віддає новий масив тієї ж довжини, перетворивши кожен елемент; filter — новий масив із тих елементів, що пройшли умову; reduce згортає масив в одне значення (число, обʼєкт, рядок). Немутуюча природа робить map і filter ланцюжковими: users.filter(u => u.active).map(u => u.name). Для AQA це щоденний інструмент перетворення відповіді API в очікуваний для асерту вигляд — витягнути всі id, відкинути видалені записи. Поруч стоять find (перший елемент або undefined), some і every (обидва повертають boolean).
Як працює reduce і яка в нього головна пастка?
reduce проходить масив, переносячи з кроку на крок акумулятор — «підсумок, що накопичується». Колбек отримує поточний акумулятор і елемент, а повертає новий акумулятор; другий аргумент методу задає його початкове значення. Ним будують не лише числа: типова задача — згорнути масив обʼєктів у згрупований обʼєкт ключ → список. Головна пастка: reduce без явного початкового значення на порожньому масиві кидає TypeError, тому початок завжди задають явно (0, {}, []). Друга поширена помилка новачків — забути повернути акумулятор з колбека, після чого наступний крок отримує undefined.
Чому sort — тиха пастка, на якій провалюються навіть досвідчені?
З двох причин. По-перше, sort упорядковує елементи прямо у вихідному масиві й віддає посилання на нього ж, а не на нову копію — тобто const s = arr.sort() залишає s === arr і псує оригінал. По-друге, без функції-компаратора sort спершу зводить елементи до рядків і впорядковує їх лексикографічно, тому [1, 2, 10, 21].sort() віддає [1, 10, 2, 21]. Правильно передавати компаратор: (a, b) => a - b для чисел, localeCompare для рядків (він ще й коректно впорається з українськими літерами). Якщо джерело чіпати не можна, спершу роблять копію — [...arr].sort(...) — або беруть немутуючу toSorted у сучасних середовищах. На співбесіді це класичне питання-пастка на мутацію й на лексикографію одночасно.
Які методи масивів мутують оригінал, а які повертають нове значення?
Мутують на місці: sort, reverse, splice, push, pop, shift, unshift, fill. Повертають нове й лишають джерело недоторканим: map, filter, slice, concat, flat, а також сучасні немутуючі близнюки toSorted, toReversed, toSpliced, with. Різниця критична в тестах: якщо всередині перевірки випадково мутувати спільні тестові дані чи фікстуру, наступний кейс отримає зіпсований вхід і почне «плавати». Тому вхідні дані обробляють немутуючими методами, а де потрібен мутуючий (sort) — спершу копіюють. Тримати цю таблицю в голові — ознака людини, яка ловила такі баги руками.
Коли брати Set чи Map замість звичайного обʼєкта й масиву?
Set тримає лише унікальні значення — його зручно взяти, щоб прибрати повтори чи впевнитися, що у відповіді немає дубльованих id: порівняти довжину [...new Set(ids)] з довжиною до дедуплікації або швидко спитати has. Map — це словник, у якому роль ключа може грати навіть обʼєкт, не тільки рядок; він зберігає елементи в порядку додавання, одразу знає свій розмір через .size і не тягне за собою успадковані поля типу toString, якими «забруднений» звичайний обʼєкт. Як вибирати: під фіксовану, наперед відому форму (конфіг, DTO) достатньо обʼєкта, а Map беруть під динамічний словник, який часто дописують і читають за ключем — надто коли важать порядок, розмір або нерядкові ключі.
У чому різниця між поверхневою й глибокою копією?
Поверхнева копія (shallow copy) створює новий контейнер лише верхнього рівня, а вкладені обʼєкти лишаються спільними за посиланням з оригіналом. Усі звичні способи — spread, Object.assign, Array.from, slice — саме такі: зміна поля верхнього рівня в копії безпечна, а зміна вкладеного обʼєкта зачепить і оригінал. Глибока копія (deep copy), навпаки, спускається по всіх рівнях і клонує кожен, тож жодного спільного посилання не лишається, а структури стають повністю незалежними. Плутанина між ними — джерело найпідступніших багів: «я ж скопіював обʼєкт», а мутація вкладеного поля тихо змінює джерело в іншому тесті. Тому перед мутацією завжди варто спитати, чи копія справді глибока.
Як правильно зробити глибоку копію в сучасному JS?
Вбудованою функцією structuredClone — це сучасний і рекомендований спосіб. Вона рекурсивно клонує вкладені обʼєкти, масиви, Date, Map, Set і навіть коректно обробляє циклічні посилання, доступна у браузерах і в Node 17+. Обмеження: вона не вміє клонувати функції й прототипи класів — на функції кине помилку, тож для чистих даних це не проблема, а для обʼєктів з методами треба інший підхід. Старий трюк JSON.parse(JSON.stringify(obj)) теж дає незалежну копію, але робить це ненадійно. Тому дефолтний вибір для глибокого клонування тестових даних — structuredClone, а не JSON-серіалізація.
Чому JSON.parse(JSON.stringify(x)) — небезпечний спосіб глибокої копії?
Бо серіалізація в JSON тихо втрачає й спотворює дані: цей формат оперує лише обʼєктами, масивами, рядками, числами, boolean та null — усе поза цим набором або випадає, або підмінюється. undefined і функції просто випадають з обʼєкта (у масиві undefined стає null); NaN та Infinity стають null; Date перетворюється на рядок ISO і назад уже не оживає; Map і Set стають порожнім {} з утраченим вмістом; циклічне посилання й BigInt взагалі кидають помилку. Для обʼєкта з примітивних полів трюк спрацює, але на будь-чому складнішому дасть підступно неповну копію. Тому для копіювання беруть structuredClone, а JSON лишають для того, для чого він і є — обміну текстом.
Що саме втрачається під час JSON.stringify і чому це важливо для тестів?
Серіалізація викидає все, що не входить у базовий набір типів JSON. Поля зі значенням undefined і поля-функції зникають з результату повністю; NaN та Infinity перетворюються на null; обʼєкт Date стає рядком у форматі ISO 8601; Map і Set серіалізуються в порожній {}. Для тестів це означає дві речі. По-перше, тіло запиту, зібране з таких полів, поїде на сервер не таким, як ти очікуєш — наприклад, поле з undefined просто не потрапить у JSON. По-друге, при логуванні відповіді через stringify частина інформації може мовчки зникнути. Корисно памʼятати й про аргументи: третій параметр задає відступ для читабельного логу, другий (replacer) дозволяє відфільтрувати поля — зокрема приховати токен.
Як правильно порівнювати обʼєкти й масиви в асертах?
За вмістом, а не за посиланням — тобто toEqual, а не toBe. toBe (як і ===) для обʼєктів звіряє адресу, тому expect(actual).toBe({ id: 1 }) завжди падає, навіть якщо поля ідентичні: літерал праворуч — це новий обʼєкт за новою адресою. toEqual виконує глибоке порівняння структур поле за полем і саме він потрібен, коли перевіряють вміст відповіді API чи очікуваний масив. Це прямий наслідок посилальної семантики, з якої починається вся тема. Плутанина toBe/toEqual на обʼєктах — одна з найчастіших причин червоного тесту, який «мав би бути зеленим».
Live coding: як прибрати дублікати, згрупувати масив обʼєктів чи знайти спільні елементи?
Тут інтервʼюер зважає на вибір інструмента й на те, чи кандидат не псує мутацією вхідний масив. Прибрати дублікати найчистіше через Set: [...new Set(arr)]. Згрупувати масив обʼєктів за полем — через reduce з обʼєктом-акумулятором або через Map, накопичуючи по ключу список. Знайти спільні елементи двох масивів — покласти один у Set і відфільтрувати другий за has, що дає лінійну складність замість вкладеного циклу. Ключова дисципліна: працювати немутуючими методами (filter, map), не чіпати вхідний масив, і памʼятати про пастку sort, якщо задача про впорядкування. Детальніше формати таких задач розбирає окрема глава про live coding для AQA.
Три кейси з робочого дня AQA, де семантика даних вирішує, зелений тест чи фантомний баг: чому асерт над «однаковими» обʼєктами падає й чим його лікувати, як перетворити сиру відповідь API в очікуваний вигляд без мутації джерела, і чому спільна фікстура псується між тестами. Скрізь — що робити і чому саме так.
Кейс 1. Асерт над обʼєктами: toBe бреше, toEqual каже правду
Тест перевіряє, що ендпоінт повернув потрібного користувача. Кандидат пише «в лоб» — і отримує червоне на, здавалося б, ідентичних даних.
import { test, expect } from '@playwright/test';
test('GET /users/1 повертає очікуваного користувача', async ({ request }) => {
const res = await request.get('/api/users/1');
const body = await res.json();
const expected = { id: 1, name: 'Ann', role: 'admin' };
// ❌ падає: два різних обʼєкти за різними адресами
// expect(body).toBe(expected);
// ✅ глибоке порівняння за вмістом
expect(body).toEqual(expected);
});
Що дивитися і чому:
toBeнад обʼєктами звіряє посилання, а не поля.bodyіexpected— це два окремих обʼєкти за різними адресами в памʼяті, тожtoBe(він же===) дастьfalse, навіть коли всі поля збігаються до символа. Це не баг тесту даних, а посилальна семантика.toEqual— для вмісту. Він обходить структуру рекурсивно й порівнює поле за полем, тому саме він потрібен для відповідей API, масивів і вкладених обʼєктів.- Часткова перевірка —
toMatchObject. Якщо сервер додає волатильні поля (createdAt,token), звіряти весь обʼєкт крихко. Тоді перевіряють лише те, що стабільне:expect(body).toMatchObject({ id: 1, role: 'admin' }). - Той самий принцип для масивів.
expect([1, 2]).toBe([1, 2])теж завжди червоне — масив це обʼєкт. Для впорядкованого вмісту берутьtoEqual, а якщо порядок неважливий — спершу сортують копію обох боків.
Кейс 2. Перетворення відповіді API без мутації джерела
Прийшов список замовлень, а асерт потребує зовсім іншого зрізу: унікальні id користувачів, лише оплачені записи, і перевірка, що дублікатів немає. Це щоденна робота filter/map/Set.
import { test, expect } from '@playwright/test';
type Order = { id: number; userId: number; status: 'paid' | 'pending' };
test('оплачені замовлення й унікальність користувачів', async ({ request }) => {
const orders: Order[] = await (await request.get('/api/orders')).json();
// немутуючий ланцюжок: джерело orders недоторкане
const paidUserIds = orders
.filter((o) => o.status === 'paid')
.map((o) => o.userId);
// є хоч одне оплачене замовлення (санітарна перевірка вхідних даних)?
expect(orders.filter((o) => o.status === 'paid').length).toBeGreaterThan(0);
// немає дубльованих користувачів серед оплачених
const unique = [...new Set(paidUserIds)];
expect(unique.length, 'один користувач двічі в оплачених').toBe(paidUserIds.length);
// згрупувати замовлення за користувачем: userId → список замовлень
const byUser = orders.reduce<Record<number, Order[]>>((acc, o) => {
(acc[o.userId] ??= []).push(o);
return acc;
}, {});
expect(Object.keys(byUser).length).toBeGreaterThan(0);
});
Що дивитися і чому:
- Ланцюжок
filter().map()не чіпаєorders. Обидва методи повертають новий масив, тож вихідні дані лишаються тими, що прийшли з сервера — наступний асерт бачить їх незіпсованими. Set— інструмент унікальності. Порівнянняunique.lengthз довжиною до дедуплікації — це і є перевірка «чи немає дублікатів», лінійна й читабельна, без вкладеного циклу.reduceдля групування. Акумулятор — обʼєктuserId → список; виразacc[o.userId] ??= []ставить порожній масив лише коли ключа ще немає (тут??=доречний, бо валідних falsy-значень серед списків не буває). Не забути повернутиacc— інакше наступний крок отримаєundefined.- Не сортуй вхідний масив у перевірці. Якщо десь знадобиться впорядкування, це
[...orders].sort((a, b) => a.id - b.id), а неorders.sort(...): інакше наступний тест, що ділить ці дані, отримає інший порядок.
Кейс 3. Спільна фікстура псується між тестами: поверхнева копія
Класичний плаваючий баг: базові тестові дані виносять у спільний обʼєкт, кожен тест «копіює» його спредом і трохи змінює. Через один тест інший падає без явної причини.
const baseUser = {
name: 'test',
role: 'user',
config: { retries: 2, timeout: 30_000 },
};
test('А: адмін з підвищеними retries', () => {
const user = { ...baseUser }; // ⚠️ поверхнева копія
user.role = 'admin'; // ок — поле верхнього рівня
user.config.retries = 5; // ❌ мутує baseUser.config!
expect(user.role).toBe('admin');
});
test('Б: очікує дефолтні retries', () => {
const user = { ...baseUser };
// baseUser.config.retries вже === 5 після тесту А → тест «плаває»
expect(user.config.retries).toBe(2); // може впасти залежно від порядку
});
Виправлення — глибока копія там, де змінюють вкладені поля:
test('А: адмін з підвищеними retries', () => {
const user = structuredClone(baseUser); // незалежна копія всіх рівнів
user.role = 'admin';
user.config.retries = 5; // baseUser недоторканий
expect(user.role).toBe('admin');
});
Що дивитися і чому:
- Спред копіює лише верхній рівень.
user.configу копії — те саме посилання, що й уbaseUser.config, тожuser.config.retries = 5тихо змінює спільний обʼєкт. Симптом упізнаваний: тест зелений поодинці, але червоніє в наборі, і результат залежить від порядку запуску. structuredCloneрозриває спільні посилання. Він рекурсивно клонуєconfig, тож мутація копії більше не тече в оригінал — правильний інструмент для незалежної фікстури з чистих даних.JSON.parse(JSON.stringify(baseUser))тут теж спрацював би, але це пастка на майбутнє. Щойно у фікстурі зʼявитьсяDate,Mapчи поле зundefined— JSON-копія тихо їх спотворить. Тому дефолт —structuredClone.- Ще надійніше — фабрика. Замість спільного мутабельного обʼєкта часто краще функція
makeUser(overrides), що щоразу будує свіжий обʼєкт: тоді ділити нема чого, і мутація одного тесту фізично не може зачепити інший.
Значення vs посилання
- Розумію, що примітиви живуть за значенням, а обʼєкти, масиви, функції,
Date,Map— за посиланням. - Можу пояснити, чому
{} === {}даєfalse:===для обʼєктів звіряє адресу, а не вміст. - Знаю, що
const b = aдля обʼєкта не копіює його, а дає друге імʼя тому самому посиланню. - Розумію, що в асертах обʼєкти й масиви порівнюють через
toEqual(за вмістом), а неtoBe(за посиланням).
Безпечний доступ до даних
- Знаю, що
?.коротко замикається лише наnull/undefinedі повертаєundefined, а не падає. - Розумію, що
?.не реагує на0, порожній рядок чиfalse— це валідні значення, ланцюжок іде далі. - Можу пояснити різницю
??vs||:||спрацьовує на будь-яке falsy,??— лише наnull/undefined; томуport || 3000зʼїдає валідний0.
Деструктуризація, spread і rest
- Можу розпакувати обʼєкт і масив у змінні, задати дефолт і перейменувати вкладене поле.
- Розумію, що дефолт у деструктуризації підставляється лише при строгому
undefined, а неnull/0. - Знаю, що
...— це spread (розсипати в нову структуру) або rest (зібрати решту) залежно від контексту. - Розумію, що spread і
Object.assignроблять лише поверхневу копію зі спільними вкладеними обʼєктами.
Методи масивів
- Знаю, що повертають
map,filter,reduce,find,some,everyі що жоден з них не мутує джерело. - Можу пояснити механізм
reduce: акумулятор, початкове значення й пастку порожнього масиву без нього. - Розумію, що
sortупорядковує масив прямо в оригіналі та віддає посилання на нього ж, а не копію. - Знаю, що
sortбез компаратора сортує лексикографічно, тому числам потрібен(a, b) => a - b. - Тримаю в голові поділ методів на мутуючі (
sort,splice,push) і немутуючі (map,slice,toSorted).
Set, Map і копіювання
- Можу пояснити, коли брати
Set(унікальність, дедуплікація) іMap(динамічний словник, нерядкові ключі). - Розумію різницю поверхневої і глибокої копії та чому вкладений обʼєкт лишається спільним.
- Знаю, що правильна глибока копія —
structuredClone, і що він не клонує функції та прототипи класів. - Можу пояснити, чому
JSON.parse(JSON.stringify())губитьDate,undefined, функції,Map/Set, аNaN/Infinityперетворює наnull.
У тесті { id: 1 } === { id: 1 } повернуло false. У чому причина?
Питання
Чому { id: 1 } === { id: 1 } дає false?