Page Object vs fixtures — struktura testów Playwright

Porównaj klasyczny Page Object Model z kompozycją opartą na fixture'ach Playwright. Dowiedz się, kiedy każdy wzorzec wygrywa, jak unikać obiektów-bogów i strukturyzować kod testowy dla.

Ten wpis jest częścią serii Podstawy Playwright. Część 2 omówiła strategię lokatorów. Ten post bada, jak strukturyzować kod testowy, aby pozostał utrzymywalny w miarę wzrostu suity.


Problem — powtarzanie i kruchość

Po napisaniu kilku testów zauważysz powtarzające się wzorce:

test('użytkownik może się zalogować', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Hasło').fill('password123');
  await page.getByRole('button', { name: 'Zaloguj' }).click();
  await expect(page.getByText('Witaj ponownie')).toBeVisible();
});

test('użytkownik może zobaczyć profil po logowaniu', async ({ page }) => {
  // Te same kroki logowania zduplikowane
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Hasło').fill('password123');
  await page.getByRole('button', { name: 'Zaloguj' }).click();
  
  await page.getByRole('link', { name: 'Profil' }).click();
  await expect(page.getByRole('heading', { name: 'Profil użytkownika' })).toBeVisible();
});

To narusza DRY (Don’t Repeat Yourself). Gdy formularz logowania się zmieni, aktualizujesz to w dziesięciu miejscach. Gdy selektor się psuje, naprawiasz go dziesięć razy.

Dwa wzorce to rozwiązują: Page Object Model (POM) i Playwright Fixtures.


Page Object Model (POM) — klasyczny wzorzec

Page Object Model enkapsuluje lokatory i akcje specyficzne dla strony w klasie. Testy wchodzą w interakcję z obiektem strony, nie z surowymi API Playwright.

Podstawowy przykład POM

// pages/LoginPage.ts
import { Page } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}

  // Lokatory
  private emailInput = () => this.page.getByLabel('Email');
  private passwordInput = () => this.page.getByLabel('Hasło');
  private loginButton = () => this.page.getByRole('button', { name: 'Zaloguj' });
  private welcomeMessage = () => this.page.getByText('Witaj ponownie');

  // Akcje
  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput().fill(email);
    await this.passwordInput().fill(password);
    await this.loginButton().click();
  }

  async expectWelcomeMessage() {
    await expect(this.welcomeMessage()).toBeVisible();
  }
}

Używanie Page Object

import { test } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('użytkownik może się zalogować', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  await loginPage.expectWelcomeMessage();
});

test('użytkownik może zobaczyć profil po logowaniu', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  
  await page.getByRole('link', { name: 'Profil' }).click();
  await expect(page.getByRole('heading', { name: 'Profil użytkownika' })).toBeVisible();
});

Korzyści z POM

  • Enkapsulacja — Wszystkie selektory związane z logowaniem żyją w jednym miejscu
  • Reużywalność — Metoda login() używana w wielu testach
  • Utrzymywalność — Zmień formularz logowania raz, napraw w jednym miejscu
  • Czytelność — Testy czytają się jak akcje użytkownika, nie wywołania API Playwright

Problem z POM — obiekty-bogi

POM działa dobrze, dopóki nie przestaje. W miarę wzrostu aplikacji, obiekty stron rosną. Kończysz z:

Antywzorzec: obiekt-bóg

export class DashboardPage {
  // 500 linii metod
  async clickProfile() {}
  async clickSettings() {}
  async clickLogout() {}
  async filterByDate() {}
  async exportToPDF() {}
  async createNewOrder() {}
  async deleteOrder() {}
  async approveOrder() {}
  async rejectOrder() {}
  // ... 30 więcej metod
}

To koszmar utrzymaniowy:

  • Kruche — jeden obiekt strony dotyka zbyt wielu funkcji, często się psuje
  • Trudne do testowania — sam obiekt strony staje się na tyle skomplikowany, że potrzebuje testów
  • Słaba abstrakcja — obiekt strony odzwierciedla strukturę UI, nie cele użytkownika
  • Trudne do nawigacji — klasy 500-liniowe są trudne do zrozumienia

Rozwiązanie: kompozycja zamiast dziedziczenia. Rozbij duże obiekty stron na mniejsze, skoncentrowane komponenty.


Playwright Fixtures — testowanie oparte na kompozycji

Fixtures Playwright to odpowiedź Playwright na ograniczenia POM. Zamiast klas, komponujesz reużywalne helpery testowe używając dependency injection.

Podstawowy przykład Fixture

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

type AuthFixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    // Setup: Zaloguj się
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Hasło').fill('password123');
    await page.getByRole('button', { name: 'Zaloguj' }).click();
    await page.waitForURL('/dashboard');
    
    // Dostarcz zalogowaną stronę do testu
    await use(page);
    
    // Teardown: (opcjonalne) wyloguj
    await page.getByRole('button', { name: 'Wyloguj' }).click();
  },
});

Używanie Fixture

import { test } from './fixtures/auth';
import { expect } from '@playwright/test';

test('użytkownik może zobaczyć profil', async ({ authenticatedPage }) => {
  // Test zaczyna się już zalogowany
  await authenticatedPage.getByRole('link', { name: 'Profil' }).click();
  await expect(authenticatedPage.getByRole('heading', { name: 'Profil użytkownika' })).toBeVisible();
});

test('użytkownik może zobaczyć zamówienia', async ({ authenticatedPage }) => {
  await authenticatedPage.getByRole('link', { name: 'Zamówienia' }).click();
  await expect(authenticatedPage.getByRole('heading', { name: 'Moje zamówienia' })).toBeVisible();
});

Każdy test używający authenticatedPage zaczyna z użytkownikiem już zalogowanym. Bez duplikacji. Bez ręcznego setupu w każdym teście.


Fixtures vs POM — kiedy używać którego

KwestiaPage Object ModelFixtures
Enkapsulacja lokatorów✅ Doskonała⚠️ Wymaga dyscypliny (może wyciekać do testów)
Cykl życia setup/teardown❌ Ręczny (beforeEach/afterEach)✅ Wbudowany (setup przed use(), teardown po)
Kompozytowalność⚠️ Oparta na dziedziczeniu (sztywna)✅ Oparta na kompozycji (elastyczna)
Dependency injection❌ Ręczne (przekazuj obiekt strony wszędzie)✅ Automatyczne (system fixture’ów się tym zajmuje)
Testowalność⚠️ Same obiekty stron potrzebują testów, jeśli są złożone✅ Fixture’y są proste, rzadko potrzebują testów
Krzywa uczenia✅ Znajoma (klasy, metody)⚠️ Bardziej stroma (wzorzec fixture jest nowy dla wielu)
Duże bazy kodu❌ Skłania się ku obiektom-bogom✅ Skaluje się dobrze z kompozycją

Kiedy używać POM

  • Małe do średnich suity testów — POM jest prosty i efektywny dla < 50 testów
  • Zespół nieznajomy z fixture’ami — POM jest łatwiejszy do onboardingu (po prostu klasy)
  • Silna enkapsulacja lokatorów — Jeśli Twoim głównym celem jest ukrycie selektorów, POM działa

Kiedy używać Fixtures

  • Reużywalny setup/teardown — Fixture’y świetnie radzą sobie z “zacznij zalogowany”, “z danymi testowymi”, “z uprawnieniami admina”
  • Kompozycja zamiast dziedziczenia — Potrzebujesz wielu, niezależnych, kombinowalnych kroków setupu
  • Duże suity testów — Fixture’y lepiej się skalują w miarę wzrostu złożoności
  • Zaawansowane wzorce — Równoległe wykonanie, stan w zakresie workera, niestandardowe środowiska testowe

:::tip[Podejście hybrydowe] Nie musisz wybierać. Użyj POM dla enkapsulacji lokatorów i akcji, a fixture’ów dla setup/teardown. Przykład: klasa LoginPage używana wewnątrz fixture’a authenticatedPage. :::


Przykład — łączenie POM i Fixtures

Oto hybrydowy wzorzec, który wykorzystuje mocne strony obu:

Page Object (enkapsuluje lokatory i akcje)

// pages/OrderPage.ts
import { Page } from '@playwright/test';

export class OrderPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/orders');
  }

  async createOrder(productName: string, quantity: number) {
    await this.page.getByRole('button', { name: 'Nowe zamówienie' }).click();
    await this.page.getByLabel('Produkt').fill(productName);
    await this.page.getByLabel('Ilość').fill(String(quantity));
    await this.page.getByRole('button', { name: 'Wyślij' }).click();
  }

  async expectOrderInList(productName: string) {
    await expect(
      this.page.getByRole('row').filter({ hasText: productName })
    ).toBeVisible();
  }
}

Fixture (obsługuje setup i dostarcza zależności)

// fixtures/orders.ts
import { test as base } from '@playwright/test';
import { OrderPage } from '../pages/OrderPage';

type OrderFixtures = {
  orderPage: OrderPage;
};

export const test = base.extend<OrderFixtures>({
  orderPage: async ({ page }, use) => {
    // Setup: Przejdź do strony zamówień
    const orderPage = new OrderPage(page);
    await orderPage.goto();
    
    await use(orderPage);
    
    // Teardown: (jeśli potrzeba)
  },
});

Test (czysty i czytelny)

import { test } from './fixtures/orders';
import { expect } from '@playwright/test';

test('użytkownik może utworzyć zamówienie', async ({ orderPage }) => {
  await orderPage.createOrder('Laptop', 2);
  await orderPage.expectOrderInList('Laptop');
});

To łączy:

  • POM — enkapsuluje lokatory i akcje specyficzne dla zamówień
  • Fixtures — obsługuje nawigację i dependency injection
  • Test — skupia się wyłącznie na testowanym scenariuszu

Checklista antywzorców

Unikaj tych częstych błędów podczas strukturyzowania kodu testowego:

❌ Obiekty stron-bogi

// ŹLE: 1000-liniowy obiekt strony robiący wszystko
export class ApplicationPage {
  async login() {}
  async createOrder() {}
  async deleteUser() {}
  async exportReport() {}
  // ...100 więcej metod
}

Naprawa: Rozbij na mniejsze, skoncentrowane obiekty stron (LoginPage, OrderPage, UserManagementPage).

❌ Testy zależne od kolejności wykonania

// ŹLE: Test B zakłada, że Test A uruchomił się pierwszy
test('utwórz użytkownika', async ({ page }) => {
  // tworzy użytkownika ID 123
});

test('usuń użytkownika 123', async ({ page }) => {
  // oczekuje, że użytkownik 123 istnieje
});

Naprawa: Każdy test tworzy własne dane przez fixture’y lub setup API.

❌ Hardkodowane dane testowe w obiektach stron

// ŹLE: Obiekt strony zawiera dane testowe
export class LoginPage {
  private readonly testEmail = 'test@example.com';
  
  async loginAsTestUser() {
    await this.login(this.testEmail, 'password');
  }
}

Naprawa: Przekazuj dane testowe jako argumenty. Pozwól testowi kontrolować swoje dane.

❌ Obiekty stron wykonujące asercje

// ŹLE: Logika asercji w obiekcie strony
export class LoginPage {
  async loginAndVerifySuccess(email: string, password: string) {
    await this.login(email, password);
    await expect(this.page.getByText('Witaj')).toBeVisible();  // ❌ Nie rób tego
  }
}

Naprawa: Obiekty stron wykonują akcje, testy wykonują asercje. Trzymaj je osobno.

// DOBRZE: Obiekt strony robi akcję, test robi asercję
await loginPage.login(email, password);
await expect(page.getByText('Witaj')).toBeVisible();  // ✅ Asercja w teście

Strukturyzowanie dużej suity testów

Dla suity 200+ testów, oto zalecana struktura folderów:

tests/
├── fixtures/
│   ├── auth.ts           # Fixture'y uwierzytelniania
│   ├── orders.ts         # Fixture'y związane z zamówieniami
│   └── api.ts            # Fixture'y klienta API
├── pages/
│   ├── LoginPage.ts      # POM dla logowania
│   ├── OrderPage.ts      # POM dla zamówień
│   └── ProfilePage.ts    # POM dla profilu
├── helpers/
│   ├── dataFactory.ts    # Generowanie danych testowych
│   └── assertions.ts     # Niestandardowe helpery asercji
└── specs/
    ├── auth.spec.ts      # Testy związane z uwierzytelnianiem
    ├── orders.spec.ts    # Testy związane z zamówieniami
    └── profile.spec.ts   # Testy związane z profilem

Dlaczego to działa:

  • Fixtures — Reużywalna logika setup/teardown
  • Pages — Enkapsulacja lokatorów
  • Helpers — Narzędzia, które nie pasują do fixture’ów ani obiektów stron
  • Specs — Testy zgrupowane według obszaru funkcji

Przykład z rzeczywistości — suita testów e-commerce

Zbudujmy mały test e-commerce używając obu wzorców.

Page Object

// pages/CheckoutPage.ts
export class CheckoutPage {
  constructor(private page: Page) {}

  async fillShippingDetails(name: string, address: string) {
    await this.page.getByLabel('Pełne imię').fill(name);
    await this.page.getByLabel('Adres').fill(address);
  }

  async selectPaymentMethod(method: 'card' | 'paypal') {
    await this.page.getByRole('radio', { name: method }).check();
  }

  async submitOrder() {
    await this.page.getByRole('button', { name: 'Złóż zamówienie' }).click();
  }
}

Fixture

// fixtures/cart.ts
export const test = base.extend({
  cartWithItems: async ({ page }, use) => {
    await page.goto('/products');
    await page.getByRole('button', { name: 'Dodaj do koszyka' }).first().click();
    await page.getByRole('link', { name: 'Koszyk' }).click();
    
    await use(page);
  },
});

Test

import { test } from './fixtures/cart';
import { CheckoutPage } from './pages/CheckoutPage';
import { expect } from '@playwright/test';

test('użytkownik może dokończyć zamówienie', async ({ cartWithItems }) => {
  await cartWithItems.getByRole('button', { name: 'Kasa' }).click();
  
  const checkoutPage = new CheckoutPage(cartWithItems);
  await checkoutPage.fillShippingDetails('Jan Kowalski', 'ul. Główna 123');
  await checkoutPage.selectPaymentMethod('card');
  await checkoutPage.submitOrder();
  
  await expect(cartWithItems.getByText('Zamówienie potwierdzone')).toBeVisible();
});

Czysty, czytelny, utrzymywalny. Test mówi co robi, nie jak to robi.


Podsumowanie

Zarówno POM, jak i fixture’y to prawidłowe wzorce. Najlepszy wybór zależy od preferencji Twojego zespołu, rozmiaru suity testów i złożoności.

Ogólne wytyczne:

  • Zacznij od POM, jeśli Twój zespół jest nowy w Playwright (łatwiejszy do nauki)
  • Adoptuj fixture’y, gdy logika setup/teardown zaczyna się duplikować w testach
  • Używaj obu dla dużych suit (fixture’y dla cyklu życia, POM dla lokatorów)

Celem nie jest wybranie “właściwego” wzorca — to utrzymanie kodu testowego utrzymywalnym, czytelnym i odpornym na zmiany.

Zadanie na ten tydzień: Zidentyfikuj najbardziej zduplikowany setup w swojej suicie testów (logowanie, nawigacja, tworzenie danych). Refaktoryzuj go do metody obiektu strony lub fixture’a. Zmierz przed/po: ile linii kodu wyeliminowałeś?


Poprzednio: Część 2 — Lokatory, które się nie rozpadają
Dalej: Część 4 — Auto-waiting, asercje i walka z flakami