Powrót do: Praktyczne wprowadzenie do testów automatycznych z Playwright
POM i komponenty, czyli wspólne elementy
TIP: Ta lekcja jest częścią rozwijanego Programu Testy Automatyczne z Playwright 🎭
Prezentacja
Dodatkowe materiały
Bazujemy na kodzie lekcji L06_pom_refactor
Kod wynikowy tej lekcji znajduje się tu: L07_pom_component
Pamiętaj, aby po danej porcji pracy: uruchamiać test, commitować poprawnie działający kod 😉
TIP: Zauważ, że do wspólnego komponentu możemy też przenieść element wyświetlający nazwę użytkownika, która również jest częścią panelu bocznego 😉
Zawartość side-menu.component.ts:
export class SideMenuComponent {
constructor(private page) { }
paymentLink = this.page.getByRole('link', { name: 'płatności' })
}
Zawartość pulpit.page.ts oraz wykorzystanie SideMenuComponent:
import { Page } from '@playwright/test';
import { SideMenuComponent } from '../component/side-menu.component';
export class PulpitPage {
constructor(private page: Page) {}
sideMenuComponent = new SideMenuComponent(this.page)
transferReceiverInput = this.page.locator('#widget_1_transfer_receiver');
transferAmountInput = this.page.locator('#widget_1_transfer_amount');
transferTitleInput = this.page.locator('#widget_1_transfer_title');
transferButton = this.page.getByRole('button', { name: 'wykonaj' });
actionCloseButton = this.page.getByTestId('close-button');
messageText = this.page.locator('#show_messages');
topUpReceiverInput = this.page.locator('#widget_1_topup_receiver');
topUpAmountInput = this.page.locator('#widget_1_topup_amount');
topUpAgreementCheckbox = this.page.locator(
'#uniform-widget_1_topup_agreement span'
);
topUpExecuteButton = this.page.getByRole('button', {
name: 'doładuj telefon',
});
moneyValueText = this.page.locator('#money_value');
userNameText = this.page.getByTestId('user-name')
}
Zawartość payment.spec.ts wraz z wykorzystaniem PulpitPage, w którym znajduje się SideMenuComponent:
import { test, expect } from '@playwright/test';
import { loginData } from '../test-data/login.data';
import { LoginPage } from '../pages/login.page';
import { PaymentPage } from '../pages/payment.page';
import { PulpitPage } from '../pages/pulpit.page';
test.describe('Payment tests', () => {
test.beforeEach(async ({ page }) => {
const userId = loginData.userId;
const userPassword = loginData.userPassword;
await page.goto('/');
const loginPage = new LoginPage(page)
await loginPage.loginInput.fill(userId)
await loginPage.passwordInput.fill(userPassword)
await loginPage.loginButton.click()
const pulpitPage = new PulpitPage(page)
await pulpitPage.sideMenuComponent.paymentLink.click();
});
test('simple payment', async ({ page }) => {
// Arrange
const transferReceiver = 'Jan Nowak';
const transferAccount = '12 3456 7890 1234 5678 9012 34568';
const transferAmount = '222';
const expectedMessage = `Przelew wykonany! ${transferAmount},00PLN dla Jan Nowak`;
// Act
const paymentPage = new PaymentPage(page)
await paymentPage.transferReceiverInput.fill(transferReceiver)
await paymentPage.transferToInput.fill(transferAccount)
await paymentPage.transferAmountInput.fill(transferAmount)
await paymentPage.transferButton.click()
await paymentPage.actionCloseButton.click()
// Assert
await expect(paymentPage.messageText).toHaveText(expectedMessage);
});
});



Hej,
czy bardzo złym pomysłem jest extendowanie klas page’ów o klasę komponentu sideMenu zamiast przypisywać instancję klasy komponentu do pola klasy?
Czyli mniej więcej coś takiego:
export class SideMenuComponent { paymentsNavBtn: Locator; constructor(protected readonly page: Page) { this.paymentsNavBtn = this.page.getByRole('link', { name: 'płatności' }); } } export class DasboardPage extends SideMenuComponent { .... constructor(page: Page) { super(page); .... } }i standardowe użycie w teście:
Hej,
Świetne pytanie!
Technicznie jest to możliwe, ale nie jest zalecane (i ja również bardzo bym to odradzał), ponieważ prowadzi do nieintuicyjnej hierarchii dziedziczenia i utrudnia utrzymanie kodu.
W takim przypadku dziedziczenie nie odzwierciedla rzeczywistej relacji.
DashboardPagenie jestSideMenuComponent, ale zawiera go jako element interfejsu.W dziedziczeniu powinniśmy myśleć o elementach dziedziczących jako elementach rozszerzających element bazowy.
Mam dobry przykład z życia, który lepiej obrazuje relacje Page a Component 😉
Załóżmy że mamy
RadioorazCar.Radio jest naturalnym elementem samochodu (czyli jest komponentem, z którego zbudowany jest samochód).
I gdybyśmy teraz zrobili dziedziczenie – czyli Car dziedziczy po Radio:
class Radio { playMusic() { console.log('Playing music...'); } } class Car extends Radio { // ❌ drive() { console.log('Driving...'); } }to mamy sprzeczność bo samochód nie jest radiem!
Dodatkowo co w przypadku gdy chcemy dodać więcej elementów wyposażenia do naszego samochodu? I tu pojawiają się kolejne problemy.
W takich przypadkach lepszym podejściem jest kompozycja zamiast dziedziczenia – czyli tworzenie instancji komponentów wewnątrz klas, zamiast ich rozszerzania 😉
Przykład poprawnego użycia dziedziczenia mógłby wyglądać w następujący sposób.
Załóżmy, że mamy klasę bazową Page z pewnymi metodami.
I chcemy, aby DashboardPage oraz SettingsPage dziedziczyły te metody.
Dodatkowo mozemy do klasy bazowej dodać różne elementy (przez kompozycję) i wtedy taka klasa pochodna automatycznie je odziedziczy:
class BasePage { sideMenu: SideMenuComponent; constructor(protected page: Page) { this.sideMenu = new SideMenuComponent(page); } async takeScreenshot() { await this.page.screenshot({ path: 'screenshot.png' }); } } class DashboardPage extends BasePage { async open() { await this.page.goto('/dashboard'); } } class SettingsPage extends BasePage { async open() { await this.page.goto('/settings'); } }Cześć! Jak podejść do przypadku, w którym chciałbym jedynie sprawdzić czy side menu jest widoczne, bez odwoływania się do web elementów które go tworzą? Czy w takiej sytuacji należy dodać dodatkowy lokator odwołujący się do całego side menu?
export class SideMenuComponent { playmentButton: Locator; sideMenu: Locator; constructor(private page: Page) { this.playmentButton = this.page.getByRole('link', { name: 'płatności' }) this.sideMenu = this.page.locator('#nav-main') } }Hej,
W tym przypadku taki lokator będzie dobrym rozwiązaniem 😉
Bo cały czas dotyczy tego komponentu, a jednocześnie pozwala na ogólne sprawdzenie (np. czy element jest widoczny) 🙂
Równiez w momencie gdy cały element ma jakis unikalny selektor to możemy zrobić tak, że wewnętrzne elementy będziemy wyszukiwać w lokatorze, tzn:
export class SideMenuComponent { sideMenu: Locator; playmentButton: Locator; constructor(private page: Page) { this.sideMenu = this.page.locator('#nav-main') this.playmentButton = this.sideMenu.getByRole('link', { name: 'płatności' }) } }Dlaczego tak?
Bo uodparniamy się na sytuacje gdy na stronie wiecej elementów pasuje do
getByRole('link', { name: 'płatności' })A w tym przykładzie – najpierw szukamy mainMenu, a pózniej w nim – konkretnego elementu 🙂
PS. Więcej o tych technikach opowiadamy w https://jaktestowac.pl/course/playwright-elements/ 🙂
Cześć! Nie jestem do końca pewien jaki typ powinienem dać dla sideMenuComponent w payment.page.ts kiedy muszę dopasować się do standardów ES2022 – może warto byłoby to dodać do tej lekcji?
export class PulpitPage { sideMenuComponent: // <---------------------- Jaki to typ powinien być? constructor(private page: Page) { this.sideMenuComponent = new SideMenuComponent(this.page); } }*pulpit.page.ts, ale to szczegóły 🙂
Ok, znalazłem w repo, że powinno być:
Hej,
W takim przypadku, należałoby użyć nazwy klasy jako określenie typu, czyli:
export class PulpitPage { sideMenuComponent: SideMenuComponent; constructor(private page: Page) { this.sideMenuComponent = new SideMenuComponent(this.page); } }i w tej postaci powinno to zadziałać😉
Dodaliśmy w pliku payment.page.ts i pulpit.page.ts element
, a w pliku payment.spec.ts z testami stosujemy :
z racji ze zdecydowaliśmy sie utworzyć w obu plikach zmienną sideMenu poprawnie nie powinno być
?
Słuszna uwaga!
Z jednej strony – moglibyśmy w
beforeEachod razu stworzyćpaymentPage, bo każda z tych stron zawiera w sobieSideMenuComponent.Z drugiej strony – zaraz po zalogowaniu jesteśmy na stronie pulpitu użytkownika (który reprezentuj
PulpitPage). Zatem jeśli wykorzystamy opcję:obrazujemy w testach jak wygląda droga użytkownika (czyli jakie kolejno otwierane są strony).
Oba podejścia są poprawne i każde z nich zawiera plusy i minusy. W pierwszym podejściu zmniejszamy odrobinę liczbę linii kodu, a w drugim – mamy więcej informacji o kolejnych krokach/odwiedzonych stronach 🙂
u siebie w testach takie elementy (widoczne w każdym panelu, tj. np. pasek nawigacji, górny pasek, jakieś boczne ikony) wydzieliłem do osobnych page objectów czyli, przykładowo, taki pasek w naszym banku to czysta nawigacja:
– navigation.page.ts
i potem w testach
const goTo = new navigationPage(page)
await goTo.platnosci();
macie jakieś przeciwwskazania dla takiego podejścia? Osobiście wydaje mi się to bardziej przejrzyste niż “ukrywanie” takiego menu w jakimś innym page’u 🙂
To troche zalezy od Twojego przypadku 🙂
Ogólnie jeśli chodzi o komponenty występujące na stronach (menu, paski nawigacji, wyszukiwarka etc) zazwyczaj lepiej wydzielić jako osoby moduł, a następnie umiescic je w danej stronie (tak jak w przykładzie, stosując kompozycję).
Przemawia za tym, że dane komponenty nie występują same w naturze, a są powiązane z daną stroną/stronami 🙂 Dzięki temu zabezpieczamy się przed wykorzystaniem elementów w momencie, gdy nie jesteśmy na stronie, na której te elementy występują.
Jednak możemy zastosować też inne wzorce i praktyki, które modyfikują to podejście.
Jesli dobrze rozumiem, to w Twoim przypadku taki komponent nawigacyjny pełni rolę nawigatora/transportera, czyli jest w stanie przekierować nas w dane miejsce (jak rozumiem – za pomoca menu).
Rozważył bym też zmianę nazwy
navigation.page.ts, bo page sugeruje że to strona 🙂 Może np.navigation.component.ts(jednak to moze sugerować komponent strony).Więc tu do rozważenia jak zaprojektować strony/komponenty/nawigacje w Twoim przypadku 🙂
hmmm
jeśli chodzi o:
pulpitPage.sideMenuComponent.paymentLink.click();
to czy nie lepiej by było zainicjować sideMenuComponent w teście bezpośrednio i po prostu się odnosić do niego:
sideMenuComponent.paymentLink.click()?
Pozwolę sobie porozmyślać, bo jest filozoficzne pytanie.
`sideMenu` samo w sobie bez strony nie istnieje, dlatego w teście pulpitPage.sideMenu jest mniej dwuznaczne niż samo sideMenu bo komponent ten też będzie widoczny na innych stronach naszego serwisu, a są strony, na których on nie będzie widoczny jak loginPage. Nie używałbym raczej suffixu `Component` bo to już dodawanie za dużej ilości szczegółów do nazwy zmiennej, ale to następny temat rzeka 😀
Zgodzę się –
sideMenujako takie nie istnieje bez rodzica w postaci danej strony 😉 Przez to, że łączymy ten komponent ze stroną, to wiemy też na jakich stronach możemy go wywołać, a na jakich go nie ma (np. LoginPage) 🙂 Jest to mega cenna informacja i potrafi znacznie ułatwić pisanie testów.Nie jest to jedyne możliwe podejście – i jesli w projekcie z jakiegoś powodu podjęłaby została decyzja, że możemy używać elementów niezależenie – to też jest możliwe 🙂
Minus – zaciemniamy sobie w testach co i gdzie jest możliwe do wykonania, jakie akcje gdzie mozna podejmować.
Nazwy zmiennych to inna historia 😀
Przy nauce na początku dobrze obrazują co jest czym, później można optymalizować i wpisywać ustalenia do Coding Standards 😉
PS. Wcześniej nie zauważyłem powiadomienia o Twoim komentarzu :/ Rzuć proszę okiem na komentarz od Adama i mój w tamtym wątku) 🙇♂️