Najkrótsza droga do stabilnych selektorów tekstowych
- text() działa na bezpośrednich węzłach tekstowych, więc bywa zbyt wąskie przy zagnieżdżonym HTML.
-
. bierze cały string-value elementu, dlatego lepiej radzi sobie z
span, ikonami i łamanym tekstem. - normalize-space() usuwa nadmiarowe odstępy i łamania linii, co znacząco poprawia trafność porównań.
- contains() i starts-with() pomagają, gdy napis jest częściowo zmienny, ale trzeba je zawężać kontekstem.
-
Najstabilniejsze locatory to nadal
idi sensownedata-*; selektory tekstowe traktuję jako narzędzie uzupełniające, a nie domyślne.
Jak XPath widzi tekst w elemencie
XPath nie patrzy na element jak człowiek, tylko jak na drzewo węzłów. text() wybiera bezpośrednie węzły tekstowe potomne, a string-value elementu to po prostu złączony tekst wszystkich jego potomków w kolejności dokumentu. To brzmi technicznie, ale w testach ma bardzo praktyczny skutek: //button[text()='Zapisz'] może nie znaleźć przycisku, jeśli napis siedzi wewnątrz , a //button[.='Zapisz'] już go zobaczy.
//button[text()='Zapisz']
//button[.='Zapisz']
//button[normalize-space(.)='Zapisz']
Ja zwykle zaczynam od sprawdzenia, czy tekst jest jednym prostym węzłem, czy UI rozbija go na kilka fragmentów. Jeśli w środku są ikony, badge albo wrappery, text() często przestaje być wystarczające i wtedy lepiej od razu przejść na kropkę oraz normalizację białych znaków. To prowadzi prosto do wyboru właściwego wariantu selektora.
Który wariant wybrać w testach automatycznych
W praktyce nie ma jednego uniwersalnego XPath-a. Ja rozróżniam je według tego, czy potrzebuję dokładnego trafienia, tolerancji na odstępy, czy częściowego dopasowania tekstu.
| Wariant | Co robi | Kiedy go używam | Na co uważać |
|---|---|---|---|
text() |
Dopasowuje bezpośrednie węzły tekstowe | Proste elementy bez zagnieżdżeń | Zawodzi przy span, ikonach i kilku węzłach tekstowych |
. |
Bierze string-value całego elementu | Przyciski, etykiety, komunikaty z prostą strukturą | Może objąć więcej tekstu niż zakładasz |
normalize-space(.) |
Usuwa nadmiarowe spacje i łamania linii | Exact match dla tekstów widocznych użytkownikowi | Nie naprawia złej struktury, tylko porządkuje string |
contains(normalize-space(.), '...') |
Szuka fragmentu napisu | Menu, alerty, dłuższe nagłówki, tekst częściowo zmienny | Zbyt szerokie dopasowanie, jeśli nie zawęzisz kontekstu |
starts-with(normalize-space(.), '...') |
Sprawdza początek napisu | Etykiety z dopiskiem, numeracją, prefiksem | Mniej elastyczne niż contains()
|
Najkrócej: exact match daję tam, gdzie tekst ma być niezmienny, a contains() zostawiam dla przypadków, w których UI naturalnie się rozrasta. Jeśli selektor ma wybaczać odstępy, normalize-space() praktycznie zawsze poprawia czytelność i stabilność. I właśnie ten wybór decyduje, czy test będzie czytelny, czy tylko pozornie działający.

Przykłady, które najczęściej ratują testy
Najlepiej to widać na realnych elementach interfejsu. Poniżej pokazuję wzorce, które sam stosuję najczęściej w testach E2E i smoke testach.
//button[normalize-space(.)='Zaloguj się']
//a[contains(normalize-space(.), 'Regulamin')]
//div[@role='alert' and contains(normalize-space(.), 'Zapisano')]
//section[@data-section='checkout']//button[normalize-space(.)='Kup teraz']
//label[.//span[normalize-space()='E-mail']]
- Przycisk akcji - pierwsza linia łapie prosty napis, bezpieczna dla zwykłych buttonów.
-
Link w nawigacji -
contains()działa, gdy link ma dłuższy tekst, ale wciąż rozpoznawalny fragment. -
Komunikat statusu - warto dodać
@role='alert', żeby nie łapać przypadkowego tekstu z innej części strony. - Sekcja zamówienia - zawężenie do konkretnego bloku jest często ważniejsze niż sam tekst, bo ten sam napis może pojawić się kilka razy.
-
Etykieta zagnieżdżona w spanie - to klasyczny przypadek, w którym
text()bywa zawodny, a.działa lepiej.
Takie selektory są czytelne, ale nie powinny żyć w próżni. Jeśli masz powtarzalny napis typu „Zobacz więcej”, bez kontekstu dostaniesz kilka trafień i test zacznie wybierać pierwszy lepszy element. Wtedy sam tekst nie rozwiązuje problemu, tylko odsłania brak zawężenia.
Najczęstsze pułapki przy selektorach tekstowych
Najwięcej błędów widzę nie w samym XPath, tylko w założeniu, że tekst w DOM zawsze wygląda tak samo jak na ekranie. W praktyce tak nie jest.
-
Spacje i łamania linii - dokładne porównanie bez
normalize-space()potrafi się wysypać przez niewidoczne odstępy albo nową linię w markupie. -
Zagnieżdżone elementy - ikona, badge albo osobny
mogą sprawić, żetext()przestanie widzieć cały napis. -
contains(text(), '...')wygląda dobrze, ale bywa zdradliwe - przy kilku węzłach tekstowych porównujesz tylko fragment, więc lepiej użyćcontains(normalize-space(.), '...')albo zawęzić selektor. -
Zbyt szerokie
contains()- jeśli dopasowujesz tylko fragment, łatwo złapać element z innego modułu albo ukrytego panelu. - Zmienne treści - liczby, daty, statusy i copy marketingowe zmieniają się częściej niż nazwy atrybutów, a przy aplikacjach wielojęzycznych tekst bywa dodatkowo zależny od lokalizacji.
-
Brak kontekstu - globalne
//button[...]jest wygodne na początku, ale w większym UI szybko robi się nieczytelne i kruche. - Brak regexów w typowym browser XPath - jeśli tekst ma złożony wzorzec, sam XPath zwykle nie wystarcza i lepiej oprzeć się na atrybucie albo rozbić sprawdzenie na prostsze kroki.
Jeśli muszę naprawić taki selektor, zwykle nie zaczynam od kombinowania z funkcją, tylko od zawężenia obszaru: formularza, sekcji, karty produktu albo konkretnego komponentu. To daje więcej niż kolejne warstwy contains(), które tylko maskują zbyt szeroki wybór.
Jak pisać selektory, które przetrwają zmiany w interfejsie
W praktyce traktuję tekstowe XPath-y jako narzędzie drugiego wyboru: bardzo skuteczne tam, gdzie tekst niesie znaczenie biznesowe, ale nie jako domyślną odpowiedź na każdy element. Jeśli masz stabilne id albo sensowne data-testid, zwykle wygrywają z bardziej ekspresyjnym selektorem opartym na tekście.
-
Zacznij od najtańszego i najbardziej stabilnego identyfikatora -
id,data-testid,namealbo semantycznyrole. - Używaj tekstu tam, gdzie to część zachowania - przyciski, etykiety, komunikaty, elementy nawigacji.
-
Zawężaj kontekst - zamiast globalnego
//button[...]lepiej wskazać konkretną sekcję, formularz lub kartę. -
Normalizuj odstępy -
normalize-space(.)oszczędza czas przy debugowaniu drobnych różnic w markupie. - Rozróżniaj exact match i partial match - pełne dopasowanie jest bardziej precyzyjne, częściowe bardziej odporne na drobne zmiany copy.
- Dodawaj jawne waity - tekst może pojawić się później niż sam kontener, więc locator i synchronizacja muszą iść razem.
To podejście dobrze współgra z Page Object Model: lokator siedzi w jednym miejscu, a test opisuje zachowanie. Dzięki temu, gdy UI się zmienia, poprawiasz selektor raz, a nie w kilkunastu scenariuszach.
Gdy tekst jest jedyną sensowną kotwicą
Najbardziej praktyczna zasada jest prosta: używaj tekstu wtedy, gdy to właśnie tekst jest dla użytkownika znaczący. Gdy interfejs jest złożony albo zbyt „sprytny”, tekstowy XPath staje się nie tyle złym narzędziem, ile narzędziem, które trzeba umieć ograniczyć.
- Exact match wybieram dla stałych etykiet i przycisków.
- Partial match zostawiam dla dłuższych komunikatów i nagłówków.
- Scoped locator stosuję, gdy ten sam napis występuje w kilku miejscach.
- Alternatywa w atrybucie wygrywa, jeśli tekst jest zależny od języka, kampanii albo testu A/B.
Dobrze napisany selektor po tekście skraca diagnostykę, poprawia czytelność testów i rzadziej psuje się przy kosmetycznych zmianach frontu. Jeśli mam wybrać jedną rzecz, którą warto zapamiętać, to tę: najpierw zawęź kontekst, potem dopasuj tekst, a dopiero na końcu kombinuj z bardziej złożonym XPath-em.