Testy komponentów Angulara można uruchamiać w jsdom lub w prawdziwej przeglądarce przez Vitest Browser Mode. Przy wyborze środowiska warto zapytać, co konkretnie test ma potwierdzić: walidację formularza, przeniesienie fokusu, rozmiar kontenera czy możliwość kliknięcia przycisku wskaźnikiem.

Sprawdziliśmy sześć takich scenariuszy. Przed napisaniem testów dla każdego z nich określiliśmy oczekiwane zachowanie oraz jedną izolowaną mutację, która celowo je psuła. Sami wybraliśmy mutacje, żeby przeanalizować różne mechanizmy. To mały przykład testowy, a nie losowa próbka błędów działającej aplikacji.

Część tych mechanizmów dotyczy interfejsów webowych ogólnie. Tutaj sprawdzaliśmy je na komponentach Angulara; w S4–S5 szczegółowo widać rolę TestBed, sygnałów i odroczonego pozycjonowania.

Poprawną i wadliwą wersję każdego komponentu Angulara uruchomiliśmy na trzy sposoby:

  • A: jsdom + synthetic actions. Vitest działa w jsdom, a interakcje wykonuje @testing-library/user-event.
  • B: Chromium + synthetic actions. To samo podejście z @testing-library/user-event, ale test działa w Chromium w trybie headless przez Browser Mode.
  • C: Chromium + provider actions. W tym samym Chromium używamy akcji z vitest/browser przez provider Playwright.

Eksperyment miał ustalić, które z wcześniej określonych wymagań użytkownika te testy sprawdzają w całości i jakich obserwacji im brakuje. Nie porównujemy szybkości i nie tworzymy rankingu narzędzi.

Różnicę między akcjami syntetycznymi a akcjami providera opisuje już Marmicode Cookbook, także na przykładzie zasłoniętego przycisku. Dodaliśmy sprawdzenie sześciu wcześniej wybranych mutacji, osobny hit test bez akcji providera oraz opis błędu we własnym teście geometrii.

Ogólny obraz: w S1–S3 jsdom wystarczył do sprawdzenia formularza, programowego fokusu i określonej sekwencji Tab. S4–S5 wymagały rzeczywistego layoutu przeglądarki. W S6 syntetyczny click wywoływał handler zasłoniętego przycisku, choć wskaźnik nie mógł w niego trafić. Taki test pozostawał zielony także po uruchomieniu w Chromium.

Co uznawaliśmy za pełne sprawdzenie

Zanim testowi można było przypisać wykrycie mutacji, musiał przejść na poprawnej wersji. Następnie sprawdzaliśmy, jaką część oczekiwanego zachowania rzeczywiście weryfikuje.

  • bug_detected: poprawna wersja przeszła test, a wadliwa naruszyła całe wcześniej określone kryterium.
  • partial: test sprawdził część tego kryterium.
  • unsupported: w tym przykładzie testowym nie można wiarygodnie sprawdzić całego kryterium bez zastąpienia badanego zachowania.

unsupported odnosi się do pełnego zachowania w tym przykładzie. Mock pozwala sprawdzić reakcję na rozmiar lub argument observe(), ale są to inne asercje. partial i unsupported nie oznaczają false_negative, czyli pominięcia błędu przy pełnym sprawdzeniu.

Scenariusz i kryterium użytkownika A: jsdom + synthetic B: Chromium + synthetic C: Chromium + provider
S1. Formularz: odrzucić nieprawidłowy email, a następnie wysłać prawidłowy bug_detected bug_detected bug_detected
S2. Fokus dialogu: po otwarciu przenieść fokus na Zamknij, a po zamknięciu przywrócić go na Otwórz bug_detected bug_detected bug_detected
S3. Kolejność Tab: przejść przez dostępne kontrolki i pominąć ukrytą bug_detected bug_detected bug_detected
S4. Szerokość kontenera: zaktualizować się po zwężeniu wewnętrznego kontenera bez zmiany viewportu unsupported bug_detected bug_detected
S5. Popover przy krawędzi: zmienić stronę i utrzymać cały interaktywny prostokąt wewnątrz viewportu unsupported bug_detected bug_detected
S6. Zasłonięty przycisk: umożliwić kliknięcie wskaźnikiem w środku i zaktualizować status partial partial bug_detected

Tabela dotyczy pierwotnych adapterów testowych, czyli kodu akcji, oczekiwań i asercji w każdym trybie. Późniejsza kontrola S6 przez elementFromPoint nie jest w niej uwzględniona; opisujemy ją osobno poniżej.

Komponenty, style i mutacje pozostawały takie same we wszystkich trybach. Adaptery się różniły: w S1 A/B używały type, a C używał fill; w S3 A/B wywoływały user-event.tab, a C wysyłał przeglądarkowy Tab. Dlatego tych wyników nie można przypisać jednej, całkowicie izolowanej właściwości środowiska.

Gdzie te scenariusze mieszczą się w piramidzie testów

We wszystkich sześciu scenariuszach montowaliśmy pojedynczy komponent przez TestBed i sprawdzaliśmy go razem z szablonem i zdarzeniami DOM. Pod względem zakresu są to testy integracyjne komponentów. Nie uruchamialiśmy pełnej aplikacji z routingiem, backendem i przepływem użytkownika. Sam Browser Mode nie zmienia takiego testu w test E2E.

W S1–S3 jsdom wystarczył do sprawdzenia zadanego zachowania. W S4–S5 komponent potrzebował wymiarów i powiadomień przeglądarki, a w S6 trzeba było sprawdzić możliwość kliknięcia. Osobne testy jednostkowe w tych scenariuszach mogą sprawdzać walidator, wybór stanu na podstawie szerokości lub obliczanie pozycji. Są przydatne, ale nie potwierdzają interakcji komponentu z przeglądarką. Do sprawdzenia przepływu całej aplikacji potrzebne byłyby testy E2E; eksperyment nie badał tego poziomu.

S1–S3: formularz, fokus i Tab

W S1 formularz odrzucał bad.example, wyświetlał błąd i utrzymywał licznik wysłań na poziomie zera. Następnie przyjmował **@*****le.com. Mutacja usuwała sprawdzenie formatu, pozostawiając sprawdzenie pustego pola. Wykryły ją wszystkie trzy tryby. Walidację wykonywała aplikacja z novalidate; natywny komunikat przeglądarki nie należał do scenariusza.

W S2 sprawdzaliśmy fokus: po otwarciu dialogu i ustabilizowaniu renderowania miał trafić na Zamknij, a po zamknięciu wrócić na Otwórz. Mutacja usuwała przeniesienie fokusu przy otwieraniu. Sprawdzenie document.activeElement wykryło ją w A, B i C.

W S3 test trzykrotnie naciskał Tab ze znanej pozycji początkowej. Oczekiwana kolejność: Pierwszy, Drugi, Koniec. Między dwiema ostatnimi kontrolkami znajdowała się jeszcze jedna w sekcji z display: none. Po zamianie tej właściwości na opacity: 0 kontrolka pozostawała w kolejności Tab, choć wizualnie była ukryta. Zmianę wykryły zarówno user-event.tab w jsdom, jak i akcja przeglądarkowa.

Te testy nie wymagały pomiarów layoutu. Ich wyniki dotyczą programowego fokusu i wskazanej sekwencji Tab. Nie sprawdzaliśmy focus trap, visibility: hidden, inert ani pozostałych wariantów nawigacji klawiaturą. Komponent używający tych sposobów ograniczania interakcji wymaga osobnych testów kolejności Tab i fokusu. S3 nie jest pełnym testem dostępności.

S4: który kontener obserwuje ResizeObserver

Komponent zmieniał etykietę w zależności od szerokości swojego kontenera. Przy 360 px pokazywał Szeroki. Po zwężeniu do 240 px, poniżej progu 300 px, miał pokazać Wąski. Viewport się nie zmieniał, a zewnętrzny kontener nadal miał szerokość 400 px.

W poprawnej implementacji ResizeObserver obserwował wewnętrzny kontener. Mutacja przełączała go na zewnętrzny. Właściwa szerokość się zmieniała, ale observer śledził inny element.

W komponencie Angulara powiadomienie aktualizowało sygnał, którego wartość pojawiała się w szablonie. Fragment poprawnej implementacji:

this.observer = new ResizeObserver((entries) => {
  this.compact.set(entries[0].contentRect.width < 300);
});
this.observer.observe(this.inner.nativeElement);

Przy niszczeniu komponentu observer był odłączany. W kodzie źródłowym istniał już osobny hook:

ngOnDestroy(): void {
  this.observer?.disconnect();
}

Ręczne przekazanie callbackowi szerokości 240 px pozwala sprawdzić, czy przełącza on stan komponentu. Jednak w takim teście powiadomienie zostało już dostarczone niezależnie od tego, który element obserwuje przeglądarka. Bez osobnego sprawdzenia obserwowanego obiektu takie zastąpienie zachowania nie sprawdza błędu z naszego scenariusza.

Można też napisać bardziej precyzyjny test z mockiem: zapisać argument observe() i porównać go z wewnętrznym kontenerem. Taki spy mógłby wykryć właśnie tę mutację w jsdom. Sprawdzałby, który element komponent zaczął obserwować. To przydatna kontrola implementacji, ale nie potwierdza całego scenariusza użytkownika: po rzeczywistym zwężeniu kontenera przeglądarka dostarczyła powiadomienie, a etykieta się zaktualizowała. Taki spy nie należał do głównej serii.

W B i C rzeczywiście zwężaliśmy wewnętrzny kontener i czekaliśmy na zmianę etykiety. Oba testy wykryły mutację. Dla A pełny scenariusz miał status unsupported: w tym przykładzie brakowało niezależnych pomiarów layoutu i powiadomienia przeglądarki.

S5: gdzie znalazł się popover

Komponent otwierał popover obok triggera, czyli elementu, do którego był zakotwiczony. W zwykłym położeniu popover miał znajdować się po prawej stronie. Gdy trigger przesuwał się przy prawą krawędź, do left = 740 przy viewporcie 800 × 600 CSS px, popover miał otwierać się po lewej.

Sprawdzaliśmy stronę oraz cały interaktywny prostokąt względem viewportu, z tolerancją jednego piksela. Mutacja wyłączała zmianę strony. Zwykłe otwarcie nadal działało, ale przy krawędzi prawa granica popovera sięgała 948 px.

Po poprawieniu testów B i C wykryły naruszenie. W A pełny scenariusz miał status unsupported: wymagał pomiaru rzeczywistego położenia popovera.

Dlaczego pierwszy test przeglądarkowy przeszedł mimo błędu

Po otwarciu Angular dodawał popover do DOM, a jego położenie było obliczane później przez requestAnimationFrame. W tym czasie element już istniał, ale zachowywał współrzędne poprzedniego otwarcia. Test mógł sprawdzić ten stary prostokąt i przejść na wadliwej wersji.

Poprawialiśmy oczekiwanie w dwóch krokach. Najpierw zaczęliśmy czekać na przesunięcie triggera do krawędzi przed otwarciem. To nie wystarczyło: trzeba było także poczekać na aktualizację położenia samego popovera. Dopiero potem test sprawdzał stronę i wszystkie cztery granice viewportu.

Oczekiwanie na aktualizację dopuszczało zarówno poprawne, jak i błędne nowe położenie. Nie podpowiadało więc testowi oczekiwanej odpowiedzi. Błąd wykrywały późniejsze asercje dotyczące strony i granic.

Kolejność sprawdzania S5: przesunięcie triggera, otwarcie popovera, oczekiwanie na jego położenie, sprawdzenie strony i granic

Przeglądarka dostarczała rzeczywiste współrzędne, ale nasz test odczytywał je za wcześnie. Po poprawieniu oczekiwań i uzupełnieniu asercji powtórzyliśmy końcowe serie. Wczesne fałszywe przejścia zachowaliśmy w dzienniku odstępstw i wykluczyliśmy z macierzy.

Jak poprawiliśmy test S5: Angular, kod oczekiwania i próg 500

Wszystkie komponenty montowaliśmy przez Angular TestBed. Helper tworzył fixture, dodawał jego element do dokumentu i wykonywał:

fixture.detectChanges();
await fixture.whenStable();

Tak rzeczywiście przygotowywaliśmy komponent w eksperymencie.

Od Angulara 21 zoneless jest domyślnie włączony. Tryb TestBed zależy od załadowania zone.js: z nim domyślnie używane jest wykrywanie zmian oparte na Zone, a bez niego zoneless. Jawny provider może zmienić ten wybór. W zapisanej konfiguracji przykładu ZoneJS nie był ładowany.

W nowych testach zoneless Angular zaleca, aby w miarę możliwości czekać na fixture.whenStable(), pozwalając Angularowi wykonać zaplanowaną aktualizację. Ręczne detectChanges() wymusza wykrywanie zmian i może ukryć brak powiadomienia o zmianie stanu. Powyższy fragment zachowuje oryginalny helper, zamiast zastępować go nową implementacją.

Następnie akcje użytkownika zmieniały stan komponentu. W S5 otwieranie popovera wyglądało tak:

open(): void {
  this.opened.set(true);
  requestAnimationFrame(() => this.place());
}

Popover pojawiał się przez Angular @if. W następnej klatce animacji place() mierzył trigger i popover oraz aktualizował sygnał ze współrzędną. Przygotowanie fixture przez whenStable() odbywało się przed tymi akcjami użytkownika. W teście otwarcia osobno czekaliśmy na potrzebną zmianę geometrii. Stabilność Angulara i poprawne położenie popovera to różne warunki: whenStable() nie jest asercją dotyczącą strony lub granic elementu.

W B i C dodaliśmy takie same oczekiwania. Poniższy fragment poprawionego testu B pokazuje kolejność:

await user.click(buttons[0]);
await until(() => Math.round(trigger.getBoundingClientRect().left) === 740);
await user.click(trigger);
await until(() => popup().left >= 500);

expect(popup().right).toBeLessThan(trigger.getBoundingClientRect().left);
expect(popup().left).toBeGreaterThanOrEqual(-1);
expect(popup().right).toBeLessThanOrEqual(801);
expect(popup().top).toBeGreaterThanOrEqual(-1);
expect(popup().bottom).toBeLessThanOrEqual(601);

Tutaj buttons[0] przesuwa trigger do krawędzi, trigger otwiera popover, a popup() odczytuje jego prostokąt. Wspólny helper until sprawdzał warunek co 20 ms, aż został spełniony lub upłynął limit czasu. Fragmenty zachowują rzeczywiście użyty kod. We własnym teście można zapisać oczekiwanie za pomocą wbudowanych vi.waitFor lub expect.poll; wybór helpera nie był tu badany.

Przed ponownym pozycjonowaniem popover pozostawał w starym miejscu: jego lewa granica znajdowała się około 148 px od lewej krawędzi ekranu. Sprawdzenie położenia w tym momencie odczytałoby wynik poprzedniego otwarcia.

Liczba 500 jest tu progiem pośrednim. Dopóki współrzędna wynosi około 148, test czeka. Gdy popover przesunie się do triggera przy prawej krawędzi, oczekiwanie się kończy. Zarówno poprawne, jak i wadliwe nowe położenie przekraczają ten próg, więc samo left >= 500 nie oznacza jeszcze, że komponent działa poprawnie.

Sprawdzenie poprawności następuje później: popover ma znaleźć się po lewej stronie triggera i pozostać wewnątrz ekranu. Wadliwa wersja spełnia warunek oczekiwania, ale następnie test kończy się błędem, ponieważ prawa granica sięga 948 px. Próg 500 dobrano do dwóch pozycji w tym przykładzie; nie można przenosić go bez zmian do innych testów.

Po doprecyzowaniu oczekiwań, dodaniu pionowych granic i poprawieniu CSS powtórzyliśmy końcowe serie. Wczesne logi zachowaliśmy w dzienniku odstępstw i wykluczyliśmy z macierzy.

S6: trzy sposoby sprawdzenia zasłoniętego przycisku

Widoczny przycisk Potwierdź o wymiarach 160 × 48 px miał po jednym kliknięciu wskaźnikiem w środku zmieniać status z Oczekuje na Potwierdzono. Fragmenty kodu zachowują oryginalne rosyjskie etykiety przykładu: Подтвердить (Potwierdź), Ожидает (Oczekuje) i Подтверждено (Potwierdzono).

Środek zasłaniała warstwa dekoracyjna. W poprawnej wersji miała pointer-events: none. Mutacja zmieniała wartość na auto: przycisk i handler pozostawały takie same, ale warstwa przechwytywała kliknięcia.

Wadliwy S6: synthetic click wywołuje handler, provider click zatrzymuje się na zasłonięciu, a osobny hit test znajduje .decoration

Pierwotny test A/B:

const fixture = await mount(CoveredButtonComponent);
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
const output = fixture.nativeElement.querySelector('output') as HTMLOutputElement;
expect(output.textContent?.trim()).toBe('Ожидает');
await userEvent.setup().click(button);
await until(() => output.textContent?.trim() === 'Подтверждено');

Tutaj userEvent jest importowany z @testing-library/user-event. Handler się uruchamiał i status zmieniał się także w wadliwej wersji. Dlatego pierwotne A/S6 i B/S6 otrzymały partial.

user-event modeluje sekwencje interakcji i wykonuje własne sprawdzenia. Jego pointerEventsCheck sprawdza, czy element ma lub dziedziczy pointer-events: none. Testing Library: pointerEventsCheck. Osobno dokumentacja pointer API mówi, że biblioteka nie ustala, czy opisane działanie jest możliwe we wskazanym punkcie layoutu. Testing Library: pointer.

Sam przycisk w S6 nie miał pointer-events: none. Problemem była inna warstwa nad nim. Target przekazany do click nadal był przyciskiem, a test po akcji sprawdzał tylko zmianę statusu. Nawet prawdziwy Chromium nie dodawał do tego kodu kontroli trafienia wskaźnikiem.

Provider click

W C akcja korzystała z page z vitest/browser. Odpowiedni fragment:

const fixture = await mount(CoveredButtonComponent);
const output = fixture.nativeElement.querySelector('output') as HTMLOutputElement;
expect(output.textContent?.trim()).toBe('Ожидает');
await page.getByRole('button', { name: 'Подтвердить' }).click();
await until(() => output.textContent?.trim() === 'Подтверждено');

Click był wywoływany bez force. Poprawna wersja przechodziła test. Na wadliwej provider zgłaszał, że .decoration przechwytuje pointer events, i nie mógł wykonać akcji. Sprawdzenie actionability wykrywało przeszkodę przed kliknięciem.

Osobny test B z elementFromPoint

Po głównej serii dodaliśmy eksploracyjne sprawdzenie B z document.elementFromPoint. Metoda zwraca najwyższy element w danym punkcie. Środek wyliczaliśmy z rzeczywistego prostokąta przycisku. Poniżej funkcja z tego testu:

const hitAtCentre = () => {
  const rect = button.getBoundingClientRect();
  expect(rect.width).toBeGreaterThan(0);
  expect(rect.height).toBeGreaterThan(0);
  const x = rect.left + rect.width / 2;
  const y = rect.top + rect.height / 2;
  const target = document.elementFromPoint(x, y);
  return { x, y, target, hitButton: target === button || button.contains(target) };
};

Sprawdzenie wykonywaliśmy przed syntetycznym clickiem i po nim. W poprawnej wersji w środku (80, 24) znajdował się przycisk lub jego potomek. W wadliwej metoda zwracała .decoration, choć handler nadal zmieniał status. Test wymagał hitButton: true w obu pomiarach i wykrył zasłonięcie.

Sprawdzenie S6 Obserwacja
Pierwotne A/B: synthetic click i status Handler zmienia stan po dostarczeniu zdarzenia.
C: provider click bez force i status Akcja przechodzi sprawdzenia actionability, zostaje wykonana, a status się zmienia.
B + hit test (eksploracyjny, poza główną macierzą) Przed clickiem i po nim w środku znajduje się przycisk lub jego potomek. Wadliwa warstwa narusza tę asercję.

W B były teraz dwa różne testy. Pierwotny sprawdzał tylko zmianę statusu po syntetycznym clicku. Późniejszy dodatkowo pytał, który element znajduje się w punkcie kliknięcia, i wykrywał zasłonięcie.

Główna tabela opisuje pierwszy zestaw testów. Dlatego B/S6 pozostaje w niej partial, a wynik dodanego testu podajemy osobno. To nie jest ograniczenie Browser Mode: przeglądarka dostarczała dane do hit testu, ale pierwotny test o nie nie pytał. Sprawdzenie jednego punktu nie jest też równoważne pełnemu zestawowi sprawdzeń actionability providera.

W tym scenariuszu środowisko dostarczało informacje o geometrii, akcja określała sposób interakcji, a test oracle definiował, jaki wynik uznać za poprawny. Zmiana statusu wystarczała do sprawdzenia handlera, ale nie do potwierdzenia deklarowanego kliknięcia wskaźnikiem.

Jak sprawdzić własny test

Poszukaj błędu, z którym Twój test nadal pozostanie zielony:

Co test już sprawdza Co może przeoczyć Jaką obserwację dodać
Dialog się pojawił Fokus pozostał na elemencie otwierającym Sprawdzić activeElement po otwarciu i przywrócenie fokusu po zamknięciu.
Komponent reaguje na przekazany rozmiar Observer obserwuje inny kontener Rzeczywiście zmienić rozmiar właściwego kontenera i poczekać na reakcję.
Popover istnieje Nie zmienił strony lub wyszedł poza viewport Poczekać na aktualizację layoutu, sprawdzić położenie i granice.
Po syntetycznym clicku zmienił się status Inna warstwa uniemożliwia kliknięcie przycisku Wykonać akcję providera lub jawny hit test w przeglądarce dla wskazanego punktu.

Dla formularza, programowego fokusu i konkretnej kolejności Tab najpierw sprawdź, czy istniejący test w jsdom ma wystarczające obserwacje. W scenariuszu klawiaturowym wykonuj sekwencję Tab: wywołania .focus() na oczekiwanych elementach nie sprawdzają przejść.

Gdy wymaganie zależy od rzeczywistego rozmiaru, położenia lub powiadomienia przeglądarki, sprawdź te dane w przeglądarce. Mock może uzupełniać takie sprawdzenie testem logiki. Nazwa i opis testu powinny odzwierciedlać tę część zachowania, którą potwierdza.

Każdy wybrany błąd sprawdzaj razem z poprawną wersją. Jeśli obie nie przechodzą testu, najpierw trzeba zbadać test i jego warunki.

Warunki eksperymentu

Testy uruchamialiśmy na Windows 11. Dla B i C używaliśmy Chromium w trybie headless z viewportem 800 × 600 CSS px i współczynnikiem skali 1.

Sami wybraliśmy sześć błędów i sprawdziliśmy je na małym przykładzie. Wyniki wyjaśniają, co sprawdzają te testy. Nie mówią, jak często takie błędy występują w działających aplikacjach ani ile błędów to samo podejście znajdzie w innym projekcie. Nie uruchamialiśmy testów na innych systemach operacyjnych ani silnikach przeglądarek. Nie sprawdzaliśmy też dużych zestawów testów ani przepływów E2E.

Na podstawie tych materiałów nie można porównywać szybkości: stare lokalne uruchomienia wykonywały różną ilość pracy. Nie mierzyliśmy pamięci, działania w CI ani kosztów migracji. Nie wyciągamy też wniosków o flakiness. Jedno wczesne uruchomienie S4/S5 na zimno zakończyło się przekroczeniem limitu czasu; nie ustaliliśmy przyczyny.

Przed napisaniem artykułu sprawdziliśmy zapisane wyniki i logi. Nie było niezależnego powtórzenia na innej maszynie z czystą instalacją.

Wersje i zapisane wyniki

W przykładzie używaliśmy następujących wersji:

Narzędzie Wersja
Angular 22.2.0
Vitest 5.0.2
@vitest/browser-playwright 5.0.2
jsdom 30.1.1
Playwright 1.63.0
@testing-library/user-event 14.6.7
Chromium 153.0.8010.12, revision 1243
Node.js 24.19.0

Przed końcową serią zwiększyliśmy czas oczekiwania na warunek do 5 sekund, a limit czasu testu do 15 sekund.

Zachowaliśmy protokół, kod, patche, lockfile, konfigurację i wyniki uruchomień. Aby sprawdzić tabelę, ponownie przeanalizowaliśmy archiwum i porównaliśmy 21 uruchomień z raportami JSON: 3 uruchomienia poprawnych wersji i 18 z mutacjami. Wynik się nie zmienił. W S6 zajrzeliśmy także do logów tekstowych: provider zgłaszał, że warstwa przechwytuje kliknięcie. Sama informacja o nieudanym teście nie wystarczyłaby do takiego wyjaśnienia.

Wniosek

W pierwotnych testach A/B dla S6 handler się uruchamiał, a zasłonięty przycisk przechodził test. Zasłonięcie wykryliśmy, gdy zaczęliśmy sprawdzać możliwość kliknięcia. Od tego warto zacząć wybór środowiska: co użytkownik musi móc zrobić i jaka obserwacja w teście to potwierdza?