ООП на тестовому коді
Зміст
Об'єктно-орієнтоване програмування (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 і не знає, який об'єкт усередині:
Практична вигода: один набір тестів на бізнес-сценарій ганяємо і через 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 з погляду ООП?» — місток до наступної теми. Достатньо: клас, що інкапсулює локатори й дії сторінки; повний розбір — в окремій главі.
Джерела
- MDN — Classes (JavaScript reference) — класи, поля, приватні поля
#, успадкування в JS. - TypeScript Handbook — Classes —
private/readonly,implements, абстрактні класи, модифікатори доступу. - TypeScript Handbook — Interfaces (Everyday Types) — інтерфейси як контракти для абстракції й поліморфізму.
- Playwright — Page Object Models — канонічний приклад класу-об'єкта в автоматизації.
- Playwright — Test fixtures — композиція підготовки замість успадкування
BaseTest.
Примітка: ООП не входить до силабусу ISTQB CTFL 4.0 — це інженерна, а не тест-аналітична тема, тож канонічні джерела тут мовні (MDN, TypeScript Handbook) і інструментальні (Playwright).
Що таке клас і об'єкт? Поясніть на прикладі з тестового коду
Клас — це опис типу: які дані має тримати певна сутність і що вона вміє робити. Об'єкт — конкретний екземпляр цього опису, який реально живе в пам'яті зі своїм станом. У тестах зручний приклад — HTTP-клієнт до вашого бекенду: сам ApiClient описує тип (поле baseUrl, токен, методи login і getUser), а от new ApiClient('https://staging...') і new ApiClient('https://api...') — це вже два окремі об'єкти. Кожен носить власний токен, тому логін під адміном в одному не зачіпає інший. Практичний сенс: у прогоні на кілька середовищ або кілька ролей ви не боїтеся, що стан одного клієнта протече в інший, — він ізольований за побудовою.
Чому ApiClient варто робити класом, а не набором окремих функцій?
Бо стан і поведінка, що з цим станом працює, тут нерозривні, і клас тримає їх разом. Токен, отриманий у login(), потрібен усередині getUser(), і зберігати його десь у глобальній змінній чи проганяти параметром через кожен виклик — це запрошення до багів «а хто підмінив токен». Клас робить token своїм приватним полем: перший метод його кладе, другий читає, і назовні ця машинерія не стирчить. Набір голих функцій змусив би або тягати токен руками скрізь, або тримати його в модульній змінній, яку легко зіпсувати. Тому об'єкт тут не синтаксичний примус, а спосіб зробити один самодостатній вузол, який відповідає за всю комунікацію з API.
Що таке інкапсуляція і навіщо вона саме в автотестах?
Інкапсуляція — це об'єднання даних із методами, що ними керують, плюс приховування внутрішньої механіки за публічним інтерфейсом. Сенс не в секретності, а в контролі: зовнішній код не повинен мати доступу до того, чим можна порушити коректність об'єкта. У тестовому клієнті токен приховують саме тому — тест має право лише викликати login() і getUser(), а не читати чи присвоювати токен вручну. Це дає дві конкретні вигоди. По-перше, тести не чіпляються за реалізацію: коли авторизація завтра переїде з Bearer-токена на cookie-сесію, ви перепишете нутрощі клієнта, а тести з викликом login() лишаться цілими. По-друге, зникає цілий клас багів «хтось десь підмінив внутрішній стан», бо ззовні до нього просто не дотягнутися.
У чому різниця між private і полем через # у TypeScript?
Це два різні рівні приватності. private — перевірка часу компіляції: компілятор не дасть звернутися до поля ззовні, але в зібраному JavaScript це звичайне видиме поле, до якого в рантаймі можна дотягнутися. Поле через # — справжня приватність на рівні рушія: воно недоступне зовні навіть під час виконання, жоден трюк його не дістане. Для тестового коду зазвичай вистачає private, бо вам треба захиститися від власних випадкових помилок, а не від зловмисника. # беруть, коли пишуть бібліотеку для чужих рук і хочуть гарантувати, що ніхто не обійде інкапсуляцію в рантаймі. Це популярне питання-пастка саме тому, що люди плутають «не видно компілятору» з «не існує в рантаймі».
Чим абстракція відрізняється від інкапсуляції?
Це найчастіша пастка на співбесіді, і плутають їх постійно. Коротке розведення: інкапсуляція — це механізм, як саме сховати деталь; абстракція — це дизайнерське рішення, що певна деталь узагалі не має значення для того, хто користується об'єктом. Інкапсуляція ховає токен усередині клієнта. Абстракція каже інше: тесту байдуже, яким способом відбувається логін, — важливо лише, що після потрібного виклику він працює під заданою роллю. Перше відповідає на питання «як приховати», друге — на «що взагалі неважливо назовні». Сильний кандидат тримає це формулювання напоготові: механізм проти рішення, приховати проти вирішити, що ховати нема потреби.
Як абстракцію виражають у коді на TypeScript?
Найчастіше через інтерфейс — контракт, що описує, що можна зробити, без жодного слова про те, як. Наприклад, інтерфейс логіну оголошує метод «залогінитись під роллю» і на цьому все — він не знає, робиться це через форму на сторінці, прямий запит до API чи підкидання cookie. Тест, написаний проти такого інтерфейсу, свідомо не бачить реалізації й не хоче її бачити. Це і є абстракція в дії: код спирається на контракт, а не на конкретний спосіб його виконати. А щойно реалізацій цього контракту стає більше однієї, ми природно виходимо на поліморфізм.
Що таке поліморфізм? Наведіть приклад з автоматизації
Поліморфізм — це здатність різних класів по-своєму відповідати на однаковий виклик: один контракт, багато реалізацій, а яка спрацює — вирішує не місце виклику, а те, який об'єкт туди передали. Канонічний приклад у тестах — логін. UI-логін повільний, зате перевіряє саму форму; API-логін миттєвий і потрібен як дешева підготовка стану для тестів зовсім про інше. Обидва реалізують один інтерфейс логіну, тому будь-який тест чи хелпер працює з контрактом і не знає, який об'єкт усередині. Вигода конкретна: той самий бізнес-сценарій ганяємо і через UI (повільно, повне покриття), і через API-підготовку (швидко), підмінивши лише передану реалізацію. На цьому ж стоять тестові дублери: реальний сервіс і його мок реалізують один інтерфейс, і тест підміни не помічає.
Чим поліморфізм відрізняється від перевантаження методів?
Це різні речі, і на співбесіді їх свідомо розводять. Перевантаження — це кілька сигнатур під одним іменем методу, вибір між якими робиться за типами аргументів на етапі компіляції. Поліморфізм у контексті ООП, про який зазвичай питають, — це підстановка різних реалізацій одного контракту: об'єкт вирішує, як відповісти на виклик, у рантаймі. Простіше кажучи, перевантаження — про різні форми одного виклику, поліморфізм — про різні об'єкти за одним інтерфейсом. Якщо кандидат на «поясніть поліморфізм» починає розповідати про кілька сигнатур методу, це сигнал, що він плутає поняття.
Що таке успадкування і як воно типово виглядає в тест-проєкті?
Успадкування — механізм, коли клас-нащадок дістає поля й методи батька і може їх доповнювати чи перевизначати; синтаксично це extends. У тестах класичне втілення — базовий тест-клас зі спільною підготовкою (створення клієнта, логін, тестові дані), від якого успадковують усі тест-класи. Для двох-трьох близьких класів це чесно економить код: спільний setup описано раз, нащадки його перевикористовують. Але тут же ховається головна пастка, про яку варто сказати одразу, а не чекати додаткового питання. Успадкування — це найжорсткіший зв'язок між класами в ООП, і саме на межах його застосування інтерв'юер перевіряє, чи ви мідл, чи джун.
Які проблеми несе глибоке успадкування? Що таке крихкий базовий клас?
Крихкий базовий клас (fragile base class) — це коли будь-яка правка в базі миттєво розходиться по всіх нащадках. Додали в спільний setup ще один крок — і десяток тест-класів, яким він узагалі не потрібен, тепер теж його виконують; одна зміна може повалити весь прогін. Друга біда — глибокі ієрархії не читаються: коли клас успадковує клас, що успадковує ще один, щоб зрозуміти, звідки в тесті взявся стан, треба тримати в голові весь ланцюг. Третя — не можна взяти половину: нащадок дістає від батька все й одразу, а якщо потрібен лише клієнт без логіну, доводиться городити прапорці в базі або ламати ієрархію. Тому успадкування каже «is-a», а коли справжній зв'язок — «has-a» («тесту потрібен залогінений клієнт»), це вже сигнал, що інструмент обрано не той.
Композиція чи успадкування для тестового фреймворку — що обрати?
Для кістяка фреймворку — композицію, а успадкування лишити на вузькі випадки. Причина в тому самому крихкому базовому класі: коли від однієї бази висить весь набір тестів, спільна правка стає єдиною точкою відмови, і це не DRY, а ризик. Сучасні ранери тому й тиснуть у бік «зібрати з частин» замість «успадкувати ціле». У Playwright спільну підготовку виносять не в базовий клас, а у фікстуру — незалежний шматок, який тест підключає лише тоді, коли реально просить його в сигнатурі. Тест бере рівно ті частини, що йому потрібні, без успадкованих сюрпризів, і зміна однієї фікстури не зачіпає тих, хто її не використовує. Сильна відповідь називає межі успадкування і композицію через фікстури як типовий сучасний вибір.
Що таке has-a проти is-a і як це підказує вибір між композицією та успадкуванням?
is-a означає «нащадок є різновидом батька» — і саме це виражає успадкування. has-a означає «об'єкту щось потрібно, він цим володіє чи користується» — і це виражає композиція. Перевірка проста: спитайте себе, чи тест-клас справді є різновидом базового, чи йому просто потрібен залогінений клієнт. У переважній більшості тестових ситуацій це саме «потрібен» — а отже, has-a, а отже, композиція через фікстуру, а не extends. Плутанина між цими зв'язками — типова причина, чому проєкти обростають ієрархіями там, де вистачило б передати готову залежність. Ця ж лінія (is-a і принцип підстановки Лісков) детальніше розбирається в темі про SOLID.
«Виглядає як поліморфізм, а насправді if/else по типу» — про що це?
Про поширену ілюзію переиспользування. Якщо по коду розкидані розгалуження на кшталт «якщо режим UI — роби так, інакше — інакше», і таких місць десяток, це не поліморфізм, а якраз його відсутність. Справжній поліморфізм ці гілки прибирає: на кожен випадок — своя реалізація одного інтерфейсу, а вибір робиться один раз, у момент створення об'єкта. Далі код працює з контрактом і взагалі не знає про варіанти. Різниця практична: коли з'явиться третій режим, поліморфний код дістане ще одну реалізацію, а «if/else по типу» доведеться правити в десяти місцях, і одне з них обов'язково забудуть. Тому наявність повторюваних розгалужень по типу — запах, що напрошується на поліморфізм.
Що таке дірява абстракція і як вона виглядає на прикладі ApiClient?
Дірява абстракція (leaky abstraction) — це коли обгортка обіцяє сховати деталі, але вони все одно вилазять назовні й тому, хто нею користується, доводиться про них знати. Приклад: якщо метод клієнта повертає сирий Response, а тест сам парсить JSON і перевіряє статус-код, транспортні деталі протекли — абстракція не тримає. Правильно спроєктований метод мав би віддати вже готовий об'єкт User або кинути зрозумілу помилку, а роботу зі статусами й тілом лишити всередині. Ознака діри — це коли користувачі обгортки скрізь дублюють той самий шматок «дообробки», який мав бути захований. У тестах це особливо болить, бо така протічка розповзається по всіх тестах і будь-яка зміна транспорту знову їх усі зачіпає.
Що таке page object з погляду ООП?
Page object — це клас, який описує сторінку через її локатори (дані) і дії з нею (методи), тобто ООП у чистому вигляді. Інкапсуляція тут у тому, що локатори сховані всередині об'єкта, а тест смикає методи типу login() чи openMenu(), а не сирі селектори. Абстракція — у тому, що тест каже що робить, а page object знає як. Поліморфізм і композиція з'являються, коли складна сторінка збирається з менших компонентів — хедера, таблиці, модалки — замість одного god object. Той самий підхід працює і поза UI: клієнт до API — це, по суті, page object для бекенду. Тому на це питання достатньо відповісти «клас, що інкапсулює локатори й дії сторінки», а деталі лишити наступній темі про page object.
Як правильно відповідати на «розкажіть про чотири принципи ООП» на співбесіді?
За схемою «принцип одним реченням → приклад із вашого тестового коду → чим він допоміг», а не означенням з підручника. Інкапсуляція: дані й методи разом, нутрощі приховані — приватний токен у клієнті, тому зміна авторизації не ламає тести. Абстракція: тесту важливо що, а не як — інтерфейс логіну, якому байдуже, UI чи API. Поліморфізм: один контракт, різні реалізації — UI-логін проти API-логіну, реальний сервіс проти мока. Успадкування: нащадок бере поля й методи батька — базовий тест зі спільним setup, і одразу межа: чому фікстури часто кращі. Ключове — у відповіді про успадкування відразу назвати межу застосування; саме вміння показати, коли інструмент не треба, відрізняє мідла від того, хто завчив визначення.
Три ситуації з рев'ю тестового коду, де ООП вирішує, чи проєкт лишиться підтримуваним: три майже однакові API-клієнти, які просяться в один клас; розгалуження «якщо UI, інакше API», що напрошується на поліморфізм; і розповзлий базовий тест, який пора розібрати на фікстури. Скрізь — що не так, як переписати і чому саме так.
Кейс 1. Три копії API-клієнта на код-рев'ю
Приходить пул-реквест, у якому тести гатять у бекенд трьома окремими наборами функцій — для staging, для prod і для локалки. Відрізняються вони буквально базовим URL, а логін, зберігання токена і формування заголовків скопійовані трикрат. Симптом майбутнього болю: коли авторизація зміниться, правити доведеться в трьох місцях, і одне забудуть.
Було — стан і поведінка розсипані, токен живе в модульній змінній, яку може зіпсувати будь-хто:
// api-staging.ts, api-prod.ts, api-local.ts — три майже однакові файли
let token: string | null = null;
async function login(email: string, password: string) {
const res = await fetch('https://staging.example.com/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
token = (await res.json()).accessToken; // глобальний стан, будь-хто перезапише
}
async function getUser(id: string) {
const res = await fetch(`https://staging.example.com/api/users/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
Стало — один клас, baseUrl як параметр конструктора, токен схований у приватному полі:
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();
}
}
const staging = new ApiClient('https://staging.example.com/api');
const prod = new ApiClient('https://api.example.com');
Що дивитися і чому:
- Токен
private— це навмисно, а не косметика. Жоден тест не має його читати чи присвоювати руками; його справа — викликатиlogin()іgetUser(). Прибрали цілий клас багів «а хтось десь підмінив токен». baseUrlяк параметр знищив три копії. Замість трьох файлів — один клас і два рядки створення об'єктів.stagingіprodтримають кожен свій токен, тому логін під адміном в одному не тече в інший.- Зміна авторизації тепер локальна. Коли Bearer зміниться на cookie-сесію, перепишуться нутрощі клієнта, а всі тести з викликом
login()лишаться цілими — публічний контракт не змінився. Це і є вигода інкапсуляції на практиці.
Кейс 2. Розгалуження «UI чи API» напрошується на поліморфізм
Тест-хелпер логіну обріс режимами: у різних місцях коду розкидані if (mode === 'ui') ... else .... Додати третій спосіб входу (наприклад, підкидання cookie) означає знайти всі ці гілки й не забути жодної. Це не поліморфізм, а його відсутність.
Було — вибір способу розмазаний по гілках:
async function loginAs(role: 'admin' | 'user', mode: 'ui' | 'api', ctx: Ctx) {
if (mode === 'ui') {
await ctx.page.goto('/login');
await ctx.page.getByLabel('Email').fill(users[role].email);
await ctx.page.getByLabel('Password').fill(users[role].password);
await ctx.page.getByRole('button', { name: 'Sign in' }).click();
} else {
const res = await ctx.request.post('/auth/login', { data: users[role] });
const { accessToken } = await res.json();
await ctx.context.addCookies([{ name: 'token', value: accessToken, url: baseURL }]);
}
}
Стало — один контракт і по реалізації на спосіб; вибір робиться раз, при створенні об'єкта:
interface LoginFlow {
loginAs(role: 'admin' | 'user'): Promise<void>;
}
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 }]);
}
}
// тест працює з контрактом і не знає, який об'єкт усередині
async function prepare(login: LoginFlow) {
await login.loginAs('admin');
}
Що дивитися і чому:
- Розгалуження зникло, а не переїхало. Замість
if/elseу кожному місці — одна реалізація на випадок; яка спрацює, вирішує переданий об'єкт. Третій спосіб входу тепер додається новим класом, а не правкою десяти гілок. - Той самий сценарій ганяємо двома швидкостями. Через
UiLogin— повільно, зате перевіряємо форму; черезApiLogin— миттєво, як дешеву підготовку стану для тестів зовсім про інше. Змінюється лише переданийLoginFlow. - Це фундамент тестових дублерів. Реальний сервіс і його мок реалізують один інтерфейс, тому тест підміни не помічає — той самий поліморфізм, тільки замість UI/API там прод/мок.
Кейс 3. Розповзлий BaseTest — коли розібрати на фікстури
Класика зрілого проєкту: базовий тест-клас, від якого успадковані всі тести. Спершу він економив код, а тепер у ньому десять кроків setup, половина з яких конкретному тесту не потрібна, і будь-яка правка бази ризикує повалити весь прогін. Питання на рев'ю не «переписати негайно», а «де межа, за якою успадкування пора міняти на композицію».
Таблиця рішень — коли який інструмент:
| Ситуація | Успадкування (BaseTest) | Композиція (фікстура) |
|---|---|---|
| Скільки тест-класів ділять підготовку | 2–3 близькі | багато різних |
| Чи всім потрібен увесь setup | так, однаковий | ні, кожному свій набір |
| Ціна правки спільного коду | зачіпає всіх нащадків | зачіпає лише тих, хто просить |
| Природа зв'язку | тест є різновидом базового (is-a) | тесту потрібна залежність (has-a) |
| Глибина | пласко, без ланцюга | будь-яка, без ланцюга успадкування |
Було — спільний стан роздається через extends, нащадок бере все й одразу:
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(); // успадкована підготовка, навіть якщо потрібен лише клієнт
}
}
Стало — та сама підготовка як фікстура, тест бере рівно те, що просить у сигнатурі:
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);
},
});
test('creates user', async ({ client }) => {
await client.getUser('42'); // залогінений client приходить аргументом, без extends
});
Що дивитися і чому:
has-a, а неis-a. Тест не є різновидом базового тесту — йому потрібен залогінений клієнт. Щойно зв'язок читається як «потрібен», успадкування — не той інструмент, і фікстура чесніша.- Правка перестає бути єдиною точкою відмови. Зміна фікстури зачіпає лише тести, що її просять у сигнатурі, а не всіх нащадків одразу. Крихкий базовий клас зникає разом з
extends. - Тест бере лише потрібні частини. Немає ситуації «потрібен клієнт без логіну, а база тягне логін»: у фікстурній моделі кожен запитує рівно свій набір, і зайвий setup не нав'язується.
Клас, об'єкт, інкапсуляція
- Можу пояснити різницю клас vs об'єкт: перше — опис типу, друге — конкретний екземпляр у пам'яті зі своїм станом.
- Розумію, чому
ApiClient— це об'єкт, а не набір функцій: стан (токен) і поведінка (login/getUser) живуть разом як одне ціле. - Знаю, що інкапсуляція — це не секретність, а контроль: зовні не дати дотягнутися до того, чим можна зламати інваріанти.
- Можу назвати дві вигоди приватного токена в тестах: тести не чіпляються за реалізацію і не можуть загнати клієнт у невалідний стан.
- Знаю різницю
privatevs#: перше — перевірка часу компіляції (у JS поле видно), друге — справжня приватність у рантаймі; у тестах зазвичай доситьprivate.
Абстракція vs інкапсуляція
- Тримаю напоготові розведення: інкапсуляція — механізм (як приховати), абстракція — рішення (що взагалі неважливо назовні); не називаю їх синонімами.
- Знаю, що абстракцію в TypeScript виражають інтерфейсом — контрактом «що можна», без жодного слова про «як».
- Розумію, що дірява абстракція (leaky abstraction) — це коли деталі реалізації протікають назовні (сирий
Responseзамість готовогоUser).
Поліморфізм
- Можу дати визначення: один контракт, багато реалізацій, вибір робить переданий об'єкт, а не місце виклику.
- Маю готовий приклад з автоматизації: UI-логін проти API-логіну під одним інтерфейсом; реальний сервіс проти мока.
- Знаю різницю поліморфізм vs перевантаження методів і не плутаю їх у відповіді.
- Впізнаю антипатерн «виглядає як поліморфізм, а насправді
if/elseпо типу» і знаю, що поліморфізм ці розгалуження прибирає.
Успадкування vs композиція
- Можу пояснити, що успадкування — найжорсткіший зв'язок в ООП, і назвати його три межі.
- Знаю, що таке крихкий базовий клас (fragile base class): правка в базі розходиться по всіх нащадках одразу.
- Розумію проблему глибоких ієрархій (стан доводиться шукати по всьому ланцюгу) і «не можна взяти половину».
- Тримаю розведення
is-avshas-aі використовую його як тест: тесту клієнт потрібен (has-a), а не тест є клієнтом. - Можу пояснити, чому сучасні ранери віддають перевагу композиції: спільну підготовку виносять у фікстуру, яку тест підключає лише коли просить у сигнатурі.
Page object і співбесіда
- Можу пояснити page object з погляду ООП: клас, що інкапсулює локатори (дані) і дії (методи) сторінки;
ApiClient— той самий підхід поза UI. - Вмію відповідати на «чотири принципи ООП» за схемою «принцип → приклад зі свого коду → чим допоміг», а не означенням.
- Знаю, що у відповіді про успадкування треба одразу назвати межу (чому фікстури часто кращі) — це відрізняє мідла від джуна.
У чому різниця між класом і об'єктом?
Питання
Клас vs об'єкт — у чому різниця?