Lokatory, które się nie rozpadają

Opanuj strategię lokatorów Playwright. Dowiedz się, dlaczego selektory oparte na rolach są lepsze od CSS, kiedy używać test ID, jak filtrować i łączyć lokatory, oraz unikać kruchych wzorców, które.

Ten wpis jest częścią serii Podstawy Playwright. Część 1 omówiła konfigurację i pierwszy test. Ten post skupia się na pisaniu lokatorów, które przetrwają refaktory UI.


Problem z kruchymi selektorami

Oto najdroższa linia kodu w typowej suicie testów E2E:

await page.click('div.MuiBox-root > div:nth-child(2) > button.btn-primary');

Ten selektor zepsuje się, gdy:

  • Deweloper doda wrapper div do stylowania
  • Framework CSS zostanie zaktualizowany i nazwy klas się zmienią
  • Designer zmieni kolejność elementów w layoucie
  • Przycisk przesunie się z drugiego dziecka na trzecie

Selektor jest powiązany z detalami implementacji, nie z zachowaniem widocznym dla użytkownika. Użytkownicy nie widzą div.MuiBox-root. Widzą przycisk “Wyślij”. Twoje testy powinny to odzwierciedlać.

Kruche selektory to przyczyna #1 kosztownych w utrzymaniu suit testów. Zespoły spędzają więcej czasu na aktualizowaniu testów po refaktorach niż na pisaniu nowych testów. W końcu suita jest porzucana, bo koszt utrzymania przewyższa wartość.

Rozwiązanie: używaj selektorów, które opisują to, co widzi użytkownik, nie to, jak zbudowany jest DOM.


Priorytet lokatorów Playwright — właściwa kolejność

Dokumentacja Playwright zaleca lokatory w kolejności priorytetów od najbardziej odpornych do najmniej odpornych:

1. Lokatory oparte na rolach (najlepsze)

await page.getByRole('button', { name: 'Wyślij' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('link', { name: 'Zobacz szczegóły' }).click();

Dlaczego to działa: Role są częścią drzewa dostępności. Opisują, jak technologia asystująca (czytniki ekranu) widzi stronę. Jeśli Twój test nie może znaleźć elementu po roli, niewidomy użytkownik z czytnikiem ekranu też nie może. Ta strategia selektorów wymusza dostępny markup.

Popularne role:

  • button<button> lub <input type="button">
  • textbox<input type="text"> lub <textarea>
  • link<a href="...">
  • checkbox<input type="checkbox">
  • heading<h1>, <h2>, itd.

Zobacz pełną listę: ARIA roles reference

2. Lokatory oparte na etykietach (dla pól formularzy)

await page.getByLabel('Hasło').fill('s3cr3t');
await page.getByLabel('Akceptuję warunki').check();

Znajduje inputy powiązane z <label>. To semantycznie poprawny HTML i odzwierciedla, jak użytkownicy nawigują formularze.

3. Lokatory oparte na placeholderach

await page.getByPlaceholder('Szukaj artykułów...').fill('playwright');

Przydatne, gdy nie ma etykiety, ale placeholder dostarcza kontekstu. Mniej odporne niż etykiety (placeholdery często się zmieniają), ale lepsze niż selektory CSS.

4. Lokatory oparte na tekście

await page.getByText('Witaj ponownie').click();
await page.getByText(/zalogowany jako/i).isVisible();

Znajduje elementy zawierające konkretny tekst. Wspiera dokładne dopasowanie, substring lub regex. Dobre dla linków, paragrafów i statycznej treści.

:::warning[Lokatory tekstowe i internacjonalizacja] Jeśli Twoja aplikacja jest lokalizowana (wielojęzyczna), lokatory oparte na tekście psują się, gdy zmienia się język. Używaj test ID lub rola + dostępna nazwa dla zlokalizowanych aplikacji. :::

5. Test ID (jawna stabilność)

await page.getByTestId('checkout-button').click();

Dodaj atrybuty data-testid do elementów, gdzie inne strategie nie pasują:

<button data-testid="checkout-button" class="btn-dynamic-class">
  Przejdź do kasy
</button>

Test ID są:

  • Niewidoczne dla użytkowników — nie wpływają na renderowanie ani zachowanie
  • Stabilne przy refaktorach — deweloper zmieniający CSS ich nie tknie
  • Jawne — sygnalizują “ten element jest testowany”

Kiedy używać test ID:

  • Dynamiczna treść, gdzie tekst lub rola zmienia się często
  • Komponenty third-party, których nie kontrolujesz (biblioteki React, osadzone widgety)
  • Elementy bez semantycznej roli lub dostępnej nazwy

6. CSS lub XPath (ostateczność)

await page.locator('[data-testid="submit"]').click();
await page.locator('css=.btn-primary').click();
await page.locator('xpath=//button[@type="submit"]').click();

Używaj CSS/XPath tylko gdy:

  • Testujesz legacy kod, którego nie można zmienić
  • Scrapujesz zewnętrzną stronę (nie swoją aplikację)
  • Wszystkie inne strategie zawiodły

Jeśli regularnie piszesz selektory CSS, traktuj to jako code smell i refaktoryzuj HTML, żeby był bardziej semantyczny.


Filtrowanie i łączenie lokatorów

Lokatory Playwright są kompozytowe. Możesz zawężać dopasowania poprzez łączenie filtrów.

Filtruj po widocznym tekście

// Wiele przycisków — znajdź ten z konkretnym tekstem
await page.getByRole('button').filter({ hasText: 'Usuń' }).click();

Filtruj po zagnieżdżonym elemencie

// Kliknij przycisk usuń w wierszu "Zamówienie #1234"
await page.getByRole('row')
  .filter({ hasText: 'Zamówienie #1234' })
  .getByRole('button', { name: 'Usuń' })
  .click();

To game-changer dla interakcji z tabelami. Zamiast kruchych selektorów nth-child, lokalizujesz wiersz po znaczącej treści, a potem znajdujesz przycisk w tym wierszu.

Filtruj po stanie

// Znajdź aktywny przycisk submit (nie wyłączony)
await page.getByRole('button', { name: 'Wyślij' }).filter({ hasNot: page.locator('[disabled]') });

Łączenie dla specyficzności

// Znajdź pole "Email" w formularzu "Logowanie"
await page.locator('form').filter({ hasText: 'Logowanie' })
  .getByLabel('Email')
  .fill('user@example.com');

Łączenie czyni selektory samodokumentującymi się. Intencja jest jasna: “w formularzu logowania, wypełnij pole email.”


Antywzorce — lokatorów do unikania

❌ nth-child i selektory pozycyjne

// ŹLE — psuje się, jeśli kolejność elementów się zmieni
await page.locator('div > button:nth-child(2)').click();
// DOBRZE — opisz, co klikasz
await page.getByRole('button', { name: 'Wyślij' }).click();

❌ Generowane nazwy klas

// ŹLE — nazwy klas zmieniają się przy każdym buildzie
await page.locator('.MuiButton-root-123').click();
// DOBRZE — użyj roli lub test ID
await page.getByRole('button', { name: 'Wyślij' }).click();

❌ Hardkodowane ID z dynamicznych list

// ŹLE — ID jest generowane z bazy i zmienia się w każdym środowisku
await page.locator('#order-98234').click();
// DOBRZE — znajdź po znaczącej treści
await page.getByRole('row').filter({ hasText: 'Zamówienie #1234' }).click();

❌ Zbyt specyficzne ścieżki CSS

// ŹLE — jeden dodatkowy div to psuje
await page.locator('div#app > main > section > div > form > button').click();
// DOBRZE — tak specyficzne, jak potrzeba, nie więcej
await page.locator('form[aria-label="Logowanie"]').getByRole('button', { name: 'Wyślij' }).click();

Przykład z prawdziwego świata — kasa e-commerce

Refaktoryzujmy kruchy test kasy na odporny.

Przed (kruchy)

test('dodaj produkt do koszyka', async ({ page }) => {
  await page.goto('/products');
  await page.click('div.product-grid > div:nth-child(1) > button.add-to-cart');
  await page.click('a.cart-icon');
  await expect(page.locator('div.cart-item').count()).toBe(1);
});

Problemy:

  • div:nth-child(1) — psuje się, jeśli kolejność produktów się zmieni
  • button.add-to-cart — psuje się, jeśli nazwa klasy się zmieni
  • a.cart-icon — psuje się, jeśli link koszyka jest refaktoryzowany
  • div.cart-item — kruchy selektor klasy

Po (odporny)

test('dodaj produkt do koszyka', async ({ page }) => {
  await page.goto('/products');
  
  // Znajdź produkt po nazwie, potem znajdź jego przycisk "Dodaj do koszyka"
  await page.getByRole('article')
    .filter({ hasText: 'Słuchawki bezprzewodowe' })
    .getByRole('button', { name: 'Dodaj do koszyka' })
    .click();
  
  // Przejdź do koszyka przez dostępny link
  await page.getByRole('link', { name: /koszyk/i }).click();
  
  // Sprawdź zawartość koszyka
  await expect(page.getByRole('list', { name: 'Przedmioty w koszyku' }).getByRole('listitem')).toHaveCount(1);
});

Dlaczego to lepsze:

  • Produkt identyfikowany po nazwie — przetrwa zmianę kolejności, zmiany CSS, aktualizacje frameworka
  • Przycisk znaleziony po dostępnej etykiecie — jeśli tekst przycisku się zmieni, test celowo nie przejdzie (bo zachowanie widoczne dla użytkownika się zmieniło)
  • Link koszyka znaleziony po tekście — regex pozwala na “Koszyk (2)” lub “Koszyk zakupów”
  • Przedmioty w koszyku liczone semantycznie — używa właściwych ról list/listitem

Debugowanie lokatorów — Playwright Inspector

Gdy lokator nie pasuje, użyj Playwright Inspector do debugowania:

npx playwright test --debug

Lub dodaj await page.pause() w swoim teście:

test('debuguj lokator', async ({ page }) => {
  await page.goto('/checkout');
  await page.pause();  // Otwiera inspector
  
  // Pisz lokatory w konsoli, zobacz dopasowania w czasie rzeczywistym
  await page.getByRole('button', { name: 'Zapłać teraz' }).click();
});

Inspector pokazuje:

  • Które elementy pasują do Twojego lokatora
  • Dlaczego lokator nie pasuje (zła rola, niedopasowanie tekstu, element nie w DOM)
  • Sugestie alternatywnych lokatorów

:::tip[Użyj Codegen dla pomysłów na lokatory] npx playwright codegen https://example.com otwiera przeglądarkę i nagrywa Twoje interakcje. Auto-generuje lokatory używając zalecanego priorytetu Playwright. Użyj tego jako punktu startowego, potem dopracuj ręcznie. :::


Testowanie dynamicznej treści

Co jeśli element pojawia się warunkowo lub po opóźnieniu?

Czekaj, aż element się pojawi

// Automatycznie ponawia próby, aż element będzie widoczny (do 5s timeout)
await expect(page.getByText('Zamówienie potwierdzone')).toBeVisible();

Czekaj, aż element zniknie

// Czekaj, aż spinner ładowania zniknie
await expect(page.getByTestId('loading-spinner')).not.toBeVisible();

Czekaj na stan elementu

// Czekaj, aż przycisk stanie się aktywny
await page.getByRole('button', { name: 'Wyślij' }).waitFor({ state: 'enabled' });

Czekaj na konkretną liczbę

// Czekaj na dokładnie 3 przedmioty w koszyku
await expect(page.getByRole('listitem')).toHaveCount(3);

Auto-waiting Playwright obsługuje większość problemów czasowych. Jeśli piszesz jawne oczekiwania, prawdopodobnie walczysz z frameworkiem. Refaktoryzuj lokator lub użyj asercji web-first.


Checklista strategii lokatorów

Użyj tego podczas przeglądania testów:

☐ Używa getByRole tam, gdzie to możliwe
☐ Używa getByLabel dla pól formularzy
☐ Używa getByText dla statycznej treści
☐ Wraca do getByTestId tylko gdy semantyczne lokatory nie pasują
☐ Brak nth-child lub selektorów pozycyjnych
☐ Brak generowanych nazw klas (MuiButton-root-*, css-xyz123)
☐ Brak hardkodowanych oczekiwań (waitForTimeout)
☐ Lokatory opisują zachowanie widoczne dla użytkownika, nie strukturę DOM
☐ Test ID dodane do HTML są udokumentowane (dlaczego są potrzebne)

Podsumowanie

Strategia lokatorów to nie drobny szczegół — to różnica między suitą testów, która działa latami z minimalnym utrzymaniem, a taką, która wymaga inżyniera na pełen etat tylko po to, żeby ją utrzymać na zielono.

Zasada: Jeśli refaktor UI, który nie zmienia zachowania widocznego dla użytkownika, psuje Twoje testy, Twoje lokatory są złe.

Zainwestuj czas w naukę selektorów opartych na rolach i filtrowanie. To się opłaca co sprint, gdy refaktory nie psują Twoich testów.

Zadanie na ten tydzień: Wybierz jeden istniejący test z selektorami CSS lub XPath. Refaktoryzuj go, aby używał lokatorów opartych na rolach i filtrów. Uruchom go przed i po, żeby zweryfikować, że zachowanie jest niezmienione. Potem zauważ, o ile bardziej czytelna jest wersja po refaktorze.


Poprzednio: Część 1 — Playwright od zera
Dalej: Część 3 — Page Object vs fixtures