Powrót do: Praktyczne wprowadzenie do testów automatycznych z Playwright
Page Object Model i agregacja akcji
Prezentacja
Dodatkowe materiały
Bazujemy na kodzie lekcji L07_pom_component
Kod wynikowy tej lekcji znajduje się tu: L08_pom_aggregate
Pamiętaj, aby po danej porcji pracy: uruchamiać test, commitować poprawnie działający kod 😉
Przeniesienie zmiennej do sekcji describe()
Zawartość pliku login.spec.ts, gdzie wykorzystujemy zmienną globalną loginPage. Jej deklaracja znajduje się w cele test.describe
, inicjalizacja w beforeEach
, a użycie – w testach:
import { test, expect } from '@playwright/test'; import { loginData } from '../test-data/login.data'; import { LoginPage } from '../pages/login.page'; import { PulpitPage } from '../pages/pulpit.page'; test.describe('User login to Demobank', () => { let loginPage: LoginPage; test.beforeEach(async ({ page }) => { await page.goto('/'); loginPage = new LoginPage(page); }); test('successful login with correct credentials', async ({ page }) => { // Arrange const userId = loginData.userId; const userPassword = loginData.userPassword; const expectedUserName = 'Jan Demobankowy'; // Act await loginPage.loginInput.fill(userId); await loginPage.passwordInput.fill(userPassword); await loginPage.loginButton.click(); // Assert const pulpitPage = new PulpitPage(page); await expect(pulpitPage.userNameText).toHaveText(expectedUserName); }); test('unsuccessful login with too short username', async ({ page }) => { // Arrange const incorrectUserId = 'tester'; const expectedErrorMessage = 'identyfikator ma min. 8 znaków'; // Act await loginPage.loginInput.fill(incorrectUserId); await loginPage.passwordInput.click(); // Assert await expect(loginPage.loginError).toHaveText(expectedErrorMessage); }); test('unsuccessful login with too short password', async ({ page }) => { // Arrange const userId = loginData.userId; const incorrectPassword = '1234'; const expectedErrorMessage = 'hasło ma min. 8 znaków'; // Act await loginPage.loginInput.fill(userId); await loginPage.passwordInput.fill(incorrectPassword); await loginPage.passwordInput.blur(); // Assert await expect(loginPage.passwordError).toHaveText(expectedErrorMessage); }); });
Page Object Model
Page Object Model może zostać zaimplementowany na różne sposoby i z różnym poziomem złożoności. Wszystko zależy od języka programowania, zastosowanych dodatkowych wzorców, wymagań i potrzeb. W tej lekcji w prosty sposób rozszerzymy implementację z poprzednich lekcji 🙂
Page Object Model z agregacją akcji
Page Object Model z agregacją akcji to klasa, która reprezentuje daną stronę. Natomiast w tej klasie znajdują się:
- lokatory, które wskazują na elementy (przyciski, pola tekstowe, etc.), z którymi wchodzimy w interakcję podczas testów,
- metody, które są zebranymi akcjami na poszczególnych elementach.
Dzięki zebraniu akcji w metodach, w testach nie musimy się odnosić do pojedynczych elementów strony.
Zawartość pliku login.spec.ts, gdzie w teście successful login with correct credentials wykorzystujemy nową implementację wzorca POM:
import { test, expect } from '@playwright/test'; import { loginData } from '../test-data/login.data'; import { LoginPage } from '../pages/login.page'; import { PulpitPage } from '../pages/pulpit.page'; test.describe('User login to Demobank', () => { let loginPage: LoginPage; test.beforeEach(async ({ page }) => { await page.goto('/'); loginPage = new LoginPage(page); }); test('successful login with correct credentials', async ({ page }) => { // Arrange const userId = loginData.userId; const userPassword = loginData.userPassword; const expectedUserName = 'Jan Demobankowy'; // Act await loginPage.login(userId, userPassword); // Assert const pulpitPage = new PulpitPage(page); await expect(pulpitPage.userNameText).toHaveText(expectedUserName); }); test('unsuccessful login with too short username', async ({ page }) => { // Arrange const incorrectUserId = 'tester'; const expectedErrorMessage = 'identyfikator ma min. 8 znaków'; // Act await loginPage.loginInput.fill(incorrectUserId); await loginPage.passwordInput.click(); // Assert await expect(loginPage.loginError).toHaveText( expectedErrorMessage ); }); test('unsuccessful login with too short password', async ({ page }) => { // Arrange const userId = loginData.userId; const incorrectPassword = '1234'; const expectedErrorMessage = 'hasło ma min. 8 znaków'; // Act await loginPage.loginInput.fill(userId); await loginPage.passwordInput.fill(incorrectPassword); await loginPage.passwordInput.blur(); // Assert await expect(loginPage.passwordError).toHaveText( expectedErrorMessage ); }); });
Zawartość pliku login.page.ts:
import { Page } from '@playwright/test'; export class LoginPage { constructor(private page: Page) {} loginInput = this.page.getByTestId('login-input'); passwordInput = this.page.getByTestId('password-input'); loginButton = this.page.getByTestId('login-button'); loginError = this.page.getByTestId('error-login-id'); passwordError = this.page.getByTestId('error-login-password'); async login(userId: string, userPassword: string): Promise<void> { await this.loginInput.fill(userId); await this.passwordInput.fill(userPassword); await this.loginButton.click(); } }
Zalety:
- zmniejszenie duplikacji kodu w testach – selektory, lokatory oraz akcje na lokatorach są w jednym miejscu (w klasie strony),
- jeszcze lepsza utrzymywalność – gdy nastąpi zmiana na stronie (zarówno w elemencie, jak i w kolejności akcji), to musimy zaktualizować klasę strony, a testy pozostają bez zmian,
- agregacja danych strony – zebranie w jednym miejscu wszystkich elementów strony, z którymi wchodzimy w interakcję,
- odpowiedzialność za kolejność działań jest w kodzie – osoba pisząca nowe testy nie musi pamiętać, jakie elementy w jakiej kolejności powinno się używać na danej stronie, np. najpierw wpisanie loginu, później hasła, a na końcu przyciśnięcie przycisku zaloguj,
- dość szybka implementacja,
- sprawdzi się zarówno w mniejszych, prostszych testach/projektach, oraz również w tych średniej wielkości.
Wady:
- wymagana lepsza znajomość programowania – wprowadzenie metod agregujących akcje może podnieść poziom skomplikowania kodu,
- pewne ograniczenia – stworzona metoda powinna mieć jedną odpowiedzialność (trzymając się założeń programistycznych np: SOLID) tzn. powinna być odpowiedzialną za jedną konkretną kompleksową akcję. Gdy mamy poprawne logowanie i niepoprawne logowanie – to nie możemy wykorzystać tej samej metody, bo atomowe akcje nie będą się zgadzały – o tym dokładniej poczytasz dalej w tej lekcji 🙂
Page Object Model i inne wariacje
Ważne aby zapamiętać: Nie ma idealnego rozwiązania, które sprawdzi się w każdym projekcie, a każdy z rozważanych sposobów implementacji ma swoje wady i zalety.
Jak podejść do wyboru sposobu implementacji testów w POM:
- przeanalizuj każdy ze sposobów pod kątem tego, czego potrzebujesz w projekcie,
- przedyskutuj podejścia z zespołem i wypracujcie wspólne zdanie i rozwiązanie,
- zapisz wybrane rozwiązanie w dokumentacji projektu (np. w decision logu albo w coding standards),
- podczas implementacji zachowuj spójność z wybranym podejściem 😉
- pamiętaj, że każde rozwiązanie można w przyszłości zmienić (co jednak może wiązać się z kosztami).
Testy negatywne a POM
Czasem w testach front-endu chcemy przetestować niepoprawne ścieżki użytkownika np.:
- niepoprawne logowanie,
- niepoprawny zakup gdy nie dodano żadnych produktów albo konto użytkownika jest niepoprawnie skonfigurowane,
- próba doładowania nieistniejącej karty, gdy nie mamy dostatecznych środków na koncie,
- notowanie wyników sportowych z błędnymi danymi.
Przy takich testach musimy sobie najpierw odpowiedzieć na pytania:
- czy jest sens je implementować po stronie testów UI/e2e?
- czy te przypadki można pokryć na niższych poziomach? np. jako testy jednostkowe front-endu, które są pisane przez developerów?
W pewnych przypadkach, po analizie wyjdzie nam, że musimy pokryć takie przypadki, bo są one krytyczne dla naszego systemu. Również może się okazać, że możemy je pokryć jedynie za pomocą testów front-endu, bo ważna jest walidacja w warstwie front-endu (a pośrednio testujemy komunikację z back-endem).
W sytuacji gdy posiadamy przypadki testowe, wykonaliśmy analizę “Czy warto te testy zautomatyzować?” i ustaliliśmy, że tak – to możemy zastanowić się jak ugryźć ten temat.
Tutaj może pojawić się wyzwanie: Jak to poprawnie zrobić, w przypadku gdy w testach mamy POM, który koncentruje się na poprawnych ścieżkach użytkownik?
Jest na to kilka sposobów.
Sposób 1 – bazowanie na lokatorach (nasz dotychczasowy sposób)
Test na niepoprawne logowanie z pliku login.spec.ts:
test('unsuccessful login with too short username', async ({ page }) => { // Arrange const incorrectUserId = 'tester'; const expectedErrorMessage = 'identyfikator ma min. 8 znaków'; // Act const loginPage = new LoginPage(page) await loginPage.loginInput.fill(incorrectUserId) await loginPage.passwordInput.click(); // Assert await expect(loginPage.loginError).toHaveText( expectedErrorMessage ); });
Zalety:
- duża dowolność w komponowaniu testów – możemy używać różnych akcji w różnej kolejności w zależności od potrzeby.
Wady:
- gorsza utrzymywalność kodu testów – korzystamy z lokatorów, zamiast z metod agregujących akcje użytkownika. Gdy np. zmieni się wymagana kolejność działań będziemy musieli naprawić wszystkie testy, w których korzystamy z tych akcji. Będzie to szczególnie uciążliwe przy dużej liczbie testów.
- [w zależności od ustaleń projektowych] łamiemy założenia POM – jeśli założyliśmy w projekcie, że zawsze agregujemy akcje użytkownika w metodach (i tylko ich używamy w testach), to w tym rozwiązaniu tworzymy wyjątek i łamiemy własne ustalenia.
Sposób 2 – dedykowane metody do niepoprawnych akcji użytkownika
W tym sposobie implementujemy nową metodę, w której wykonamy akcje wymagane w niepoprawnej ścieżce użytkownika. W naszym przypadku, w teście niepoprawnego logowania, zaimplementujemy metodę do wpisywania danych przy logowaniu, ale bez przyciskania przycisku zaloguj.
Test na niepoprawne logowanie z pliku login.spec.ts:
test('unsuccessful login with too short username', async ({ page }) => { // Arrange const incorrectUserId = 'tester'; const expectedErrorMessage = 'identyfikator ma min. 8 znaków'; // Act const loginPage = new LoginPage(page) await loginPage.fillLoginCredentials(incorrectUserId, '') // Assert await expect(loginPage.loginError).toHaveText( expectedErrorMessage ); });
Zawartość pliku login.page.ts:
import { Page } from '@playwright/test' export class LoginPage { constructor(private page: Page) {} loginInput = this.page.getByTestId('login-input') passwordInput = this.page.getByTestId('password-input') loginButton = this.page.getByTestId('login-button') async login(userId: string, userPassword: string): Promise<void> { await this.loginInput.fill(userId); await this.passwordInput.fill(userPassword); await this.loginButton.click(); } async fillLoginCredentials(userId: string, userPassword: string): Promise<void> { await this.loginInput.fill(userId); await this.passwordInput.fill(userPassword); await this.passwordInput.blur(); } }
Zalety:
- dedykowane metody – możemy ich użyć podczas testowania niepoprawnych ścieżek użytkownika,
- agregacja akcji – pojedyncze akcje są zagregowane w metodzie, co zmniejsza duplikację kodu w testach.
Wady:
- duplikacja kodu w implementacji obiektów stron – możemy mieć kilka metod, które agregują kilkanascie akcji użytkownika, ale różnią się jedną akcją. W naszym przypadku mamy dwie podobne metody, które nieznacznie się różnią między sobą (w jednej jest
click()
, a w drugiejblur()
).
Sposób 3 – wywoływanie metod z metod
W tym sposobie wywołujemy mniejsze metody (wpisane danych logowania) w większych metodach (logowanie do aplikacji).
Test na niepoprawne logowanie z pliku login.spec.ts:
test('unsuccessful login with too short username', async ({ page }) => { // Arrange const incorrectUserId = 'tester'; const expectedErrorMessage = 'identyfikator ma min. 8 znaków'; // Act await loginPage.fillLoginCredentials(incorrectUserId, '') // Assert await expect(loginPage.loginError).toHaveText( expectedErrorMessage ); });
Zawartość pliku login.page.ts:
import { Page } from '@playwright/test' export class LoginPage { constructor(private page: Page) {} loginInput = this.page.getByTestId('login-input') passwordInput = this.page.getByTestId('password-input') loginButton = this.page.getByTestId('login-button') async login(userId: string, userPassword: string): Promise<void> { await this.fillLoginCredentials(userId, userPassword) await this.loginButton.click(); } async fillLoginCredentials(userId: string, userPassword: string): Promise<void> { await this.loginInput.fill(userId); await this.passwordInput.fill(userPassword); await this.passwordInput.blur(); } }
Zalety:
- zmniejszenie duplikacji kodu i używanie istniejących metod,
- łatwiejsze i mniej kosztowne utrzymanie – łatwiej wprowadzać zmiany lub poprawki do kodu i akcji użytkownika, które mamy zagregowane w jednym miejscu.
Wady:
- czasem możemy potrzebować wykonać dodatkową akcję, która nie jest wymagana w poprawnej ścieżce (np. nadmiarowa akcja
blur()
podczas wykonywania akcji logowania).
Podsumowanie
Podobnie jak z wyborem wariantu POM, tak tutaj też nie ma idealnego rozwiązania, które sprawdzi się w każdym projekcie, a każdy z powyższych sposobów implementacji ma swoje wady i zalety. Przed wyborem danego sposobu zastanów się i rozważ plusy i minusy 🙂
W przypadku tej lekcji finalnie zdecydowaliśmy się na pozostawienie lokatorów w negatywnych testach. Da nam to maksymalną swobodę przy projektowaniu nowych przypadków kosztem czasochłonności refaktoryzacji w przypadku zmian, np kolejności danych akcji lub potrzeby dodatkowych kroków.
Mam pytanie o Promise. Czy w każdej funkcji, która nic nie zwraca, powinniśmy wpisywać Promise, czy też można to pomijać (bo kod i tak się wykona)? Jakie jest uzasadnienie dla wpisywania tego?
Niestety edytor obciął mi void. Chodziło mi o wpisywanie
Promise<void>
Hej,
Osobiście sugerowałbym zawsze dodawać 🙂
Z pozoru wydaje się nadmiarowe, ale ma sporo plusów.
Przy dodawaniu również tego typu zwracanego mamy pełna spójność i czytelność z pozostałymi metodami, gdzie dodaliśmy typ zwracany. Dodatkowo – zabezpieczy nas to przed sytuacjami, gdy modyfikujemy daną metodę/funkcję i chcemy aby zwracała jakąś wartość.
Wtedy świadomie będziemy musieli też zmienić typ zwracany i unikniemy potencjalnych błędów 🙂
Dzięki!