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

    08 · JavaScript/TypeScript для AQA

    this, класи та ООП у тестовому коді

    Зміст

    Відкрий будь-який тестовий фреймворк на JS/TS — і побачиш класи: LoginPage, ApiClient, BaseTest, компоненти сторінки. Page Object, найпоширеніший патерн автоматизації, — це просто клас, а всередині класу все тримається на слові this. Тому питання «як визначається this?» і «чим bind відрізняється від call?» — не абстрактна теорія, а те, що ламає реальні тести й що майже гарантовано спитають на співбесіді на AQA.

    Класична сцена: метод page object раптом падає з TypeError: Cannot read properties of undefined. Виглядає як баг застосунку або кривий локатор, а насправді метод відірвали від об'єкта, і this став undefined. Хто розуміє механізм this, класів і прототипів (prototype) під капотом — читає такі помилки за секунди, а не гадає годину. Ця глава — про мовний фундамент; сам патерн Page Object детально розбирає розділ «Автоматизація: стратегія».

    Як визначається this

    Головна ідея, яку більшість вивчає найдовше: this не визначається там, де функцію оголосили. Він визначається в момент виклику — за тим, як функцію викликали. Одна й та сама функція в різних викликах отримує різний this. Тому дивитися треба не на тіло функції, а на місце виклику (call-site).

    Правил прив'язки (binding) рівно чотири (коли на один виклик претендує кілька, пріоритет такий: new → явна прив'язка → метод → простий виклик):

    1. Виклик як методobj.method(). this дорівнює об'єкту зліва від крапки, тобто obj. Це найчастіший випадок у page object: loginPage.open() → всередині open this — це loginPage.
    2. Простий викликfn(), без об'єкта зліва. У строгому режимі (strict mode) this дорівнює undefined. Тіло класу й ES-модулі завжди строгі, тож у тестовому коді це майже завжди undefined, а не глобальний об'єкт.
    3. Виклик з newnew Fn(). Створюється новий порожній об'єкт, і this вказує на нього. Саме тому в конструкторі this.page = page записує поле в новий екземпляр.
    4. Явна прив'язкаfn.call(obj), fn.apply(obj), fn.bind(obj). this — це те, що ти передав першим аргументом.

    Окремий, п'ятий випадок — стрілочні функції (arrow functions). У них немає власного this: вони беруть його лексично, з місця оголошення, і назавжди. Стрілку не можна «перепризначити» через call чи bind — її this зафіксований тим оточенням, де вона написана. Це не виняток заради винятку, а головний робочий інструмент проти втрати контексту (детальніше про лексичну область видимості — у главі «Функції, замикання та область видимості»).

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Як викликано функцію?

    Стрілочна функція?

    this з оточення,
    де оголошено

    Виклик через
    call/apply/bind?

    this = переданий об'єкт

    Виклик з new?

    this = новий об'єкт

    Є об'єкт зліва
    від крапки?

    this = той об'єкт

    this = undefined
    strict mode

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Ні

    Як викликано функцію?

    Стрілочна функція?

    this з оточення,
    де оголошено

    Виклик через
    call/apply/bind?

    this = переданий об'єкт

    Виклик з new?

    this = новий об'єкт

    Є об'єкт зліва
    від крапки?

    this = той об'єкт

    this = undefined
    strict mode

    Втрата контексту і bind/call/apply

    Тепер найпоширеніша пастка. this прив'язується під час виклику, а не оголошення — тож щойно ти відриваєш метод від об'єкта й передаєш його як значення, зв'язок губиться.

    class Toolbar {
      label = 'Menu';
    
      logLabel() {
        console.log(this.label);
      }
    }
    
    const bar = new Toolbar();
    bar.logLabel();          // 'Menu' — виклик через крапку, this = bar
    
    const detached = bar.logLabel;
    detached();              // TypeError: Cannot read properties of undefined (reading 'label')

    detached — це та сама функція, але виклик уже простий, без об'єкта зліва. this став undefined, і звернення this.label падає. У реальному коді це стається, коли метод передають як колбек: обробник події, аргумент у setTimeout, array.forEach(this.check), підписка на емітер. Функцію викличуть, але вже не «через крапку».

    Рятують три методи, які є в кожної функції:

    • bind(thisArg) — не викликає функцію, а повертає нову функцію з назавжди прив'язаним this. Її потім можна передавати куди завгодно.
    • call(thisArg, arg1, arg2) — викликає функцію негайно з заданим this, аргументи перелічуються через кому.
    • apply(thisArg, [arg1, arg2]) — те саме, що call, але аргументи одним масивом.
    const bound = bar.logLabel.bind(bar);
    bound();                 // 'Menu' — this зафіксовано на bar
    
    bar.logLabel.call(bar);  // 'Menu' — виклик негайно

    Мнемоніка різниці: call → comma (кома), apply → array (масив), bind → пізніше.

    Але в сучасному коді bind руками пишуть рідко. Той самий ефект дає стрілковий метод як поле класу: його this лексично прив'язаний до екземпляра, тож метод можна відривати й передавати безкарно.

    class Toolbar {
      label = 'Menu';
    
      logLabel = () => {
        console.log(this.label);   // this завжди — екземпляр
      };
    }
    
    const bar = new Toolbar();
    const detached = bar.logLabel;
    detached();              // 'Menu' — контекст не губиться

    Нюанс, за який чіпляються на співбесіді: такий стрілковий метод — це вже не спільний метод на прототипі, а окрема копія-поле в кожному екземплярі. Для десятка page object це неважливо; для тисяч об'єктів у гарячому циклі — вже аргумент. Знати різницю варто (див. розділ «Прототипи під капотом» нижче).

    Класи: constructor, методи, static

    Клас (class) — це шаблон об'єктів: він описує, які в них поля й методи. constructor — спеціальний метод, який виконується під час new і зазвичай приймає залежності. У тестовому коді конструктор page object майже завжди отримує page (об'єкт сторінки Playwright) і зберігає його в поле.

    import { Page } from '@playwright/test';
    
    class LoginPage {
      constructor(private readonly page: Page) {}
    
      async open(): Promise<void> {
        await this.page.goto('/login');
      }
    
      async loginAs(user: string, password: string): Promise<void> {
        await this.page.getByLabel('Email').fill(user);
        await this.page.getByLabel('Password').fill(password);
        await this.page.getByRole('button', { name: 'Sign in' }).click();
      }
    }

    Запис private readonly page: Page у параметрах конструктора — це «параметр-властивість» TypeScript: короткий спосіб оголосити поле й одразу присвоїти йому аргумент. Звичайні методи (open, loginAs) — це дії сторінки, і всередині них this — конкретний екземпляр LoginPage.

    Ключове слово static робить член належним самому класу, а не екземпляру. Статику не видно через this об'єкта — до неї звертаються через ім'я класу. Типове застосування — фабричні методи, які інкапсулюють складне створення об'єкта, і константи.

    class ApiClient {
      private constructor(private readonly token: string) {}
    
      // Фабрика: залогінитись і повернути готового клієнта
      static async authorized(request: APIRequestContext): Promise<ApiClient> {
        const token = await requestToken(request);
        return new ApiClient(token);
      }
    }
    
    const client = await ApiClient.authorized(request);

    Приватний конструктор плюс статична фабрика — акуратний спосіб гарантувати, що клієнта не створять без токена.

    extends і super: успадкування з мірою

    extends створює підклас, який успадковує поля й методи батька. super() у конструкторі викликає конструктор батька, а super.method() — батьківський метод. Правило, яке ловить новачків: у конструкторі підкласу до super() не можна звертатися до this — це кине ReferenceError. Спершу побудуй батьківську частину, потім користуйся this.

    class BasePage {
      constructor(protected readonly page: Page) {}
    
      async open(path: string): Promise<void> {
        await this.page.goto(path);
      }
    }
    
    class LoginPage extends BasePage {
      async open(): Promise<void> {
        await super.open('/login');   // виклик батьківського open
      }
    }

    Синтаксис — простий. Складне питання — коли ним користуватися. Тут діє канон «композиція проти успадкування (composition over inheritance)» з розділу «Автоматизація: стратегія». Коротко: неглибокий BasePage зі справді спільною механікою (базовий URL, спільне очікування) — норма. А от глибокі ієрархії (BasePageAuthPageAdminPageUsersPage) швидко стають болем: зміна в корені ламає гілки, а логіка розмазана рівнями, і незрозуміло, звідки метод.

    Тому для перевикористання частіше беруть композицію: has-a замість is-a. Сторінка не успадковує хедер і таблицю — вона містить їх як окремі об'єкти-компоненти в полях.

    class DashboardPage {
      readonly header: HeaderComponent;
      readonly grid: DataGrid;
    
      constructor(private readonly page: Page) {
        this.header = new HeaderComponent(page);
        this.grid = new DataGrid(page);
      }
    }
    
    // у тесті:
    await dashboard.header.logout();
    await dashboard.grid.sortBy('Name');

    HeaderComponent і DataGrid — самостійні класи, які можна перевикористати на будь-якій сторінці з хедером чи таблицею, не будуючи ієрархію. Успадкування прив'язує намертво; композицію легко перескладати.

    Геттери, сеттери і приватні поля

    Геттер (getter) і сеттер (setter) — методи, оголошені через get/set, до яких звертаються як до звичайних властивостей, без дужок. Ззовні cart.size виглядає як поле, а насправді щоразу виконується функція. Зручно для обчислюваних або похідних значень.

    class Cart {
      #items: string[] = [];
    
      add(item: string): void {
        this.#items.push(item);
      }
    
      get size(): number {
        return this.#items.length;   // читаємо як cart.size, без дужок
      }
    }
    
    const cart = new Cart();
    cart.add('book');
    console.log(cart.size);          // 1

    Поле з решіткою — #items — це приватне поле (private field), справжня інкапсуляція на рівні мови. Звернутися до #items можна лише зсередини класу; будь-яка спроба ззовні — синтаксична помилка ще до запуску. Це критична відмінність від модифікатора private у TypeScript: private існує тільки під час компіляції, у зібраному JS поле лишається звичайним і доступним через obj['field']. Тобто private — це підказка компілятора й дисципліна, а # — жорсткий бар'єр у рантаймі. Для тестового коду частіше достатньо private з TypeScript (докладніше про його типи — у главі «TypeScript: типи, інтерфейси та строгий режим»); # беруть там, де приватність треба гарантувати самою мовою.

    Прототипи під капотом

    Класи в JavaScript великою мірою — синтаксичний цукор над прототипами (хоча додають і власну семантику: приватні поля #, строгий режим за замовчуванням, заборону виклику без new). Розуміти механізм варто, бо він пояснює і instanceof, і те, чому методи «спільні», і звідки взагалі береться метод, якого немає в самому об'єкті.

    У кожного об'єкта є прихований зв'язок — [[Prototype]] — на інший об'єкт, його прототип. Коли ти звертаєшся до obj.method(), рушій шукає method спочатку в самому obj; не знайшовши — йде по цьому зв'язку вгору, до прототипу, потім до прототипу прототипу, аж поки не знайде або не впреться в null. Це і є ланцюжок прототипів (prototype chain).

    Коли ти пишеш class LoginPage, звичайні методи (open, loginAs) кладуться не в кожен екземпляр, а один раз — у LoginPage.prototype. Усі екземпляри LoginPage посилаються на цей один об'єкт як на свій прототип. Тому метод у пам'яті один, а не копіюється в кожен об'єкт — на відміну від стрілкових полів-методів, які таки лежать у кожному екземплярі.

    прототип

    прототип

    прототип

    loginPage
    (екземпляр: поле page)

    LoginPage.prototype
    (open, loginAs)

    Object.prototype
    (toString, hasOwnProperty)

    null

    прототип

    прототип

    прототип

    loginPage
    (екземпляр: поле page)

    LoginPage.prototype
    (open, loginAs)

    Object.prototype
    (toString, hasOwnProperty)

    null

    extends просто зшиває два такі ланцюжки: прототип LoginPage отримує своїм прототипом BasePage.prototype. Тому виклик успадкованого методу — це знахідка на другому кроці підйому ланцюжком. І loginPage instanceof BasePage повертає true саме тому, що BasePage.prototype трапляється десь у ланцюжку.

    Мовний фундамент Page Object

    Складемо все докупи. Page Object — це не магія фреймворку, а звичайний клас, який тримається рівно на тому, що ми розібрали:

    • клас і constructor приймають page як залежність і зберігають у поле;
    • this усередині методів указує на екземпляр сторінки — поки метод викликають через крапку;
    • методи на прототипі — це дії сторінки, спільні для всіх екземплярів;
    • композиція компонентів (хедер, таблиця, модалка окремими об'єктами в полях) масштабує структуру краще за глибоке успадкування;
    • інкапсуляція (private, #) ховає локатори всередині, лишаючи назовні лише осмислені дії.

    Тобто, щойно ти зрозумів this, класи й прототипи, Page Object читається як проста конструкція, а не як заклинання. Сам патерн — його межі, «god object», перевірки в page object — це тема розділу «Автоматизація: стратегія»; тут — його мова.

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

    • this is undefined при виклику методу. Виглядає як баг класу чи зламаний локатор, а насправді метод відірвали від об'єкта (передали як колбек, у setTimeout, forEach) — і this став undefined. Лікування: стрілковий метод-поле або bind.
    • «Стрілковий метод безкоштовно чинить this». Виглядає як безкоштовний фікс, а насправді такий метод — окреме поле в кожному екземплярі, а не спільний метод на прототипі. Для page object байдуже, але на питання «де живе метод?» це правильна відповідь.
    • «TS private приховав поле». Виглядає як справжня інкапсуляція, а насправді private зникає при компіляції — у рантаймі поле доступне. Гарантовану приватність дає лише #.
    • Звернення до this до super(). Виглядає як дивний ReferenceError на порожньому місці, а насправді в конструкторі підкласу до виклику super() this ще не існує.
    • Глибока ієрархія extends заради перевикористання. Виглядає як економія коду, а насправді жорстка зв'язка: зміна в корені ламає всі гілки. Часто правильна відповідь — композиція.

    Підсумок

    • this визначається в момент виклику за тим, як викликали функцію, а не де її оголосили; стрілочні функції — виняток, вони беруть this лексично.
    • Метод, відірваний від об'єкта, губить this; повертають контекст bind (нова функція), call/apply (виклик негайно) або стрілковий метод-поле.
    • static належить класу, а не екземпляру; #field — справжня приватність у рантаймі, тоді як TypeScript private існує лише під час компіляції.
    • Класи — цукор над прототипами: звичайні методи лежать на прототипі (спільні для всіх екземплярів), extends зшиває ланцюжки прототипів, instanceof перевіряє цей ланцюжок.
    • Page Object — це клас, і для перевикористання композиція компонентів зазвичай краща за глибоке успадкування.

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

    • «Як визначається this у JavaScript?» Інтерв'юер хоче почути головне: this залежить від місця виклику, а не оголошення. Сильна відповідь перелічує чотири правила (метод, простий виклик, new, call/bind) і окремо — лексичний this стрілок.
    • «Чим відрізняються call, apply і bind Дивляться, чи розумієш, що bind повертає нову функцію, а call/apply викликають негайно, і що різниця між call і apply — у форматі аргументів.
    • «Чому метод падає з undefined, коли його передали в колбек?» Класична перевірка на розуміння втрати контексту. Плюс — назвати два способи це виправити.
    • private у TypeScript проти #. Перевіряють, чи знаєш, що TS-приватність — це лише перевірка компілятора, а # тримається в рантаймі.
    • «Класи — це справжнє ООП чи цукор?» Хочуть почути про прототипи: методи на prototype, ланцюжок пошуку, як працює extends і instanceof.
    • «Композиція чи успадкування для page object?» Оцінюють інженерну зрілість: чи не тягнеш глибоку ієрархію туди, де достатньо композиції компонентів.

    Джерела