POM - czym jest i czym nie jest (i dlaczego połowa robi to źle)
Page Object Model to prawdopodobnie najczęściej dyskutowany wzorzec w testowaniu automatycznym. Jedni go uwielbiają, inni twierdzą że jest przestarzały. Moim zdaniem obie strony tej dyskusji mają rację - tyle że rozmawiają o dwóch zupełnie różnych implementacjach.
Zanim przejdę do konkretów: to jest moje zdanie oparte na doświadczeniu. Wzorzec nie jest dobry ani zły sam w sobie - sposób implementacji robi różnicę.
Czym POM jest, a czym nie jest
POM to wzorzec hermetyzujący wiedzę o tym, jak znaleźć elementy na stronie i jak z nimi interagować. Kiedy strona zmienia swój układ albo dodaje nowe pola - idziesz do jednego pliku i poprawiasz. Nie szukasz locatorów rozrzuconych po 20 plikach testowych.
POM nie jest miejscem na logikę testów. To co testujemy, co sprawdzamy i w jakiej kolejności - to należy do testu, nie do Page Objecta.
Brzmi prosto. Ale tu właśnie większość implementacji zbacza.
Skąd się biorą złe POM-y
Ludzie nie wymyślają złych POM-ów sami. Uczą się ich.
Większość tutoriali - zarówno starych kursów Selenium, jak i wielu współczesnych materiałów na YouTube czy Udemy - uczy POM jako "opakuj locatory w metody klasy". Krok 1: stwórz klasę. Krok 2: dodaj pole na każdy element. Krok 3: napisz metodę na każdą interakcję. Gotowe, masz Page Object.
Problem w tym, że to uczy myślenia o POM od strony struktury UI, nie od strony akcji użytkownika. Efekt? Ludzie tworzą klasy, które są lustrzanym odbiciem strony HTML - jedno pole na każdy input, jeden klik na każdy przycisk. Technicznie poprawne. Praktycznie bezużyteczne.
Najczęstsze błędy, które z tego wynikają:
Błąd #1: granularne metody
Najczęstszy błąd - POM z metodami odzwierciedlającymi każde pojedyncze kliknięcie:
// Zły POM - granularne, niskopoziomowe metodyclass BookingPage {async clickMovieDune() {await this.page.getByRole('listitem').filter({ hasText: 'Dune: Part Three' }).getByRole('button', { name: 'Wybierz seans' }).click();}async clickShowtime1830() {await this.page.getByRole('radio', { name: '18:30 - sala 2' }).click();}async clickSeatD7() {await this.page.getByTestId('seat-D7').click();}async clickConfirmSeats() {await this.page.getByRole('button', { name: 'Zatwierdź miejsca' }).click();}async fillName(name: string) {await this.page.getByLabel('Imię i nazwisko').fill(name);}// ... i tak dla każdego elementu}
A potem test wygląda tak:
// Test z granularnym POM - lista kroków bez kontekstutest('rezerwacja biletu', async ({ page }) => {const booking = new BookingPage(page);await booking.clickMovieDune();await booking.clickShowtime1830();await booking.clickSeatD7();await booking.clickConfirmSeats();await booking.fillName('Jan Kowalski');await booking.fillPhone('600 123 456');await booking.clickSubmit();// ...});
Co to nam dało? Test jest dłuższy i trudniejszy w czytaniu niż bez POM. Teraz skaczemy między dwoma plikami zamiast jednego. Każda zmiana w UI wciąż wymaga poprawki - tyle że w innym pliku.
To nie jest POM. To jest Locator Repository w przebraniu POM-a.
Rozwiązanie: metody na poziomie biznesowym
Dobry POM mówi "co użytkownik robi", nie "w co klika":
// Dobry POM - deklaratywne, biznesowe akcjeclass KinoTestPage {constructor(private page: Page) {}async goto() {await this.page.goto('https://kinotest.howtest.it');}async selectMovie(title: string) {await this.page.getByRole('listitem').filter({ hasText: title }).getByRole('button', { name: 'Wybierz seans' }).click();}async selectShowtime(time: string) {await this.page.getByRole('radio', { name: time }).click();await this.page.getByRole('button', { name: 'Dalej' }).click();}async selectSeat(seatId: string) {await this.page.getByTestId('seat-' + seatId).click();await this.page.getByRole('button', { name: 'Zatwierdź miejsca' }).click();}async fillBookingDetails(name: string, email: string, phone: string) {await this.page.getByLabel('Imię i nazwisko').fill(name);await this.page.getByLabel('Adres e-mail').fill(email);await this.page.getByLabel('Numer telefonu').fill(phone);await this.page.getByRole('button', { name: 'Zarezerwuj' }).click();}async getBookingNumber(): Promise<string> {return await this.page.getByTestId('booking-number').innerText();}}
Test z takim POM:
test('rezerwacja biletu - happy path', async ({ page }) => {const kino = new KinoTestPage(page);await kino.goto();await kino.selectMovie('Dune: Part Three');await kino.selectShowtime('18:30 - sala 2');await kino.selectSeat('D7');const bookingNumber = await kino.getBookingNumber();expect(bookingNumber).toMatch(/^KN-/);});
Ten test możesz pokazać product ownerowi i powie "tak, to dokładnie to co chciałem przetestować". Bez znajomości Playwright.
Kluczowe pytanie przy projektowaniu POM: czy ta metoda opisuje akcję użytkownika ('zarezerwuj bilet') czy implementację UI ('kliknij przycisk zatwierdź')? Jeśli drugie - to prawdopodobnie zły poziom abstrakcji.
Błąd #2: asercje w Page Objectach
Drugi częsty błąd - wrzucanie asercji do POM:
// Źle - POM z asercjamiclass BookingPage {async fillBookingDetails(name: string, email: string, phone: string) {await this.page.getByLabel('Imię i nazwisko').fill(name);await this.page.getByLabel('Adres e-mail').fill(email);await this.page.getByLabel('Numer telefonu').fill(phone);await this.page.getByRole('button', { name: 'Zarezerwuj' }).click();// To nie powinno tu byćawait expect(this.page.getByTestId('booking-number')).toBeVisible();await expect(this.page.getByText('Rezerwacja potwierdzona')).toBeVisible();}}
Dlaczego to problem? Bo teraz każdy test używający fillBookingDetails() automatycznie sprawdza sukces rezerwacji. A co jeśli chcę przetestować scenariusz, w którym rezerwacja powinna się nie udać? Muszę napisać osobną metodę albo obchodzić istniejącą.
POM odpowiada za akcje - "wypełnij formularz i kliknij zarezerwuj". Test odpowiada za weryfikację - "sprawdź że rezerwacja się udała" albo "sprawdź że wyświetlił się błąd walidacji".
// Dobrze - asercje w teścietest('rezerwacja z pustym mailem pokazuje błąd', async ({ page }) => {const kino = new KinoTestPage(page);await kino.goto();await kino.selectMovie('Dune: Part Three');await kino.selectShowtime('18:30 - sala 2');await kino.selectSeat('D7');await kino.fillBookingDetails('Jan Kowalski', '', '600 123 456');// Asercja tu - w teście, nie w POMawait expect(page.getByText('Podaj adres e-mail')).toBeVisible();});
Jest jeden wyjątek: waity na nawigację. Jeśli metoda POM powoduje przejście na nową stronę, sensowne jest poczekanie na załadowanie tej strony wewnątrz metody - to nie jest asercja testowa, to część akcji.
Błąd #3: dziedziczenie zamiast kompozycji
Klasyk z Javowego świata Selenium - BasePage, z którego dziedziczy wszystko:
// Źle - hierarchia dziedziczeniaclass BasePage {constructor(protected page: Page) {}async waitForPageLoad() { /* ... */ }async getHeader() { /* ... */ }async getFooter() { /* ... */ }async acceptCookies() { /* ... */ }}class BookingPage extends BasePage {async selectMovie(title: string) { /* ... */ }async selectShowtime(time: string) { /* ... */ }}class VIPBookingPage extends BookingPage {async selectVIPLounge() { /* ... */ }async addDrinkPackage() { /* ... */ }}
Na początku wygląda elegancko. Ale po kilku miesiącach BasePage staje się śmietnikiem - każda "wspólna" metoda ląduje w nim, bo "wszystkie strony tego potrzebują". A VIPBookingPage dziedziczy metody selectShowtime() i acceptCookies(), których może w ogóle nie używa.
Lepiej: osobne, małe Page Objecty składane w teście przez kompozycję.
// Dobrze - kompozycja zamiast dziedziczeniaclass CookieBanner {constructor(private page: Page) {}async accept() {await this.page.getByRole('button', { name: 'Akceptuję' }).click();}}class MovieList {constructor(private page: Page) {}async selectMovie(title: string) { /* ... */ }}class SeatPicker {constructor(private page: Page) {}async selectSeat(seatId: string) { /* ... */ }}// Test składa to co potrzebujetest('rezerwacja VIP', async ({ page }) => {const cookies = new CookieBanner(page);const movies = new MovieList(page);const seats = new SeatPicker(page);await cookies.accept();await movies.selectMovie('Dune: Part Three');await seats.selectSeat('VIP-A1');});
Każdy POM jest mały, niezależny i robi jedną rzecz. Nie ciągniesz za sobą całego drzewa dziedziczenia.
Co się dzieje przy zmianie UI
Teoria teorią - zobaczmy co się dzieje w praktyce. Załóżmy, że formularz rezerwacji się zmienia: zamiast trzech osobnych pól (imię, email, telefon) jest teraz logowanie przez Google i jedno pole na telefon.
Z granularnym POM - poprawiasz fillName(), fillEmail(), usuwasz je, dodajesz clickGoogleLogin(), a potem poprawiasz każdy test który wołał te metody po kolei. 15 testów, 15 poprawek.
Z biznesowym POM - poprawiasz wnętrze fillBookingDetails(). Metoda nadal przyjmuje dane kontaktowe i finalizuje rezerwację - zmienia się tylko implementacja w środku. Testy się nie zmieniają, bo nie wiedzą jak formularz wygląda. Wiedzą tylko, że podają dane i rezerwują.
To jest ta praktyczna różnica, dla której warto robić POM dobrze.
Co POM robi dla Ciebie w praktyce
Jedno miejsce do poprawki przy zmianie UI. Kiedy siatka miejsc dostała nową strukturę i data-testid zmienił format - poprawiasz metodę selectSeat() w jednym miejscu. Wszystkie testy używające tej metody działają dalej bez zmian.
Reużywalność. fillBookingDetails() możesz używać w testach happy path, testach walidacji, testach powiadomień email. Piszesz raz.
Czytelność. Test opisuje scenariusz biznesowy. Ktoś nowy w projekcie czyta test i rozumie co sprawdza bez zagłębiania się w locatory.
Gdzie POM ma swoje granice
Jest kilka sytuacji, gdzie POM może być overkill:
Małe projekty - jeśli masz 20 testów i jedną stronę, POM dodaje złożoność bez realnej korzyści. Locatory można trzymać bezpośrednio w testach.
Szybkie eksperymenty - piszesz testy do jednorazowego sprawdzenia, nie do długoterminowego utrzymania. Nie ma sensu tworzyć Page Objectów.
Functional helpers jako alternatywa - zamiast klas, możesz używać zwykłych funkcji eksportowanych z modułu. To ta sama filozofia (hermetyzacja akcji biznesowych), tylko bez klas. Oba podejścia są ok.
Moje podejście
Kiedy zaczynam nowy projekt testowy, zaczynam bez POM. Piszę kilka testów bezpośrednio. Po pewnym czasie zaczynam widzieć powtarzające się bloki kodu - np. "przejście do siatki miejsc" pojawia się w 10 testach. Wtedy wyodrębnia się naturalnie do Page Objecta.
POM, który powstaje po refaktoryzacji realnych, istniejących testów, jest prawie zawsze lepszy niż POM zaprojektowany abstrakcyjnie na początku projektu.
Checklist dobrego POM
Projektowanie
Elastyczność
Separacja
Architektura
Podejście
Podsumowanie
Trzy najczęstsze błędy w POM:
- Granularne metody - opakowują kliknięcia zamiast akcji biznesowych
- Asercje w POM - blokują reużywalność i mieszają odpowiedzialności
- Dziedziczenie - tworzy kruche hierarchie zamiast elastycznych komponentów
Wszystkie wynikają z tego samego źródła: myślenia o POM od strony struktury UI, nie od strony tego, co użytkownik robi. Jeśli Twoje metody POM czytają się jak scenariusz biznesowy - jesteś na dobrej drodze.
