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
| Kwestia | Page Object Model | Fixtures |
|---|---|---|
| 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