Використання шаблону проєктування “Модель об’єкта сторінки” в додатках Angular

Чистий код, чисті тести

При розробці програмних додатків є два аспекти, серед інших, які я вважаю особливо важливими:

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

Ця стаття про поєднання цих двох аспектів. Адже коли ви пишете тестовий код, ви все одно пишете код — і він також повинен бути чистим і модульним!

Тестування UI компонентів Angular

UI компоненти є будівельними блоками будь-якого веб-додатку.
Коли пишете юніт-тести для цих компонентів, важливо тестувати інтеграцію між класом і відповідним HTML, оскільки обидва ці елементи є важливими для функціональності компонента.

Це відоме як Тестування DOM компонента, і я вважаю, що це настільки важливе, що варто присвятити цілу статтю цій темі:

[

Тестування DOM компонентів в Angular

У цій статті я покажу переваги та приклади тестування DOM в Angular, хоча більшість концепцій…

javascript.plainenglish.io

](https://javascript.plainenglish.io/component-dom-testing-in-angular-0d2256414c06?source=post_page-----62c2fa42afb1--------------------------------)

У цій статті я поясню, як писати чисті тести для компонентів, використовуючи один із найпоширеніших шаблонів проєктування для тестування веб-додатків: Page Object Model.

Шаблон проєктування Page Object Model

Page Object Model (POM) — це шаблон проєктування, який використовується в автоматизованому тестуванні веб-додатків і сприяє модульності та повторному використанню коду.

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

Назва може бути трохи заплутаною…

Я навмисно використав термін “частина” замість “сторінка”, тому що термін “сторінка” мені здається трохи оманливим у цьому контексті.
Зазвичай "об'єкти сторінки" використовуються для управління конкретними частинами додатку, а не цілою "сторінкою". Однак термін "об'єкт сторінки" став стандартом і прийнятою назвою для цього шаблону проєктування, тому він широко визнається.

Коротко

Об'єкт сторінки абстрагує деталі інтерфейсу, інкапсулюючи всю логіку, що відповідає за взаємодію з DOM.

pic

Шаблон проєктування Page Object Model

Таким чином, тести не повинні турбуватися про пошук HTML елементів або натискання кнопок: вони делегують ці завдання об'єкту сторінки.

Розділення "як" і "що"

Результат — чистіший код і кращий розподіл обов'язків:

  • Об'єкт сторінки фокусується на "ЯК" — наприклад, як знайти HTML елемент, як визначити, чи є елемент "видимим" або в "темному режимі".
  • Тест фокусується на "ЩО" — наприклад,
    які дії потрібно виконати та які очікувані результати.

Використання шаблону Page Object Model в Angular

Коли я згадую цей шаблон проєктування, мене іноді запитують: "Хіба цей шаблон не використовувався для e2e тестів?"

Так, цей шаблон дійсно є дуже популярним у тестуванні кінцевих точок (e2e). Але чи можна використовувати його також для модульного тестування компонентів? Абсолютно! Адже це шаблон проєктування, і щоразу, коли ваші тести повинні взаємодіяти з DOM, він стає дуже корисним. Навіть документація Angular коротко згадує про нього.

Я насправді використовував цей шаблон у більшості Angular проєктів, над якими працював. Саме тому я вирішив створити невелику Angular бібліотеку, яка надає базові інструменти для використання об'єктів сторінки в тестах компонентів Angular — ngx-page-object-model.
Доступна на npm, вона мінімалістична, повністю відкритий код і може використовуватися з будь-яким тестувальним фреймворком (Jest, Jasmine, Vitest тощо).
Вона також може використовуватись разом із Spectator або як самостійний інструмент.

Тепер давайте розглянемо кілька прикладів коду, щоб побачити, як цей шаблон проектування працює на практиці!

Приклад

Компонент перемикання між темною та світлою темою

Розглянемо цей простий компонент ToggleStyleComponent, який приймає текстовий ввід і відображає його поряд із кнопкою для перемикання між «світлим» і «темним» режимами:

pic

pic

Ось реалізація компонента:

@Component({  
 selector: 'app-toggle-style',  
 standalone: true,  
 changeDetection: ChangeDetectionStrategy.OnPush,  
 template: `  
  {{ toggleText() }}   

 {{ text() }} 
    `,    styles: `    div {    padding: 20px;    margin-top: 10px;    }    .dark {    background-color: black;    color: white;    }    .light {    background-color: white;    color: black;    }    `,   })   export class ToggleStyleComponent {    readonly text = input();       protected readonly isDarkModeEnabled = signal(false);    protected readonly toggleText = computed(() =>    this.isDarkModeEnabled()     ? 'Switch to Light Mode'    : 'Switch to Dark Mode',    );       protected toggleDarkMode(): void {    this.isDarkModeEnabled.set(!this.isDarkModeEnabled());    }   } ```

## План тестування

Створимо простий план тестування для нашого компонента `ToggleStyleComponent`.
Ми хочемо, щоб наші юніт-тести перевіряли наступне:

- При ініціалізації компонент має відображати кнопку перемикання та контейнер для тексту
- При ініціалізації компонент має бути за замовчуванням у світлому режимі
- Компонент має відображати наданий текстовий ввід
- У світлому режимі контейнер для тексту має мати CSS клас `light`, а не `dark`
- У світлому режимі текст на кнопці має бути _«Перемкнути на темний режим»_
- У світлому режимі, при натисканні на кнопку, має відбуватися перехід на темний режим
- У темному режимі контейнер для тексту має мати CSS клас `dark`, а не `light`
- У темному режимі текст на кнопці має бути «Перемкнути на світлий режим»
- У темному режимі, при натисканні на кнопку, має відбуватися перехід на світлий режим

## Приклад реалізації тестів БЕЗ використання шаблону Page Object

Це (не ідеальний) приклад того, як ми реалізуємо вищезазначений план тестування для нашого компонента `ToggleStyleComponent` без використання шаблону проектування Page Object Model:


import { ToggleStyleComponent } from './toggle-style.component';  
import { TestBed } from '@angular/core/testing';  
import { By } from '@angular/platform-browser';  

describe(ToggleStyleComponent.name, () => {  
 beforeEach(async () => {  
 await TestBed.configureTestingModule({  
 imports: [ToggleStyleComponent],  
 }).compileComponents();  
 });  

 describe('Ініціалізація', () => {  
 it('має відобразити кнопку перемикання та контейнер для тексту', () => {  
 const fixture = TestBed.createComponent(ToggleStyleComponent);  
 fixture.detectChanges();  
 const textContainer = fixture.debugElement.query(By.css('div'));  
 const toggleButton = fixture.debugElement.query(By.css('button'));  
 expect(textContainer.nativeElement).toBeTruthy();  
 expect(toggleButton.nativeElement).toBeTruthy();  
 });  
 it('має бути в світлому режимі за замовчуванням', () => {  
 const fixture = TestBed.createComponent(ToggleStyleComponent);  
 fixture.detectChanges();
const textContainer = fixture.debugElement.query(By.css('div'));  

 expect(textContainer.nativeElement.classList).toContain('light');  
 expect(textContainer.nativeElement.classList).not.toContain('dark');  
 });  

 it('має відображати заданий текст', () => {  
 const fixture = TestBed.createComponent(ToggleStyleComponent);  

 fixture.componentRef.setInput('text', 'Мій текст');  
 fixture.detectChanges();  
 const textContainer = fixture.debugElement.query(By.css('div'));  

 expect(textContainer.nativeElement.textContent).toContain('Мій текст');  
 });  
 });  

 describe('Світлий режим', () => {  
 it('має відображати правильний стиль і текст кнопки', () => {  
 const fixture = TestBed.createComponent(ToggleStyleComponent);  

 fixture.detectChanges();  
 const textContainer = fixture.debugElement.query(By.css('div'));  
 const toggleButton = fixture.debugElement.query(By.css('button'));  

 expect(textContainer.nativeElement.classList).toContain('light');
expect(textContainer.nativeElement.classList).not.toContain('dark');  
 expect(toggleButton.nativeElement.textContent).toContain(  
 'Перейти в темний режим',  
 );  
 });  

 it('має перемикатись на темний режим після натискання кнопки', () => {  
 const fixture = TestBed.createComponent(ToggleStyleComponent);  

 fixture.detectChanges();  
 const textContainer = fixture.debugElement.query(By.css('div'));  
 const toggleButton = fixture.debugElement.query(By.css('button'));  

 toggleButton.nativeElement.click();  
 fixture.detectChanges();  

 expect(textContainer.nativeElement.classList).toContain('dark');  
 expect(textContainer.nativeElement.classList).not.toContain('light');  
 });  
 });  

 describe('Темний режим', () => {  
 it('має відображати правильний стиль і текст кнопки', () => {  
 const fixture = TestBed.createComponent(ToggleStyleComponent);  

 fixture.detectChanges();  
 const textContainer = fixture.debugElement.query(By.css('div'));
const toggleButton = fixture.debugElement.query(By.css('button'));  
 toggleButton.nativeElement.click();  
 fixture.detectChanges();  

 expect(textContainer.nativeElement.classList).toContain('dark');  
 expect(textContainer.nativeElement.classList).not.toContain('light');  
 expect(toggleButton.nativeElement.textContent).toContain(  
 'Перейти в світлий режим',  
 );  
 });  

 it('має перемикатись на світлий режим після натискання кнопки', () => {  
 const fixture = TestBed.createComponent(ToggleStyleComponent);  

 fixture.detectChanges();  
 const textContainer = fixture.debugElement.query(By.css('div'));  
 const toggleButton = fixture.debugElement.query(By.css('button'));  
 toggleButton.nativeElement.click();  
 fixture.detectChanges();  

 toggleButton.nativeElement.click();  
 fixture.detectChanges();  

 expect(textContainer.nativeElement.classList).toContain('light');  
 expect(textContainer.nativeElement.classList).not.toContain('dark');  
 });  
 });  
});

Ви вже відчуваєте, що наш тест можна значно покращити, чи не так?

## Приклад реалізації тестів за допомогою Page Object

Давайте спочатку створимо Page Object, який буде відповідальний за:

- знаходження HTML елементів
- взаємодію з ними
- визначення, чи активний світлий чи темний режим

Для цього ми просто створимо новий клас `Page`, який розширює базовий клас `PageObjectModel`, наданий бібліотекою [**ngx-page-object-model**](https://github.com/FrancescoBorzi/ngx-page-object-model), і створимо наші методи.

Для отримання HTML елементів ми могли б використовувати стандартні CSS селектори, такі як `div` і `button`, однак використання властивості `data-testid` вважається кращим способом (детальніше про це [тут](https://francescoborzi.github.io/ngx-page-object-model/docs/best-practices/data-testid)).

Код досить простий і зрозумілий:

import { DebugHtmlElement, PageObjectModel } from 'ngx-page-object-model';
class Page extends PageObjectModel {
// методи доступу до елементів
textContainer(): DebugHtmlElement {
return this.getDebugElementByTestId('text-container');
}
toggleButton(): DebugHtmlElement {
return this.getDebugElementByTestId('toggle-dark-mode-button');
}

// методи дій
getRenderedText(): string | null {
return this.textContainer().nativeElement.textContent;
}
clickToggleMode(): void {
this.toggleButton().nativeElement.click();
this.detectChanges();
}

// повторно використовувані макроси очікувань
expectLightModeActive(): void {
const containerClass = this.textContainer().nativeElement.classList;
expect(containerClass).toContain('light');
expect(containerClass).not.toContain('dark');
expect(this.toggleButton().nativeElement.textContent).toContain(
'Switch to Dark Mode',
);
}
expectDarkModeActive(): void {
const containerClass = this.textContainer().nativeElement.classList;
expect(containerClass).toContain('dark');
expect(containerClass).not.toContain('light');
expect(this.toggleButton().nativeElement.textContent).toContain(
'Switch to Light Mode',
);
}
}
```

Тепер у наших тестах ми можемо створити екземпляр нашого класу Page, передаючи в нього fixture компонента в конструктор:

const page = new Page(TestBed.createComponent(ToggleStyleComponent));

Використовуючи об'єкт page, ось як виглядають наші тести:

it('should render a toggle  and a text container 
', () => {    const page = new Page(TestBed.createComponent(ToggleStyleComponent));    page.detectChanges();       expect(page.textContainer()).toBeTruthy();    expect(page.toggleButton()).toBeTruthy();   });      it('should be in Light Mode by default', () => {    const page = new Page(TestBed.createComponent(ToggleStyleComponent));    page.detectChanges();
// this now fully checks all Light Mode props  
 page.expectLightModeActive();  
});  

it('should display the given text', () => {  
 const page = new Page(TestBed.createComponent(ToggleStyleComponent));  
 page.fixture.componentRef.setInput('text', 'My text');  
 page.detectChanges();  

 expect(page.getRenderedText()).toContain('My text');  
});  

it('should switch to Dark Mode after clicking the button', () => {  
 const page = new Page(TestBed.createComponent(ToggleStyleComponent));  
 page.detectChanges();  

 page.clickToggleMode();  

 // this now fully checks all Dark Mode props  
 page.expectDarkModeActive();  
});  

it('should switch to Light Mode after clicking the button again', () => {  
 const page = new Page(TestBed.createComponent(ToggleStyleComponent));  
 page.detectChanges();  

 page.clickToggleMode();  
 page.clickToggleMode();  

 page.expectLightModeActive();  
});

В результаті:

  • Код тесту виглядає чистіше і легше для сприйняття.
  • Більше немає повторів.
  • Потрібно менше тестових випадків, оскільки виклики page.expectLightModeActive() і page.expectDarkModeActive() вже повністю перевіряють правильність кожного режиму.
  • Код тесту може повністю зосередитись на що має статися і що потрібно перевірити, не турбуючись про як — деталі реалізації сховані в об'єкті сторінки.

Повний вихідний код цього прикладу доступний тут.

Більше про тестування UI-компонентів Angular

Перегляньте документацію ngx-page-object-model, де можна знайти більше можливостей, прикладів, кращих практик і технік з цієї теми.

Висновки

  • Як автоматизоване тестування, так і модульність коду є ключовими елементами при розробці додатків, що масштабуються.
  • Якість коду тестів є однаково важливою, і він має бути модульним та підтримуваним.
  • Юніт-тести для UI-компонентів повинні імітувати те, як користувач взаємодіє з ними.
  • Модель об'єкта сторінки (Page Object Model) — це шаблон проєктування, який допомагає писати модульний код тестів при взаємодії з DOM, створюючи додатковий рівень абстракції.
  • Бібліотека ngx-page-object-model може допомогти легко інтегрувати цей шаблон проєктування в тести компонентів Angular.

Перекладено з: Using the Page Object Model design pattern in Angular applications

Leave a Reply

Your email address will not be published. Required fields are marked *