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ę zmienibutton.add-to-cart— psuje się, jeśli nazwa klasy się zmienia.cart-icon— psuje się, jeśli link koszyka jest refaktoryzowanydiv.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