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

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

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

    Зміст

    SOLID випливає на співбесіді AQA з двох боків. Перший — прямий: інтерв'юер (часто з бекенд-фоном) просить «розкажіть про SOLID», і кандидат переказує п'ять означень з блогу, не давши жодного прикладу зі свого фреймворку. Другий — прихований, у щоденній роботі: тестовий проєкт із півсотні тестів починає гнити — god object на тисячу рядків, 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 писали для продуктового коду — довговічних модулів, які використовують багато місць. У тестах він застосовується з поправкою (докладно — в останньому розділі): найбільше він потрібен для інфраструктури тестів (page object, клієнти, фікстури), а не для тіла окремого тесту, яке має лишатися пласким і читатися згори вниз.

    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-хелпер, який уміє все на світі.

    // Порушення 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. Тест оголошує в сигнатурі, що йому потрібна абстракція, а фікстура вирішує, яку реалізацію підкласти.

    // Абстракція, від якої залежить тест (з глави про ООП)
    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, і рефакторинг мав би прибирати абстракції, а не додавати.

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

    • Виглядає як 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.
    • «Коли SOLID у тестах — це перебір?» — найсильніше питання, відрізняє мідла від того, хто вивчив акронім. Очікують: тести — це документація, надлишкові абстракції вбивають читабельність; SOLID — для інфраструктури (page object, клієнти, фікстури), тіло тесту лишається пласким; інтерфейс з однією реалізацією — запах.

    Джерела

    • SOLID — Wikipedia — оглядовий індекс п'яти принципів із першоджерелами кожного.
    • Liskov substitution principle — Wikipedia — формулювання підстановки й приклади порушень.
    • Playwright — Test fixtures — впровадження залежностей (DI) як механізм DIP у тестах.
    • Robert C. Martin. Clean Architecture (2017) — канонічне зведення SOLID як набору з розбором SRP і DIP.
    • Спеціального розділу в силабусі ISTQB CTFL 4.0 SOLID не має — це загальноінженерна, а не тест-аналітична тема; у контексті автоматизації її читають разом із главами про ООП і принципи дизайну тестового коду.