Як керувати поведінкою e2e-тестів Playwright з TypeScript-декораторами

Усі статті, обговорення, новини про тестування — в одному місці. Підписуйтеся на DOU | QA!

Радий вітати! Я Роман, Senior Test Automation Engineer в TenantCloud. У цій статті я розповім, як можна застосувати TS-декоратори для реалізації запуску e2e-тестів для декстоп і мобайл-розширення.

Впровадження запусків тестів у мобільному розширенні для e2e-тестів у Playwright може бути складним завданням з декількох причин.

1. Окремі spec-файли: створення окремих spec-файлів для мобільних і десктопних платформ часто призводить до дублювання коду, коли різниця між ними мінімальна. Наприклад, потрібно натиснути дві додаткові кнопки на мобільному розширенні.

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

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

Завдяки декораторам TypeScript ми можемо інкапсулювати цю специфічну для платформи логіку в одному місці, спрощуючи наш код і роблячи його більш підтримуваним.

Наша мета — мати єдиний тест, який працює для обох платформ. З екземплярами класів, що поводяться по-різному залежно від платформи, як показано в наступному прикладі.

test.describe('Crawler', () => {
   test('Navigation', async ({ app, page, isMobile }) => {
      await page.goto('/');
      await app.dashboardPage.navbar.assertTitle();
      await app.dashboardPage.navbar.selectNavItem('Docs');
      await app.article.assertHeader('Installation');
      await app.article.assertTableOfContentsLinkCount(9);
      await app.dashboardPage.navbar.openNavigation();
      await app.dashboardPage.navbar.clickCloseNavigation();
      await app.article.navbar.clickSearch();
      await app.article.search.fill('api');
      await app.article.search.close();
      await app.article.search.assertModalNotExist();
   });
});

Що таке декоратори в TypeScript

Декоратори в TypeScript — це спосіб додавання спеціальної поведінки до класів та їх методів. Вони дають змогу модифікувати або розширювати функціональність без повторення коду, що робить його більш чистим та структурованим.

// test.ts
import { test as base } from '@playwright/test';


export const test = base.extend<>({
   isMobile: async ({ isMobile }, use) => {
       global.IS_MOBILE = isMobile;
       await use(isMobile);
   },
});

Реалізація декораторів

Почнемо з того, що зв’яжемо isMobile test option у Playwright з нашою глобальною змінною global.IS_MOBILE. Це дозволить встановлювати значення isMobile під час визначення проєктів у Playwright і використовувати його глобально, особливо в декораторі, без необхідності створювати окрему змінну isMobile у файлі .env.

Щоб використовувати декоратори, переконайтеся, що ви використовуєте TypeScript версії 5.0 або вище.

// Type definition for a class constructor
type ClassConstructor = new (...args: any[]) => any;


// A Map to store the mobile and desktop class mappings for each base class
const classMappings = new Map<Function, { mobile?: ClassConstructor; desktop?: ClassConstructor }>();


// Function to map a class to either 'mobile' or 'desktop'
function mapClass(classConstructor: ClassConstructor, classType: 'mobile' | 'desktop'): void {
   const baseClass = Object.getPrototypeOf(classConstructor);
   const mappings = classMappings.get(baseClass) ?? {};
   mappings[classType] = classConstructor;
   classMappings.set(baseClass, mappings);
}


// Decorator for the desktop version of the class
export const Desktop = () => {
   return function (DesktopClass: ClassConstructor, _context: Record<string, any>): ClassConstructor {
       mapClass(DesktopClass, 'desktop');
       return DesktopClass;
   };
};


// Decorator for the mobile version of the class
export const Mobile = () => {
   return function (MobileClass: ClassConstructor, _context: Record<string, any>): ClassConstructor {
       mapClass(MobileClass, 'mobile');
       return MobileClass;
   };
};


// Decorator for the base class that proxies to either the mobile or desktop version
export const Base = () => {
   return function (BaseClass: ClassConstructor, _context: Record<string, any>): ClassConstructor {
       return class Proxy extends BaseClass {
           public static isProxying = false; // Flag to prevent infinite recursion in the constructor


           constructor(...args: any[]) {
               if (!Proxy.isProxying) {
                   Proxy.isProxying = true;


                   try {
                     
                       const TargetClass = global.IS_MOBILE
                           ? classMappings.get(Proxy).mobile
                           : classMappings.get(Proxy).desktop;


                       if (TargetClass) {
                           const instance = new TargetClass(...args); // Create an instance of the appropriate class
                           Proxy.isProxying = false;
                           return instance;
                       }
                   } catch (error) {
                       throw new Error(`Index file with exports might not have been created \n ${error.message}`);
                   }


                   Proxy.isProxying = false;
               }
               super(...args); // Call the constructor of the base class if no proxying occurs
           }
       };
   };
};

Застосування декораторів

Щоб побачити, як ці декоратори працюють на практиці, застосуємо їх до NavbarComponent, який має поводитися по-різному на мобільних і десктопних платформах.

Для тестування я буду використовувати вебсайт Playwright як тестове середовище, а приклади коду базуються на версії Playwright 1.46.0.

Нижче наведені реалізації базового компонента та його варіацій, специфічних для кожної платформи.

// navbar.component.ts
import { Base } from '@global/decorators/platform.decorator';
import { expect, Page } from '@playwright/test';


@Base()
export class NavbarComponent {
   constructor(
       protected page: Page,
       protected closeNavigation = page.locator('[aria-label="Close navigation bar"]'),
       private title = page.locator('.navbar__title'),
       private colorMode = page.locator('.colorModeToggle_DEke button'),
       private search = page.locator('[aria-label="Search"]')
   ) {}


   public async assertTitle(): Promise<void> {
       await expect(this.title.first()).toHaveText('Playwright');
   }


   public async clickSearch(): Promise<void> {
       await this.search.click();
   }
   public async clickColorMode(): Promise<void> {
       await this.colorMode.click();
   }


   public async clickCloseNavigation(): Promise<void> {}


   public async openNavigation(): Promise<void> {}


   public async selectNavItem(_item: string): Promise<void> {}
}




// navbar.desktop.component.ts
import { Desktop } from '@global/decorators/platform.decorator';
import { NavbarComponent } from '@src/modules/shared/components/navbar/navbar.component';


@Desktop()
export class NavbarDesktopComponent extends NavbarComponent {
   public override async selectNavItem(item: string): Promise<void> {
       await this.page.getByText(item).click();
   }
}






// navbar.mobile.component.ts
import { Mobile } from '@global/decorators/platform.decorator';
import { NavbarComponent } from '@src/modules/shared/components/navbar/navbar.component';


@Mobile()
export class NavbarMobileComponent extends NavbarComponent {
   private navToggle = this.page.locator('[aria-label="Toggle navigation bar"]');


   public override async selectNavItem(item: string): Promise<void> {
       await this.openNavigation();
       await this.page.getByRole('link', { name: item }).click();
   }


   public override async openNavigation(): Promise<void> {
       await this.navToggle.click();
   }


   public override async clickCloseNavigation(): Promise<void> {
       await this.closeNavigation.click();
   }
}

NavbarComponent. Виконує роль базового класу з загальною функціональністю, яку будуть успадковувати як десктопні, так і мобільні компоненти.

NavbarDesktopComponent. Цей клас декоровано за допомогою @Desktop(), також він перевизначає метод selectNavItem, специфічний для десктопної версії.

NavbarMobileComponent. Цей клас використовує декоратор @Mobile() і перевизначає базові методи, такі як openNavigation та clickCloseNavigation, щоб реалізувати поведінку, специфічну для мобільної платформи.

Експортування компонентів та як працює index.ts

Щоб правильна версія компонента (мобільна або десктопна) створювалась при використанні декораторів @Mobile() та @Desktop(), всі пов’язані компоненти повинні бути експортовані з центрального файлу index.ts. Це важливо для правильного функціонування декораторів.

// index.ts
export * from './navbar.component';
export * from './navbar.mobile.component';
export * from './navbar.desktop.component';


// dashboard.page.ts
import { Page } from '@playwright/test';
import { BasePage } from '@src/modules/shared/modules/base/base.page';
import { NavbarComponent } from '@src/modules/shared/components/navbar';


export class DashboardPage extends BasePage {
   constructor(
       page: Page,
       public navbar = new NavbarComponent(page),
       private getStarted = page.getByText('Get started')
   ) {
       super(page);
   }


   public async clickGetStarted(): Promise<void> {
       await this.getStarted.click();
   }
}

Чому це працює

Коли ви імпортуєте index.ts, який повторно експортує компоненти, TypeScript виконує весь код верхнього рівня в межах модуля, включно з визначенням класів і декораторами. Оскільки декоратори в TypeScript виконуються в момент визначення класу, декоратори Desktop і Mobile реєструють відповідні класи (NavbarDesktopComponent, NavbarMobileComponent) у classMappings під час завантаження модуля.

Це дозволяє вашій логіці, специфічній для платформи, працювати правильно, коли ви пізніше створюєте екземпляри цих класів.

Порядок виконання декораторів

Коли NavbarComponent (позначений декоратором @Base) імпортується в NavbarDesktopComponent, то декоратор @Base на NavbarComponent виконується перед декоратором @Desktop() на NavbarDesktopComponent. У цей момент мапа classMappings порожня, оскільки декоратори для NavbarMobileComponent та NavbarDesktopComponent ще не були застосовані.

Роль класу Proxy. Клас Proxy «затримує» логіку всередині декоратора @Base() до моменту, коли фактично створюється екземпляр. Тобто логіка конструктора виконується пізніше, лише коли ви створюєте екземпляр NavbarComponent (який насправді є екземпляром `Proxy`). Це відбувається після того, як декоратори Desktop і Mobile вже виконалися та заповнили мапу classMappings. Це гарантує, що коли декоратору `@Base()` нарешті потрібно визначити, який клас створити, вся необхідна інформація вже доступна, що дозволяє забезпечити правильну та передбачувану поведінку.

Нескінченна рекурсія. Коли створюється NavbarMobileComponent, він наслідує NavbarComponent, який обгорнутий класом Proxy завдяки декоратору @Base. Під час інстанціювання Proxyперевіряє, чи є платформа мобільною, і намагається створити NavbarMobileComponent знову. Це повторно викликає конструктор Proxy, оскільки кожне створення NavbarMobileComponent призводить до нової спроби інстанціювати той самий клас, що викликає нескінченну рекурсивну петлю. Ця петля триває, поки не переповниться стек, що спричиняє помилку під час виконання.

Рішення. Проперті isProxying у класі Proxy введено для запобігання цьому нескінченному циклу.

Запуск тесту для перевірки роботи декораторів

Тепер, коли ми налаштували наші декоратори та пов’язали значення isMobile з global.IS_MOBILE, запустимо простий тест, щоб побачити, як декоратори працюють на практиці.

Ось приклад тесту:

test.describe('Crawler', () => {
   test('Navigation', async ({ app, page }) => {
       await page.goto('/');
       await app.dashboardPage.navbar.assertTitle();
       await app.dashboardPage.navbar.selectNavItem('Docs');
       await app.article.assertHeader('Installation');
   });
});

Я запущу його для обох платформ — декстопної та мобільної, використовуючи такі команди:

npx playwright test --project=local-mobile --project=local-chrome crawler.spec.ts

Ми можемо побачити, що різні компоненти використовуються залежно від умови isMobile.

У деяких випадках базовий клас може вже задовольняти вимоги для десктопної реалізації, роблячи декоратор `@Desktop` та додатковий клас для дестопної версії непотрібними. Ось приклад:

// article.page.ts
@Base()
export class ArticlePage extends BasePage {
constructor(
 page: Page,
 public navbar = new NavbarComponent(page),
 protected tableOfContentsLink = page.locator('.table-of-contents__link'),
 private header = page.locator('header')
) {
 super(page);
}


public async assertHeader(text: string): Promise<void> {
 await expect(this.header).toHaveText(text);
}


public async assertTableOfContentsLinkCount(count: number): Promise<void> {
 await expect(this.tableOfContentsLink).toHaveCount(count);
 }
}


// article.mobile.page.ts
@Mobile()
export class ArticleMobilePage extends ArticlePage {
private onThisPage = this.page.getByText('On this page');
public override async assertTableOfContentsLinkCount(count: number): Promise<void> {
 await this.onThisPage.click();
 await expect(this.tableOfContentsLink).toHaveCount(count);
 }
}

У ньому ArticlePage слугує базовою реалізацією для декстопної платформи. Оскільки цей клас уже відповідає вимогам декстопної версії, немає потреби створювати окрему реалізацію для декстопної платформи та застосовувати декоратор @Desktop. Декоратор @Mobile все ще використовується для ArticleMobilePage, щоб забезпечити специфічну для мобільного розширення поведінку.

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

Додаткові функції для умовних дій

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

Для вирішення цього завдання можна створити функції, які виконують дії залежно від заданої умови.

Ось як можна використовувати ці функції в тесті:

// utils.ts
export async function skipOn(flag: boolean, callback: () => Promise<void>): Promise<void> {
   if (!flag) {
       return callback();
   }
   return;
}


export async function onlyOn(flag: boolean, callback: () => Promise<void>): Promise<void> {
   if (flag) {
       return callback();
   }
   return;
}


test.describe('Crawler', () => {
   test('Navigation', async ({ app, page, isMobile }) => {
       await page.goto('/');
       await app.dashboardPage.navbar.assertTitle();
       await app.dashboardPage.navbar.selectNavItem('Docs');
       await app.article.assertHeader('Installation');
       await app.article.assertTableOfContentsLinkCount(9);
       await app.dashboardPage.navbar.openNavigation();
       await app.dashboardPage.navbar.clickCloseNavigation();


       await onlyOn(isMobile, async () => {
           await app.article.scrollToBottom();
           await app.article.footer.assertCopyright();
       });


       await skipOn(isMobile, async () => {
           await app.article.navbar.clickColorMode();
       });


       await app.article.navbar.clickSearch();
   });
});

Це все ще if, але з більш читабельною назвою, що робить намір коду зрозумілішим і легшим для сприйняття.

Обробка мобільних реалізацій у батьківських класах

Декоратори не будуть працювати при наслідуванні класу з мобільною реалізацією, оскільки дочірній мобільний клас автоматично не успадковує мобільну версію батьківського класу.

Як показано на картинці, коли ArticleMobilePage наслідує ArticlePage, він не автоматично успадковує BasePageMobile.

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

Ось концептуальний приклад:

if (isMobile && mobileClass?.__proto__?.__proto__?.prototype?.__proto__?.__proto__) {
   const parentMobileClass = classStore.find(
       item =>
           item.desktopClass ===
           mobileClass.__proto__.__proto__.prototype.__proto__.__proto__.constructor
   )?.mobileClass;


   if (parentMobileClass) {
       mobileClass.__proto__.__proto__.prototype.__proto__ = new parentMobileClass(...args);
   }
}

Хоча цей метод працює і я використовував його протягом тривалого часу, врешті-решт вирішив відмовитися через неприродну поведінку.

Висновок

Використання декораторів TypeScript для обробки логіки, специфічної для платформи, у тестах Playwright є ефективним способом спростити код. Завдяки декораторам ви можете уникнути дублювання коду, зменшити кількість умов `if` і зберегти єдиний тестовий сценарій для мобільних і декстопних платформ.

Загальні поради

— Пропуск дій на дестопній платформі. Якщо потрібно пропустити дію на декстопній платформі, але виконати її на мобільній, створіть порожній метод для декстопної версії.
— Використовуйте функції для умовної логіки. Якщо перевизначення методів недостатньо для пропуску або додавання дій для певної платформи, використовуйте функції skipOn() та onlyOn(). Ці функції допоможуть вам контролювати кількість дій або спосіб виконання дій і перевірок.
— Тегування тестів. Якщо тестовий файл призначений для запуску тільки на декстопній платформі, додайте до нього тег @onlyDesktop.
— Управління селекторами. Хоча ви можете перевизначати селектори для різних платформ, загалом краще уніфікувати їх, де це можливо. Це зменшує потребу в реалізаціях, специфічних для платформи.

Репозиторій з прикладами.

👍ПодобаєтьсяСподобалось12
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Хм, по факту ви реалізували патерн Стратегія

Так, згоден. І мені здається, що якщо правильно реалізувати Стратегію, то буде трохи проще і код не такий складний

Підписатись на коментарі