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

    06 · Автоматизація: стратегія

    ООП на тестовому коді

    Зміст

    Об'єктно-орієнтоване програмування (object-oriented programming, OOP) для AQA болить у двох місцях. Перше — співбесіда: «розкажіть про чотири принципи ООП», і кандидат переказує означення з вікіпедії, а інтерв'юер чекає прикладів з вашого коду. Друге — щоденне рев'ю: тестовий проєкт розповзається на BaseTest, від якого успадковані всі тести, god object на пів тисячі рядків і три однакові API-клієнти, що відрізняються лише URL. І те, і те лікується одним і тим самим розумінням: ООП — це не про синтаксис class, а про те, де провести межі між частинами коду, щоб зміна в одній не ламала іншу.

    Ця глава — канонічний розбір ООП на сайті: інші глави розділу («Page Object», «Принципи дизайну», «SOLID») спираються на неї. Її можна пропустити при першому проході, якщо мета — просто запустити перші тести (це глава-поглиблення). Але вага на співбесідах у неї висока, а ще вона задає словник, яким користуватимуться наступні глави. Приклади всюди — тестовий код: API-клієнт, конфігурація, стратегії логіну. Саме так це й треба вміти пояснювати: принцип одним реченням, а далі — приклад із автоматизації.

    Клас і об'єкт: API-клієнт як приклад

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

    У тестах найпоказовіший приклад класу — HTTP-клієнт до вашого API. Опишемо, що він уміє й що пам'ятає:

    class ApiClient {
      private token: string | null = null;
    
      constructor(private readonly baseUrl: string) {}
    
      async login(email: string, password: string): Promise<void> {
        const res = await fetch(`${this.baseUrl}/auth/login`, {
          method: 'POST',
          body: JSON.stringify({ email, password }),
        });
        this.token = (await res.json()).accessToken;
      }
    
      async getUser(id: string): Promise<User> {
        const res = await fetch(`${this.baseUrl}/users/${id}`, {
          headers: { Authorization: `Bearer ${this.token}` },
        });
        return res.json();
      }
    }

    Клас ApiClient описує тип. А от об'єкти — це вже конкретні клієнти, кожен зі своїм baseUrl і своїм токеном:

    const staging = new ApiClient('https://staging.example.com/api');
    const prod = new ApiClient('https://api.example.com');

    staging і prod — два різні об'єкти одного класу. Кожен тримає власний стан (token), тож логін під адміном у staging ніяк не зачепить prod. Ось навіщо тут ООП, а не просто набір функцій: стан (токен, базовий URL) і поведінка (login, getUser), що цей стан використовує, живуть разом, як одне ціле. Це підводить нас до першого принципу.

    Інкапсуляція: сховати те, що можна зламати

    Інкапсуляція (encapsulation) — це об'єднання даних і методів, які з ними працюють, в одному об'єкті, і приховування внутрішньої «кухні» за публічним інтерфейсом. Мета не в секретності заради секретності, а в контролі: зовнішній код не має дотягуватися до того, що може зіпсувати інваріанти об'єкта.

    Погляньте на token у прикладі вище — він private. Це навмисно. Тест не повинен ні читати сирий токен, ні присвоювати його руками — його справа викликати login() і getUser(). Чому це важливо саме в тестах:

    • Тести не чіпляються за реалізацію. Якщо завтра авторизація переїде з Bearer-токена на cookie-сесію (див. /web/kuki-sesii-ta-skhovyshche-brauzera), ви перепишете нутрощі ApiClient, а всі тести, що викликають login(), лишаться цілими. Публічний контракт не змінився — змінилося те, що за ним.
    • Неможливо потрапити в невалідний стан. Якщо будь-який тест міг би записати client.token = 'abc' напряму, з'явився б клас багів «а хтось десь підмінив токен». Приховане поле цю можливість прибирає за побудовою.

    TypeScript дає для цього private, readonly і справжні приватні поля з #:

    class ApiClient {
      #token: string | null = null; // недоступне ззовні навіть у рантаймі
    
      constructor(private readonly baseUrl: string) {}
    }

    Різниця тонка, але її люблять питати: private — це перевірка часу компіляції (у зібраному JS поле видно), а # — справжня приватність на рівні рушія, поле недоступне ззовні й у рантаймі. Для тестового коду зазвичай досить private; # беруть, коли пишуть бібліотеку, якою користуються інші.

    Абстракція: інтерфейс важливіший за реалізацію

    Абстракцію (abstraction) часто плутають з інкапсуляцією, і на співбесіді на цьому ловлять. Просте розведення: інкапсуляція — це як приховати деталі (механізм); абстракція — це рішення, які деталі взагалі не мають значення для того, хто користується об'єктом (дизайн). Інкапсуляція ховає токен. Абстракція каже: тесту байдуже, як саме відбувається логін, — йому важливо лише, що після loginAs('admin') він працює від імені адміна.

    У TypeScript абстракцію найчастіше виражають через інтерфейс (interface) — контракт без реалізації:

    interface LoginFlow {
      loginAs(role: 'admin' | 'user'): Promise<void>;
    }

    LoginFlow не каже нічого про те, як логінитись — через форму на сторінці, через прямий запит до API чи через підкидання cookie. Він описує лише що можна зробити. Тест, написаний проти LoginFlow, не знає й не хоче знати реалізацію. А коли реалізацій стає більше однієї — ми приходимо до поліморфізму.

    Поліморфізм: один інтерфейс, різні реалізації

    Поліморфізм (polymorphism) — це здатність різних класів відповідати на той самий виклик по-своєму. Один контракт, багато реалізацій; яку саме викличуть — вирішується не в місці виклику, а тим, який об'єкт туди передали.

    Найкорисніший приклад у тестах — логін. UI-логін повільний, але перевіряє саму форму; API-логін миттєвий і потрібен як підготовка стану для тестів про інше (принцип «готуй стан найдешевшим способом» — див. /avtomatyzatsiia/anatomiia-avtotestu-aaa-nezalezhnist-atomarnist). Обидва реалізують один інтерфейс:

    class UiLogin implements LoginFlow {
      constructor(private page: Page) {}
      async loginAs(role: 'admin' | 'user'): Promise<void> {
        await this.page.goto('/login');
        await this.page.getByLabel('Email').fill(users[role].email);
        await this.page.getByLabel('Password').fill(users[role].password);
        await this.page.getByRole('button', { name: 'Sign in' }).click();
      }
    }
    
    class ApiLogin implements LoginFlow {
      constructor(private request: APIRequestContext, private context: BrowserContext) {}
      async loginAs(role: 'admin' | 'user'): Promise<void> {
        const res = await this.request.post('/auth/login', { data: users[role] });
        const { accessToken } = await res.json();
        await this.context.addCookies([{ name: 'token', value: accessToken, url: baseURL }]);
      }
    }

    Тепер тест, чи будь-який хелпер, працює з LoginFlow і не знає, який об'єкт усередині:

    «interface»

    LoginFlow

    +loginAs(role) : Promise

    UiLogin

    +loginAs(role) : Promise

    ApiLogin

    +loginAs(role) : Promise

    «interface»

    LoginFlow

    +loginAs(role) : Promise

    UiLogin

    +loginAs(role) : Promise

    ApiLogin

    +loginAs(role) : Promise

    Практична вигода: один набір тестів на бізнес-сценарій ганяємо і через UI (повільно, повне покриття), і через API-підготовку (швидко) — підмінивши лише те, який LoginFlow передали. Той самий прийом лежить в основі тестових дублерів: реальний платіжний сервіс і його мок реалізують один інтерфейс, тому тест не помічає підміни (таксономія дублерів — /avtomatyzatsiia/testovi-dublery-stub-mock-fake-spy; патерни на кшталт Screenplay і Factory будують саме на поліморфізмі — /avtomatyzatsiia/paterny-v-avtomatyzatsii-screenplay-builder-factory-facade).

    Не плутайте поліморфізм із перевантаженням методів (кілька сигнатур одного імені) — на співбесіді це різні речі. Поліморфізм, про який питають у контексті ООП, — це саме підстановка різних реалізацій одного контракту.

    Успадкування: BaseTest — користь і межі

    Успадкування (inheritance) — механізм, коли клас-нащадок отримує поля й методи батьківського класу і може їх доповнювати чи перевизначати. Синтаксично це extends. У тестових проєктах класичне втілення — BaseTest: базовий клас зі спільною підготовкою, від якого успадковують усі тест-класи.

    abstract class BaseApiTest {
      protected client!: ApiClient;
    
      async setup(): Promise<void> {
        this.client = new ApiClient(process.env.BASE_URL!);
        await this.client.login(process.env.USER!, process.env.PASS!);
      }
    }
    
    class UserApiTest extends BaseApiTest {
      async createsUser() {
        await this.setup();
        // this.client уже залогінений — успадкована підготовка
      }
    }

    Користь очевидна: спільний setup (логін, клієнт, тестові дані) описано раз, нащадки його перевикористовують. Для двох-трьох близьких тест-класів це чесно економить код.

    А тепер межі — і це головне, що треба вміти сказати на співбесіді. Успадкування створює найжорсткіший зв'язок між класами, який є в ООП:

    • Крихкий базовий клас (fragile base class). Будь-яка зміна в BaseApiTest розходиться по всіх нащадках одразу. Додали в setup ще один крок — і десяток тест-класів, для яких він зайвий, тепер його теж виконують.
    • Глибокі ієрархії не читаються. Коли AdminUserApiTest extends UserApiTest extends BaseApiTest, щоб зрозуміти, звідки взявся стан у тесті, треба тримати в голові весь ланцюг. Успадкування каже «is-a» (нащадок є різновидом батька); якщо насправді зв'язок «has-a» («тесту потрібен залогінений клієнт»), успадкування — не той інструмент. Ці ж is-a і LSP розбирає глава про SOLID: /avtomatyzatsiia/solid-u-test-freimvorku.
    • Не можна взяти половину. Нащадок бере від батька все. Потрібен лише клієнт без логіну — доведеться або городити прапорці в базі, або ламати ієрархію.

    Сучасні ранери тому й тиснуть у бік композиції (composition) — «зібрати з частин» замість «успадкувати ціле». У Playwright спільну підготовку виносять не в BaseTest, а у фікстуру (fixture) — незалежний шматок, який тест підключає лише коли треба (механіка фікстур — /avtomatyzatsiia/testovyi-stek-zseredyny-raner-fikstury-khuky-konfihuratsiia):

    // Композиція через фікстуру замість успадкування BaseTest
    export const test = base.extend<{ client: ApiClient }>({
      client: async ({}, use) => {
        const client = new ApiClient(process.env.BASE_URL!);
        await client.login(process.env.USER!, process.env.PASS!);
        await use(client);
      },
    });
    
    // у тесті: залогінений client приходить як аргумент, без жодного extends
    test('creates user', async ({ client }) => {
      await client.getUser('42');
    });

    Тест бере рівно ті частини, які просить у сигнатурі, — і жодних успадкованих сюрпризів. Ширший розбор компромісу «композиція проти успадкування» — це канон окремої глави: /avtomatyzatsiia/pryntsypy-dyzainu-testovoho-kodu-dry-kiss-yagni-kompozytsiia. Тут достатньо винести: успадкування в тестах — інструмент вузького застосування, а не хребет фреймворку.

    Тизер: Page Object як ООП у зборі

    Найвідоміший об'єкт у автоматизації — page object: клас, що описує сторінку через її локатори (дані) і дії з нею (методи). Це ООП у чистому вигляді. Інкапсуляція: локатори приховані в об'єкті, тест смикає методи login() чи openMenu(), а не сирі селектори (єдине джерело правди для локаторів — /avtomatyzatsiia/lokatory-stratehiia-stabilnykh-selektoriv). Абстракція: тест каже що робить, page object знає як. Поліморфізм і композиція — коли складна сторінка збирається з менших компонентів (хедер, таблиця, модалка). Повний розбір — від класики до компонентного підходу — у наступній главі: /avtomatyzatsiia/page-object-vid-klasyky-do-komponentiv. Той самий принцип, до речі, працює й поза UI — ApiClient з початку глави це вже page object для API.

    Як відповідати на ООП-питання прикладами з автоматизації

    Сильна відповідь на «розкажіть про принципи ООП» будується так: принцип одним реченням → приклад із вашого тестового коду → чим він допоміг. Не означенням з підручника. Ось готова мапа, яку варто тримати в голові:

    ПринципОдне реченняПриклад з автоматизації
    ІнкапсуляціяДані й методи разом, нутрощі прихованіТокен private в ApiClient; зміна авторизації не ламає тести
    АбстракціяТесту важливо що, не якІнтерфейс LoginFlow — тесту байдуже, UI чи API
    УспадкуванняНащадок бере поля й методи батькаBaseTest зі спільним setup — і чому фікстури часто краще
    ПоліморфізмОдин контракт, різні реалізаціїUiLogin vs ApiLogin; реальний сервіс vs мок

    Помітьте: у графі «успадкування» відповідь одразу містить межу («і чому фікстури часто краще»). Це те, що відрізняє мідла від джуна — не переказати визначення, а показати, коли інструмент не треба. Інтерв'юер на це й дивиться.

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

    • Виглядає як інкапсуляція, а насправді просто private на всьому. Понатикати private перед кожним полем — не інкапсуляція. Інкапсуляція — це продуманий публічний контракт (які методи назовні) і свідомо прихована реалізація за ним. Якщо до половини приватних полів є публічний геттер-сеттер, ви нічого не інкапсулювали.
    • Виглядає як перевикористання, а насправді крихка ієрархія. BaseTest, від якого успадковані всі 200 тестів, — це не DRY, це єдина точка відмови. Одна зміна в базі — і падає весь прогін. Спільний стан краще роздавати композицією (фікстури), а не успадкуванням.
    • Виглядає як поліморфізм, а насправді if/else по типу. Якщо в коді if (mode === 'ui') {...} else {...}, розкиданий по десяти місцях, — це не поліморфізм, а його відсутність. Поліморфізм якраз прибирає ці розгалуження: одна реалізація інтерфейсу на кожен випадок, вибір робиться раз при створенні об'єкта.
    • Виглядає як абстракція, а насправді дірява обгортка. Якщо ApiClient.getUser() повертає сирий Response і тест сам парсить JSON та перевіряє статус — абстракція протікає, деталі транспорту вилізли назовні. Абстракція мала б віддати готовий User або кинути зрозумілу помилку.
    • Плутанина абстракція vs інкапсуляція на співбесіді. Найчастіший провал. Тримайте розведення: інкапсуляція ховає реалізацію, абстракція вирішує, що реалізація неважлива для користувача. Перше — механізм, друге — дизайн.

    Підсумок

    • Клас — шаблон, об'єкт — його екземпляр зі своїм станом; ApiClient тримає дані (токен) і поведінку (login) разом — саме тому це об'єкт, а не набір функцій.
    • Інкапсуляція ховає нутрощі за публічним контрактом, щоб зміна реалізації не ламала тести; абстракція вирішує, які деталі користувачу взагалі не потрібні.
    • Поліморфізм — один інтерфейс, різні реалізації (UiLogin/ApiLogin, реальний сервіс/мок); він прибирає if/else по типу й лежить в основі підміни дублерів.
    • Успадкування — найжорсткіший зв'язок; BaseTest корисний для двох-трьох близьких класів, але глибокі ієрархії крихкі — сучасні ранери віддають перевагу композиції через фікстури.
    • На співбесіді ООП пояснюють прикладом зі свого коду й межею застосування, а не означенням з підручника.

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

    • «Назвіть чотири принципи ООП» — базовий фільтр. Слабка відповідь — чотири означення. Сильна — кожен принцип + приклад із тестового коду (мапа вище). Інтерв'юер перевіряє, чи ви цим користувались, а не завчили.
    • «Чим абстракція відрізняється від інкапсуляції?» — улюблене питання на розведення. Ловлять тих, хто вважає їх синонімами. Відповідь: інкапсуляція — механізм приховування, абстракція — рішення про те, що ховати неважливо для користувача інтерфейсу.
    • «Композиція чи успадкування — що обрати для тестового фреймворку?» — мідл-рівень. Дивляться, чи знаєте ви межі успадкування (крихкий базовий клас, глибокі ієрархії) і чи назвете композицію через фікстури як типовий вибір у сучасних ранерах.
    • «Поясніть поліморфізм на прикладі з автоматизації» — сигнал, чи ви застосовували ООП, а не читали про нього. Готовий приклад — UI/API-логін під одним інтерфейсом або реальний сервіс проти мока.
    • «Що таке page object з погляду ООП?» — місток до наступної теми. Достатньо: клас, що інкапсулює локатори й дії сторінки; повний розбір — в окремій главі.

    Джерела

    Примітка: ООП не входить до силабусу ISTQB CTFL 4.0 — це інженерна, а не тест-аналітична тема, тож канонічні джерела тут мовні (MDN, TypeScript Handbook) і інструментальні (Playwright).