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

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

    SOLID у тест-фреймворку

    Зміст

    SOLID випливає на співбесіді AQA з двох боків. Перший — прямий: інтерв'юер із бекенд-фоном просить «розкажіть про SOLID», і кандидат переказує п'ять означень з блогу, не давши жодного прикладу зі свого фреймворку. Другий — прихований, у щоденній роботі: тестовий проєкт із півсотні тестів починає гнити — на тисячу рядків, BaseTest на чотири рівні, один клас-хелпер, який чіпаєш заради однієї фічі й ламаєш десять інших. SOLID — це п'ять правил Роберта Мартіна (Robert C. Martin) про те, як розкласти код так, щоб зміна в одному місці не розходилася на весь проєкт. У тестах ця тема гостра особливо, бо тестовий код міняється так само часто, як продукт, який він перевіряє.

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

    SOLID коротко: п'ять способів пережити зміну

    SOLID — акронім із п'яти принципів об'єктно-орієнтованого дизайну: самі принципи сформулював Роберт Мартін у роботі 2000 року, а в запам'ятовуваний акронім їх склав Майкл Фізерс (Michael Feathers) близько 2004-го. Спільна ідея всіх п'яти одна: зменшити зчеплення (coupling) між частинами коду так, щоб зміна лишалася локальною.

    ЛітераПринципОдне речення
    SSingle ResponsibilityУ класу — одна причина змінюватися
    OOpen-ClosedВідкритий для розширення, закритий для змін
    LLiskov SubstitutionНащадок підставляється замість батька, не ламаючи виклик
    IInterface SegregationКлієнт не залежить від методів, яких не використовує
    DDependency InversionЗалежимо від абстракції, а не від конкретної реалізації

    Важлива рамка на весь текст: SOLID писали для продуктового коду — довговічних модулів, які використовують багато місць. У тестах він застосовується з поправкою (докладно — в останньому розділі): найбільше він потрібен для інфраструктури тестів (, клієнти, ), а не для тіла окремого тесту, яке має лишатися пласким і читатися згори вниз.

    S — єдина відповідальність (Single Responsibility Principle, SRP)

    Формулювання Мартіна: у модуля має бути одна причина змінюватися — він відповідальний перед одним «актором». У тестах це найчастіше порушує god object — page object, який робить усе одразу.

    // Порушення SRP: три різні причини змінюватися в одному класі
    class LoginPage {
      async login(user: User): Promise<void> { /* заповнити форму, клацнути */ }
      async assertLoggedIn(): Promise<void> {
        await expect(this.page).toHaveURL('/dashboard');   // причина 1: політика перевірок
      }
      async createUser(u: User): Promise<void> {
        await this.api.post('/users', u);                  // причина 2: підготовка даних
      }
      async readUsersFromCsv(path: string): Promise<User[]> { /* ... */ } // причина 3: I/O
    }

    Цей клас змінюватимуть з трьох незалежних приводів: редизайн форми логіну, зміна того, що ми стверджуємо про успішний вхід, і зміна API створення юзерів. Три причини в одному файлі означають, що правка заради однієї зачепити дві інші, а конфлікти в git гарантовані. Розкладаємо за відповідальностями:

    • Page object лише взаємодіє. LoginPage заповнює форму й клацає — і все. — його єдине джерело істини (див. Локатори).
    • Перевірки живуть у тесті. expect — у тілі тесту, а не в page object, бо намір перевірки має бути видно локально (див. Анатомія автотесту).
    • Підготовка даних — у фабриці чи фікстурі. Створення юзера — це стратегія тест-даних, окрема відповідальність (див. Тест-дані).

    SRP у тестах — найкорисніший із п'яти принципів, бо він прямо б'є по головному запаху автоматизації: god page object, у якому змішані локатори, дії, та .

    O — відкритість/закритість (Open-Closed Principle, OCP)

    Принцип: сутність має бути відкрита для розширення, але закрита для модифікації — нову поведінку додають, не переписуючи наявний перевірений код. У тестах найпрактичніший прояв OCP — це параметризація: додати новий випадок, не чіпаючи тіло тесту.

    Антипатерн — розгалуження, яке доводиться редагувати щоразу:

    // Порушення OCP: новий 'viewer' змушує лізти в цю функцію й додавати гілку
    function permissionsFor(role: string): Permissions {
      if (role === 'admin')  return { canDelete: true,  canEdit: true };
      if (role === 'editor') return { canDelete: false, canEdit: true };
      throw new Error(`unknown role: ${role}`);
    }

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

    // OCP: розширюємо матрицю даними, тіло тесту не чіпаємо
    const roleMatrix = {
      admin:  { canDelete: true,  canEdit: true },
      editor: { canDelete: false, canEdit: true },
      viewer: { canDelete: false, canEdit: false }, // додали рядок — код перевірки закритий для змін
    } as const;
    
    for (const [role, perms] of Object.entries(roleMatrix)) {
      test(`${role}: права доступу до документа`, async ({ page }) => {
        // один сценарій обслуговує всі ролі; нова роль не вимагає нового коду
      });
    }

    Додати роль — це дописати рядок у дані, а не редагувати сценарій. Те саме працює для матриці «браузер × середовище» в конфігурації . Але чесно: OCP у тестах — це переважно параметризація й таблиці даних, а не плагінна архітектура; будувати формальний механізм розширення заради трьох випадків — це вже YAGNI (див. розділ нижче).

    L — підстановка Лісков (Liskov Substitution Principle, LSP)

    Принцип названо на честь Барбари Лісков (Barbara Liskov): об'єкт нащадка має підставлятися всюди, де очікують батька, не ламаючи того, хто його викликає. Порушення LSP — це коли нащадок формально успадковує тип, але зраджує його контракт: інша сигнатура, несподіваний виняток, інша семантика.

    У тестах це вилазить у неглибоких на вигляд ієрархіях page object:

    // Батько обіцяє: open() без аргументів веде на сторінку
    abstract class BasePage {
      abstract open(): Promise<void>;
    }
    
    class DashboardPage extends BasePage {
      async open(): Promise<void> { await this.page.goto('/dashboard'); }
    }
    
    class ProfilePage extends BasePage {
      // Порушення LSP: вимагає userId, якого немає в контракті батька
      async open(userId: string): Promise<void> { await this.page.goto(`/users/${userId}`); }
    }

    Поки код працює з ProfilePage напряму — усе добре. Але щойно з'явиться загальний хелпер, який приймає будь-яку BasePage і викликає open() (наприклад, «прогрій усі сторінки перед сюїтою»), ProfilePage зламає його: open() без userId поведеться не так, як обіцяв контракт. Це не баг конкретного методу — це неправильно обрана ієрархія.

    LSP дає систематичну відповідь на питання «чому глибокі ієрархії болять» з попередніх глав: якщо нащадок не підставляється замість батька чисто, значить, зв'язок насправді не «є» (is-a), а «має» (has-a), і його треба виражати композицією, а не extends. Найпростіший спосіб не порушити LSP у тестах — не будувати ієрархію там, де достатньо композиції компонентів (див. Page Object).

    I — розділення інтерфейсів (Interface Segregation Principle, ISP)

    Принцип: клієнт не повинен залежати від методів, яких не використовує. Замість одного «товстого» інтерфейсу — кілька вузьких і сфокусованих. У тестах товстий інтерфейс — це god-хелпер, який уміє все на світі.

    Виноска для тих, хто готується до CTAL-TAE: назву interface segregation тут ужито за автором акроніма — у Роберта Мартіна це «ISP) The Interface Segregation Principle». Силабус ISTQB CTAL-TAE v2.0 розшифровує цю літеру інакше — дослівно, §3.1.5, с. 28: «It is an acronym of single responsibility, open-closed, Liskov substitution, interface substitution and dependency inversion». Тобто в силабусі стоїть interface substitution, і на іспиті ви побачите саме це формулювання. Різниця не косметична: «підстановка» вже зайнята сусідньою літерою L (Liskov substitution), а сегрегація описує інше — розділення товстого контракту на вузькі. Галузева норма й сам автор принципу вживають segregation, тож глава пише так само, але про рядок силабуса треба знати.

    // Порушення ISP: смоук-тесту потрібен лише логін, а він тягне весь контракт
    interface TestHelper {
      loginViaUi(): Promise<void>;
      loginViaApi(): Promise<void>;
      seedOrders(n: number): Promise<void>;
      clearInbox(): Promise<void>;
      chargeCard(amount: number): Promise<void>;
      // ...ще 30 методів
    }

    Ціна такого інтерфейсу видно саме в тестах. По-перше, тест, якому треба лише залогінитись, змушений знати про 35 методів. По-друге — і це болючіше — щоб замокати цей TestHelper у якомусь ізольованому тесті, доведеться застабити всі 35 методів, хоча використовуються два. Розбиваємо на вузькі здатності:

    // ISP: сфокусовані контракти — тест залежить рівно від того, що використовує
    interface Auth     { loginAs(role: Role): Promise<void>; }
    interface OrderApi { seed(n: number): Promise<Order[]>; }
    interface Mailbox  { latest(): Promise<Email>; }

    Це рідна модель фікстур: краще кілька дрібних фікстур, кожну з яких тест підключає за потреби, ніж одна god-фікстура з усім усередині. На тому самому принципі стоять «здатності» (abilities) у патерні Screenplay — вони навмисно дрібні (див. Патерни в автоматизації).

    D — інверсія залежностей (Dependency Inversion Principle, DIP)

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

    І тут головне для AQA: фікстури Playwright працюють як (dependency injection, DI) — механізм, яким реалізують DIP. Тест оголошує в сигнатурі, що йому потрібна абстракція, а фікстура вирішує, яку реалізацію підкласти. Одразу застереження: це наше прочитання, а не слова доки — сторінка «Test fixtures» говорить про ізоляцію, підключення на вимогу й , а терміна dependency injection не вживає взагалі. Прямо цей термін уживає CodeceptJS: там є окремий розділ «Dependency Injection» і поле include як реєстр інжектованих обʼєктів.

    // Абстракція, від якої залежить тест (з глави про ООП)
    interface LoginFlow { loginAs(role: Role): Promise<void>; }
    
    // Фікстура вирішує, ЯКУ реалізацію дати; тест цього не знає
    export const test = base.extend<{ loginFlow: LoginFlow }>({
      loginFlow: async ({ page, request, context }, use) => {
        const flow = process.env.LOGIN === 'ui'
          ? new UiLogin(page)                 // повільно, перевіряє саму форму
          : new ApiLogin(request, context);   // швидко, як підготовка стану
        await use(flow);
      },
    });
    
    test('адмін бачить панель керування', async ({ loginFlow, page }) => {
      await loginFlow.loginAs('admin');       // залежність від контракту, не від реалізації
      await expect(page.getByRole('heading', { name: 'Admin' })).toBeVisible();
    });

    Тест залежить від LoginFlow, а UiLogin і ApiLogin — теж від LoginFlow. Стрілка залежності перевернута: деталі вказують на абстракцію, а не тест на деталі. Той самий прийом дозволяє підмінити реальний платіжний сервіс його мок-дублером, не змінивши жодного тесту (див. Тестові дублери).

    Низький рівень: як саме

    Високий рівень: що перевірити

    Тест

    Абстракція
    interface LoginFlow / фікстура

    UiLogin

    ApiLogin

    Мок-сервіс

    Низький рівень: як саме

    Високий рівень: що перевірити

    Тест

    Абстракція
    interface LoginFlow / фікстура

    UiLogin

    ApiLogin

    Мок-сервіс

    Ключове розведення для співбесіди: DIP — це принцип (залежати від абстракції), а DI через фікстури — механізм, яким його досягають. У сучасних ранерах DIP дістається майже даром: якщо ви користуєтесь фікстурами замість того, щоб робити new ApiClient(...) всередині кожного тесту, — ви вже застосовуєте DIP, навіть не називаючи його так (механіка фікстур — Тестовий стек зсередини).

    Де SOLID у тестах — overengineering

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

    Практична межа проходить між інфраструктурою й тілом тесту:

    ПринципВарте застосування в тестахЗазвичай overengineering
    SRPРозвести локатори / дії / асерти / сідінгДробити page object на класи по одному методу
    OCPПараметризація, таблиці даних, матриці конфігуПлагінна архітектура на 20 тестів
    LSPНе форсувати extends там, де не «є»Будувати LSP-коректну ієрархію замість композиції
    ISPДрібні сфокусовані фікстури й клієнтиІнтерфейс під кожен клас «про всяк випадок»
    DIPФікстури/DI для клієнтів і логінуШар абстракцій «щоб легко переїхати з Playwright»

    Типова траєкторія перебору: команда бачить, що фікстури — це DIP, і починає ховати будь-яку залежність за інтерфейсом; кожен page object отримує інтерфейс, якому відповідає одна реалізація; з'являється абстрактний шар над Playwright «щоб легко змінити інструмент», якого ніхто ніколи не змінить. Це не SOLID — це його карго-культ. Інтерфейс з єдиною реалізацією не додає гнучкості, а лише зайвий файл, у який стрибати на Cmd+click.

    Здорове правило: застосовуйте SOLID до того тестового коду, який реально має багато викликачів — page object, API-клієнти, фікстури. Це справжня інфраструктура, і там принципи окупаються. Не застосовуйте його до окремого тесту — тест лишається пласким, явним, DAMP, навіть ціною повторів між сусідніми тестами. Якщо, щоб зрозуміти один тест, доводиться відкрити три інтерфейси й дві фабрики, SOLID тут переможений YAGNI, і рефакторинг мав би прибирати абстракції, а не додавати.

    Окремо про джерела теми: спеціального розділу в силабусі ISTQB CTFL 4.0 SOLID не має — це загальноінженерна, а не тест-аналітична тема. У контексті автоматизації її читають разом із главами про ООП і принципи дизайну тестового коду, а канонічну опору з боку тестування дає CTAL-TAE.

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

    • Виглядає як SRP, а насправді дроблення заради дроблення. Розбити page object на десять класів по одному методу — це не єдина відповідальність, а фрагментація, у якій сторінку неможливо зібрати думкою. SRP — про різні причини змінюватися, а не про кількість методів у класі.
    • Виглядає як DIP, а насправді зайвий шар. Інтерфейс, у якого рівно одна реалізація і жодного плану на другу, не інвертує залежність, а лише додає файл. DIP окупається там, де реалізацій справді більше однієї (UI/API-логін, реальний сервіс/мок).
    • Виглядає як OCP, а насправді передчасна платформа. Плагінний механізм «щоб потім легко додавати перевірки» на проєкті з двадцятьма тестами — це YAGNI. Чесний OCP у тестах — це параметризація, а не фреймворк усередині фреймворку.
    • Виглядає як гарна ієрархія, а насправді порушення LSP. ProfilePage.open(id) замість open() — нащадок, який не підставляється замість батька. Симптом того, що зв'язок насправді «має», і його треба виражати композицією, а не успадкуванням.
    • Виглядає як ISP, а насправді god-фікстура. Одна фікстура, що віддає об'єкт із півсотнею методів, змушує кожен тест і кожен мок знати про все. Дрібні сфокусовані фікстури й клієнти читаються й мокаються локально.

    Підсумок

    • SOLID — п'ять правил про зменшення зчеплення, щоб зміна лишалася локальною; у тестах найцінніші SRP (проти god page object) і DIP (через фікстури — дістається майже даром).
    • LSP дає систематичну причину, чому глибокі ієрархії болять: якщо нащадок не підставляється замість батька чисто, зв'язок «має», а не «є», — і його місце в композиції, а не в extends.
    • OCP у тестах — це параметризація й таблиці даних (розширюємо даними, не чіпаючи код), а не плагінна архітектура; ISP — дрібні сфокусовані фікстури й клієнти замість god-хелпера.
    • Межа з overengineering проходить між інфраструктурою й тілом тесту: SOLID застосовують до page object, клієнтів і фікстур (багато викликачів), а окремий тест лишають пласким і DAMP.
    • Інтерфейс з єдиною реалізацією, абстракція над Playwright «на майбутнє», ієрархія заради LSP там, де досить композиції, — це карго-культ SOLID, а не SOLID.

    Можливі питання

    • «Розкажіть про SOLID» — базовий фільтр, зокрема від бекенд-інтерв'юера. Слабка відповідь — п'ять означень. Сильна — кожна літера + приклад із вашого тестового коду (SRP: розвести дії й асерти в page object; DIP: фікстури як DI). Інтерв'юер перевіряє, чи ви цим користувались.
    • «Наведіть приклад SRP / DIP у своєму фреймворку» — сигнал зрілості. Готові приклади: SRP — page object лише взаємодіє, асерти в тесті, сідінг у фабриці; DIP — тест залежить від LoginFlow-фікстури, а не від new ApiClient().
    • «Що таке LSP і до чого воно в page object?» — мідл-рівень. Чекають на зв'язок: якщо нащадок ламає контракт батька (інша сигнатура open()), це знак, що потрібна композиція, а не успадкування.
    • «Де фікстури Playwright — це SOLID?» — перевіряють, чи бачите ви DIP/DI за інструментом. Відповідь: фікстура підставляє тесту готову залежність, тож тест не знає конкретної реалізації — це і є DIP на практиці. Корисно додати, що сама дока Playwright слова «dependency injection» не вживає (на відміну від CodeceptJS) — це наша інтерпретація механізму.
    • «Коли SOLID у тестах — це перебір?» — найсильніше питання, відрізняє мідла від того, хто вивчив акронім. Очікують: тести — це документація, надлишкові абстракції вбивають читабельність; SOLID — для інфраструктури (page object, клієнти, фікстури), тіло тесту лишається пласким; інтерфейс з однією реалізацією — запах.

    Джерела

    SOLID коротко: п'ять способів пережити зміну

    • Wikipedia — SOLID — акронім пʼяти принципів, покликаних зробити код зрозумілішим, гнучкішим і легшим у підтримці; там же факт, який часто плутають: акронім склав Michael Feathers (~2004), тобто автор принципів і автор акроніма — різні люди (trust: secondary, dec-0710).
    • Robert C. Martin — Solid Relevance — пʼять однорядкових формулювань від автора принципів і різні модальності, які не є стилістикою: should в OCP і DIP, must not — у LSP.
    • ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5: SOLID віднесено до принципів, що покращують читабельність, підтримуваність і масштабованість саме тестового коду.

    S — єдина відповідальність (Single Responsibility Principle, SRP)

    • Robert C. Martin — The Single Responsibility Principle — автор сам називає класичне «one and only one reason to change» недостатнім («What defines a reason to change?») і доводить формулювання до людей: зміни модуля мають походити від однієї вузько визначеної бізнес-функції.
    • Robert C. Martin — Solid Relevance — робоче формулювання того самого принципу: «Gather together the things that change for the same reasons. Separate things that change for different reasons».
    • Wikipedia — SOLID — звʼязок із тестуванням названо прямо: тестованість — наслідок SRP, бо класи з одним фокусом легше покривати (trust: secondary, dec-0710).

    O — відкритість/закритість (Open-Closed Principle, OCP)

    • Robert C. Martin — Solid Relevance — формулювання й модальність першоджерела: «A Module should be open for extension but closed for modification».
    • ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5: розшифровка акроніма в канонічному для розділу джерелі, включно з open-closed.

    L — підстановка Лісков (Liskov Substitution Principle, LSP)

    • Robert C. Martin — Solid Relevance — авторське формулювання з найсильнішою модальністю («A program that uses an interface must not be confused by an implementation of that interface») і визнання поширеної помилки: «People (including me) have made the mistake that this is about inheritance. It is not. It is about sub-typing»; симптом порушення — розмноження if/switch у клієнтському коді.
    • Wikipedia — Liskov substitution principle — чому порушення не ловить компілятор: відношення семантичне, а не синтаксичне, тож виявляється лише на виконанні (trust: secondary, dec-0710).

    I — розділення інтерфейсів (Interface Segregation Principle, ISP)

    • Robert C. Martin — Solid Relevance — «Keep interfaces small so that users donʼt end up depending on things they donʼt need»; назва літери в автора акроніма — «ISP) The Interface Segregation Principle».
    • ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5, с. 28: як цю саму літеру розшифровує канонічне джерело розділу — «interface substitution».
    • Playwright — Test fixtures — модель дрібних фікстур на вимогу: оснастку піднімають лише під те, що тест справді просить.

    D — інверсія залежностей (Dependency Inversion Principle, DIP)

    • Robert C. Martin — Solid Relevance — «Depend in the direction of abstraction. High level modules should not depend upon low level details».
    • CodeceptJS — Page Objects — де термін «впровадження залежностей» ужито прямо: «All objects described here are injected via Dependency Injection», а include є реєстром інжектованих обʼєктів.
    • Playwright — Test fixtures — межа: та сама механіка описана іншими словами, словосполучення dependency injection на сторінці немає жодного разу — читання фікстур як DI наше.
    • Wikipedia — SOLID — слабке зчеплення від DIP робить код гнучкішим і легшим у тестуванні (trust: secondary, dec-0710).

    Де SOLID у тестах — overengineering

    • ISTQB® CTAL-TAE Syllabus v2.0 — §3.1.5: канон рекомендує вживати патерни й принципи там, де вони корисні й доречні, а не всюди — це і є межа застосування SOLID у тестах.
    • Playwright — Best Practices — той самий обмежувач з боку інструмента: трохи дублювання в простих тестах дозволено, якщо це робить їх зрозумілішими й легшими в підтримці.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.