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

    08 · JavaScript/TypeScript для AQA

    Live coding для AQA: типові задачі та підходи

    Зміст

    Live coding — це формат, де тебе просять розвʼязати невелику задачу прямо в редакторі чи на дошці, вголос пояснюючи, що робиш. Для AQA-вакансій це майже завжди не «напиши алгоритм Дейкстри», а щось приземлене: розверни рядок, знайди дублікати в масиві, згрупуй тестові дані, зроби retry для флакі-запиту. Задачі прості, але саме на них валяться кандидати — бо хвилюються, забувають про крайові випадки й пишуть код, який на першому ж порожньому масиві падає.

    Головна пастка live coding у тому, що оцінюють не сам розвʼязок, а те, як ти мислиш. Той самий FizzBuzz можна зробити за 30 секунд і отримати «ну ок», а можна за три хвилини — уточнивши вимоги, проговоривши межі й перевіривши результат — і отримати «беремо». Ця глава показує канонічні задачі й, головне, патерн міркування, який перетворює написання коду на демонстрацію тестувальницького мислення. Мова прикладів — TypeScript, бо саме її очікують на JS/TS-стеку; синтаксис методів масивів і рядків розібрано в главах Обʼєкти, масиви та методи роботи з даними і Рядки, дати й регулярні вирази.

    Що насправді оцінюють

    Інтервʼюер дивиться не тільки на робочий код. За той самий час він зчитує чотири речі, і три з них — не про алгоритми:

    • Чи уточнюєш вимоги. Задачу формулюють навмисно неповно. «Розверни рядок» — а що з емодзі? З порожнім рядком? Реверс за символами чи за словами? Кандидат, який одразу питає, показує зрілість; кандидат, який кинувся кодити, показує, що так само кинеться писати автотест без розуміння очікуваного результату.
    • Чи бачиш крайові випадки. Це прямий проксі твоєї основної навички. Порожнє, null, дублікати, одиничний елемент, дуже великий вхід, unicode — те саме, що ти щодня закладаєш у тест-дизайн (класи еквівалентності й граничні значення живуть у розділі «Тест-дизайн»).
    • Чи пояснюєш вголос. Мовчазний геній, який за пʼять хвилин видає ідеальний код без єдиного слова, програє тому, хто думає вголос. Автоматизатор постійно пояснює рішення на код-рев'ю — інтервʼю це репетирує.
    • Чи перевіряєш себе. Дописав — і одразу прогнав подумки на кількох входах, зокрема на крайових. Це рефлекс тестувальника, і його видно неозброєним оком.

    Тому далі кожну задачу подано не як «ось відповідь», а разом із межами, які варто проговорити.

    Патерн міркування: вимоги → крайові випадки → рішення → перевірка

    Це кістяк, на який нанизується будь-яка задача. Він же — те, чим QA відрізняється від розробника на тому самому завданні: розробник біжить до робочого коду, тестувальник спершу окреслює простір входів.

    знайшов дірку

    Вимоги
    що на вході, що на виході

    Крайові випадки
    порожнє, межі, unicode, null

    Рішення
    найпростіше, що працює

    Перевірка
    прогнати на межах вголос

    знайшов дірку

    Вимоги
    що на вході, що на виході

    Крайові випадки
    порожнє, межі, unicode, null

    Рішення
    найпростіше, що працює

    Перевірка
    прогнати на межах вголос

    Проговори це буквально такими словами на співбесіді:

    1. Вимоги. «Уточню: вхід — це завжди рядок? Реверс посимвольний? Регістр важливий?» Дві-три хвилини тут економлять переписування потім.
    2. Крайові випадки. Назви їх до коду: порожній вхід, один елемент, дублікати, від'ємні числа, дуже великий масив, undefined серед даних. Навіть якщо не оброблятимеш усі — покажи, що бачиш.
    3. Рішення. Спершу найпростіше, що працює й читається. Не оптимізуй передчасно; якщо спитають про складність — тоді й обговориш.
    4. Перевірка. Прогони код подумки на happy-path і на кількох краях. Вголос: «порожній рядок → повертає порожній, ок; один символ → сам себе, ок».

    Далі — задачі, кожна крізь цю призму.

    Рядки: реверс, паліндром, частотний словник

    Реверс рядка. Класика, у якій ховається справжнє питання про unicode.

    function reverse(input: string): string {
      return [...input].reverse().join('');
    }

    Чому [...input], а не input.split('')? Спред перебирає рядок за код-поінтами (Unicode code points), тож емодзі й символи з сурогатних пар не розваляться навпіл. split('') ріже за 16-бітними одиницями й на "👍" дасть кашу. Проговорити цю різницю — і задача-«розминка» вже показала, що ти знаєш, де в рядках болить. Крайові випадки: порожній рядок (повертає порожній), один символ, рядок із комбінованими діакритиками (тут навіть код-поінти не рятують — але про це чесно сказати як про межу знань).

    Паліндром. Питання-пастка — не «як порівняти з реверсом», а «що вважати паліндромом». Майже завжди мають на увазі: ігнорувати регістр, пробіли й пунктуацію.

    function isPalindrome(input: string): boolean {
      const normalized = input.toLowerCase().replace(/[^a-z0-9]/gi, '');
      let left = 0;
      let right = normalized.length - 1;
      while (left < right) {
        if (normalized[left] !== normalized[right]) return false;
        left++;
        right--;
      }
      return true;
    }

    Два вказівники замість normalized === [...normalized].reverse().join('') — не заради швидкості (обидва читаються нормально), а щоб показати, що ти можеш і без «магії» методів. Але якщо напишеш через реверс і поясниш — теж валідно. Крайові: порожній рядок (зазвичай вважають паліндромом), один символ, "A man, a plan, a canal: Panama" — саме на цьому вході видно, чи проговорив ти нормалізацію.

    Частотний словник. Порахувати, скільки разів кожен елемент зустрічається — база для купи реальних перевірок (розподіл статусів у відповіді API, дублікати в експорті).

    function frequency(items: string[]): Map<string, number> {
      const counts = new Map<string, number>();
      for (const item of items) {
        counts.set(item, (counts.get(item) ?? 0) + 1);
      }
      return counts;
    }

    Map кращий за звичайний обʼєкт: ключі будь-якого типу, немає колізій із успадкованими властивостями штибу constructor, і порядок вставки збережено. Оператор ?? дає нуль для ще не баченого ключа. Крайові: порожній масив (порожня мапа), усі елементи однакові, різний регістр (уточни, чи "A" і "a" — те саме).

    Масиви: дублікати, унікальні, перетин, сортування обʼєктів

    Унікальні значення — однорядковий ідіом, який мусиш видавати не думаючи:

    const unique = [...new Set(items)];

    Знайти дублікати — трохи хитріше, бо треба саме ті, що повторюються:

    function findDuplicates(items: number[]): number[] {
      const seen = new Set<number>();
      const dupes = new Set<number>();
      for (const item of items) {
        if (seen.has(item)) dupes.add(item);
        else seen.add(item);
      }
      return [...dupes];
    }

    Два Set — щоб не отримати сам дублікат кількаразово в результаті. Тут добре проговорити складність: один прохід, O(n), замість наївного подвійного циклу O(n²).

    Перетин двох масивів — спільні елементи. Ключова оптимізація: другий масив у Set, щоб перевірка була константною.

    function intersection(a: number[], b: number[]): number[] {
      const setB = new Set(b);
      return [...new Set(a)].filter((x) => setB.has(x));
    }

    Зовнішній new Set(a) прибирає дублікати з результату — уточни в інтервʼюера, чи це бажано. Крайові для всіх трьох: порожній масив, повний збіг, жодного спільного елемента, дублікати на вході.

    Сортування обʼєктів — тут ховається найвідоміша пастка JavaScript. Метод sort без компаратора сортує як рядки, тож [10, 9, 1].sort() дасть [1, 10, 9]. І він мутує вихідний масив.

    const users = [
      { name: 'Ada', age: 36 },
      { name: 'Alan', age: 41 },
      { name: 'Grace', age: 36 },
    ];
    
    // за віком зростанням, копія без мутації оригіналу
    const byAge = [...users].sort((a, b) => a.age - b.age);

    Компаратор повертає число: відʼємне — a перед b, додатне — навпаки, нуль — порядок збережено. Для чисел — різниця, для рядків — localeCompare (щоб коректно з українськими літерами). Із ES2019 sort гарантовано стабільний, тож елементи з однаковим age збережуть відносний порядок — на це можна спертися. Крайові: порожній масив, один елемент, однакові ключі (перевірка стабільності), змішані типи.

    Масив обʼєктів як тестові дані: фільтрація, групування, агрегація

    Це найближча до реальної роботи AQA задача: тобі дають масив обʼєктів (відповідь API, вибірку з БД) і просять щось із ним зробити. Тут метод reduce — головний інструмент, і вміння його пояснити цінується.

    Фільтрація тривіальна (filter), тож інтервʼюер зазвичай веде до групування.

    Групування — розкласти елементи по кошиках за ключем:

    interface TestRun {
      suite: string;
      status: 'passed' | 'failed' | 'skipped';
      durationMs: number;
    }
    
    function groupByStatus(runs: TestRun[]): Map<string, TestRun[]> {
      return runs.reduce((groups, run) => {
        const bucket = groups.get(run.status) ?? [];
        bucket.push(run);
        groups.set(run.status, bucket);
        return groups;
      }, new Map<string, TestRun[]>());
    }

    Сучасний рушій (Node 21+) уміє Object.groupBy(runs, (r) => r.status) — але не в кожному середовищі воно є, тож на співбесіді безпечніше показати варіант через reduce, а про Object.groupBy згадати як новіший цукор. Це саме та обережність із доступністю API, яку від тебе й чекають.

    Агрегація — порахувати підсумок по групі: кількість, суму, середнє.

    function averageDuration(runs: TestRun[]): number {
      if (runs.length === 0) return 0; // ділення на нуль!
      const total = runs.reduce((sum, run) => sum + run.durationMs, 0);
      return total / runs.length;
    }

    Порожній масив тут — не формальність, а справжня діра: без перевірки отримаєш NaN від ділення на нуль. Назвати цей крайовий випадок до того, як інтервʼюер тицьне в нього, — половина успіху. Комбінована задача «згрупуй прогони за суїтом і порахуй середню тривалість кожного» перевіряє все одразу; розкладай її на групування, а потім агрегацію кожної групи — не намагайся зробити в один прохід, поки не запрацює простий варіант.

    Async-задачі: retry, послідовність, ліміт паралельності

    Асинхронні задачі відсіюють тих, хто плутається в await. Механіку промісів розібрано в главі Асинхронність: Promises, async/await і паралельність; тут — типові live-coding формулювання. Усім трьом потрібен хелпер затримки:

    const delay = (ms: number): Promise<void> =>
      new Promise((resolve) => setTimeout(resolve, ms));

    Retry із затримкою — прямий болючий кейс автоматизатора: повторити нестабільну операцію кілька разів, перш ніж здатися.

    async function retry<T>(
      action: () => Promise<T>,
      attempts = 3,
      delayMs = 500,
    ): Promise<T> {
      let lastError: unknown;
      for (let i = 0; i < attempts; i++) {
        try {
          return await action();
        } catch (error) {
          lastError = error;
          if (i < attempts - 1) await delay(delayMs);
        }
      }
      throw lastError;
    }

    Ключові моменти для проговорювання: не спати після останньої спроби (нема сенсу); зберегти й прокинути останню помилку, а не проковтнути її; дженерик <T>, щоб типізація не губилась. Логічне продовження, яке часто просять, — експоненційний бекоф: delayMs * 2 ** i. Тут же доречно згадати тестувальницьку мудрість: retry маскує флак, а не лікує його (таксономія флакі-тестів — тема розділу «Автоматизація: стратегія»).

    Послідовні запити — виконати по черзі, коли кожен наступний залежить від попереднього. Головна пастка — forEach не чекає await всередині:

    // НЕ спрацює: forEach ігнорує проміси, усе стартує разом
    ids.forEach(async (id) => { await fetchUser(id); });
    
    // правильно: for...of дає справжню послідовність
    async function fetchSequentially(ids: number[]): Promise<User[]> {
      const users: User[] = [];
      for (const id of ids) {
        users.push(await fetchUser(id));
      }
      return users;
    }

    Ліміт паралельності — найскладніша з трійки. Promise.all запускає все одразу; якщо треба «не більше N одночасно» (щоб не завалити сервер), потрібен пул:

    async function withLimit<T>(
      tasks: (() => Promise<T>)[],
      limit: number,
    ): Promise<T[]> {
      const results: T[] = new Array(tasks.length);
      let cursor = 0;
      async function worker(): Promise<void> {
        while (cursor < tasks.length) {
          const index = cursor++;
          results[index] = await tasks[index]();
        }
      }
      const pool = Array.from({ length: Math.min(limit, tasks.length) }, worker);
      await Promise.all(pool);
      return results;
    }

    Тут важлива ідея: tasks — це масив функцій, що повертають проміси, а не готових промісів; інакше вони стартують до того, як пул почне їх притримувати. limit воркерів розбирають завдання зі спільного курсора. Порядок результатів збережено через індекс. Крайові: порожній список, limit більший за кількість задач, задача, що падає (уточни — чи зупиняти весь пул). Якщо не встигаєш дописати пул — чесно скажи ідею вголос; часто цього достатньо.

    FizzBuzz як задача на дизайн

    FizzBuzz («Fizz» на кратних 3, «Buzz» на кратних 5, «FizzBuzz» на кратних 15, інакше число») сам по собі тривіальний. Але його часто дають, щоб подивитися на розширюваність: «а тепер додай правило Bazz на кратних 7». Наївний варіант із каскадом if доведеться переписувати; тому цінують рішення, де правила — це дані.

    class FizzBuzz {
      constructor(private rules: [number, string][] = [[3, 'Fizz'], [5, 'Buzz']]) {}
    
      convert(n: number): string {
        const output = this.rules
          .filter(([divisor]) => n % divisor === 0)
          .map(([, word]) => word)
          .join('');
        return output || String(n);
      }
    }
    
    const fb = new FizzBuzz([[3, 'Fizz'], [5, 'Buzz'], [7, 'Bazz']]);
    // fb.convert(15) → 'FizzBuzz'; fb.convert(21) → 'FizzBazz'; fb.convert(1) → '1'

    Тут «FizzBuzz» на 15 виходить автоматично — рядки склеюються, бо 15 кратне і 3, і 5. Нове правило — один елемент у масиві, жодного if переписувати. Це демонструє два принципи, які цінує ревʼюер тестового коду: конфігурація замість гілок і одна причина для зміни. Синтаксис класів — у главі this, класи та ООП у тестовому коді. Крайові: 0 (кратне всьому — поверне склейку всіх слів, уточни, чи це очікувано), відʼємні числа, дуже велике n.

    Типові помилки

    • Виглядає як «зрозумів задачу», а насправді не уточнив очікуваний результат. Кинувся кодити реверс — і зробив посимвольний, коли просили за словами. Дві хвилини питань дешевші за переписування під тиском.
    • Виглядає як робочий код, а насправді падає на порожньому вході. average без перевірки довжини дає NaN, [].reduce без початкового значення кидає виняток. Порожнє — крайовий випадок номер один, завжди називай його першим.
    • Виглядає як сортування, а насправді лексикографічне. [2, 10, 1].sort()[1, 10, 2]. Забутий компаратор — найчастіша мовчазна помилка на співбесідах.
    • Виглядає як async-цикл, а насправді все стартувало разом. await усередині forEach ігнорується; проміси не чекаються, а помилки в них стають unhandled rejection. Для послідовності — for...of.
    • Виглядає як паралельність із лімітом, а насправді Promise.all над готовими промісами. Якщо передати вже створені проміси, вони стартують негайно, і жоден «ліміт» їх не притримає. Ліміт працює лише над функціями, що відкладають старт.
    • Виглядає як мовчазна зосередженість, а насправді втрачені бали. Не пояснювати хід думки — програшна стратегія навіть із правильним кодом.

    Підсумок

    • Live coding для AQA оцінює мислення, а не алгоритми: уточнення вимог, бачення крайових випадків, пояснення вголос і самоперевірка важать більше за швидкість.
    • Патерн вимоги → крайові випадки → рішення → перевірка — це той самий тест-дизайн, лише застосований до власного коду; проговорюй його явно.
    • Порожній вхід, один елемент, дублікати, unicode й null — універсальний список меж; називай їх до написання коду, а не після тику інтервʼюера.
    • Знай напамʼять ідіоми: [...new Set()] для унікальних, Set для перетину/дублікатів за O(n), reduce для групування й агрегації, компаратор для sort.
    • В async уникай await у forEach, розумій різницю послідовного for...of і Promise.all, а ліміт паралельності будуй над функціями, не над промісами.

    Що питають на співбесіді

    • «Розверни рядок» — інтервʼюер чекає, що спитаєш про unicode й порожній вхід; сам однорядковик менш важливий за ці питання.
    • «Знайди дублікати / перетин масивів» — дивляться, чи згадаєш Set і чи оціниш складність; наївний O(n²) покаже прогалину.
    • «Згрупуй ці дані й порахуй підсумок» — перевірка reduce і того, чи обробиш порожню групу без NaN; проксі реальної роботи з відповіддю API.
    • «Зроби retry для нестабільного запиту» — чекають коректну затримку (не після останньої спроби), прокидання помилки і зауваження, що retry маскує флак.
    • «Обмеж кількість одночасних запитів» — найскладніше; навіть якщо не допишеш пул, цінують правильно сформульовану ідею з функціями-задачами.
    • «Ось FizzBuzz — тепер додай правило» — перевіряють, чи закладеш розширюваність через дані, а не каскад if.

    На що дивиться інтервʼюер понад код: чи ти уточнюєш перед тим, як писати; чи проговорюєш крайові випадки; чи перевіряєш себе на межах наприкінці. Це рівно ті рефлекси, які потім підуть у тест-дизайн і код-рев'ю. Якщо застряг — думай уголос: мовчазний глухий кут гірший за озвучену гіпотезу.

    Джерела

    • MDN: Array — методи (map, filter, reduce, sort) — довідник із методів масивів і поведінки sort.
    • MDN: Set — унікальність, дублікати, перетин.
    • MDN: Promiseall/allSettled/race для паралельних і послідовних сценаріїв.
    • MDN: async function — коректна робота з await, зокрема пастка циклів.
    • Силабус ISTQB CTFL 4.0 — розділ 4 (техніки чорної скриньки: класи еквівалентності й граничні значення), що стоять за списком крайових випадків.