Z poniższego artykułu dowiesz się, jak pisać testy e2e, które są stabilne i łatwe w utrzymywaniu.
Automatyzacja testów oprogramowania stała się nieodłącznym narzędziem w zapewnianiu nie tylko efektywności, ale również wysokiej jakości w procesie tworzenia aplikacji. Wartości, jakie niesie ze sobą automatyzacja testów, są niezaprzeczalne – oszczędność czasu, zwiększona precyzja testów, szybsza informacja zwrotna.
Jednakże, aby osiągnąć te korzyści, konieczne jest zrozumienie i wdrożenie najlepszych praktyk pisania i takich testów. W niniejszym artykule przyjrzymy się kilku kluczowym zasadom, których trzymanie się nie tylko ułatwia utrzymanie testów w przyszłości, ale również znacznie zwiększa ich skuteczność i wpływa pozytywnie na ich stabilność.
Pokazane poniżej przykłady są oparte o JavaScript i framework Playwright, jednak wskazane w nich najlepsze praktyki są w większości uniwersalne i niezależne od użytego narzędzia do automatyzacji.
1. Testy powinny być niezależne od siebie
Wyobraźmy sobie sytuację, w której testujemy ścieżkę zakupową w aplikacji e-commerce, a dokładniej – wyszukanie produktu i dodanie go do koszyka. Przyjrzyjmy się poniższemu fragmentowi kodu testu automatycznego:
const { test, expect } = require('@playwright/test');
test('test of searching for a product', async ({ page }) => {
// wyszukaj produkt XYZ
// sprawdz, czy produkt zostal znaleziony
});
test('test of adding a product to the basket', async ({ page }) => {
// dodaj znaleziony wczesniej produkt XYZ do koszyka
// sprawdz, czy produkt zostal usuniety
});
Pierwszym przypadkiem testowym jest wyszukanie produktu, a drugim – dodanie go do koszyka. Choć na pierwszy rzut oka oba testy robią co do nich należy, taki zapis nie jest poprawny, a wynik testów może być zafałszowany. Co dokładnie tutaj jest nie tak?
- test nr 2 jest zależny od testu nr 1, ponieważ potrzebuje produktu o nazwie XYZ znalezionego w teście nr 1. Jeśli pierwszy test nie znajdzie produktu, drugi test zwróci nam błąd
- jeśli test nr 2 wykona się przed testem nr 1, również zwróci nam błąd –
Jaka jest dobra praktyka? Testy powinny być niezależne od siebie. Żaden test nie może zależeć od powodzenia innego testu, ponieważ jeśli jeden z nich się nie uda, zafałszuje wyniki pozostałych. Izolacja testów niesie ze sobą korzyść w postaci większej stabilności i wiarygodności wyników.
2. Każdy test powinien sprawdzać jeden warunek
Przyjrzyjmy się kolejnemu przykładowi – znów wyobraźmy sobie, że testujemy ścieżkę zakupową, czyli wyszukanie produktu, dodanie go do koszyka, wykorzystanie kodu rabatowego, przejście do płatności.
const { test, expect } = require('@playwright/test');
test('test of adding products', async ({ page }) => {
// wyszukaj produkt XYZ
// asercja - sprawdź, czy produkt został wyszukany
// dodaj produkt XYZ do koszyka
// asercja - sprawdz, czy produkt zostal dodany
// w koszyku dodaj kupon rabatowy
// asercja - sprawdz, czy kupon rabatowy obnizyl cene produktu
// przejdz do platnosci
// asercja - sprawdz, czy użytkownik znalazł się na stronie płatności
});
Powyższy test byłby niepoprawny. Jest tu za dużo warunków do sprawdzenia – co jeśli produkt zostanie dodany do koszyka, ale nie uda się użyć kodu rabatowego? Test zostanie oznaczony jako nieudany – niezależnie od tego, przy której asercji pojawi się błąd.
Jak w takim razie powinno to wyglądać? Idealnie byłoby stworzyć kilka osobnych testów – jeden do sprawdzenia wyszukiwania, drugi do przetestowania dodawania do koszyka i tak dalej. W ten sposób każdy z tych testów sprawdza jedną wyodrębnioną rzecz. Czy jesteśmy ograniczeni do tylko jednej asercji na końcu testu? Nie, ale dobrą praktyką jest nieużywanie więcej niż 3-4.
Zalecenie mówiące o tym, że każdy test powinien sprawdzać tylko jeden warunek, opiera się na zasadzie pojedynczej odpowiedzialności (Single Responsibility Principle, SRP). Zgodnie z tą zasadą, w kontekście pisania testów automatycznych, każdy test powinien skupiać się na jednym konkretnym aspekcie lub warunku systemu. Istnieje kilka powodów, dla których taka praktyka jest zalecana; jest to łatwiejsze debugowanie, większa przejrzystość łatwiejsze utrzymanie testów i ich większa niezależność.
Warto jednak podkreślić, że SRP to bardziej zalecenie niż surowa reguła. W praktyce można spotkać się z testami, które sprawdzają kilka powiązanych ze sobą warunków, zwłaszcza gdy są one silnie ze sobą związane i trudno je rozdzielić. W takich przypadkach kluczowe jest zachowanie umiaru.
3. Testy nie istnieją bez asercji
Nie pisz testów, które kończą się akcją (takich jak poniżej):
test('playwright can search products', async ({ page }) => {
await page.goto('https://playground.quality-frontiers.pl/');
await page.getByPlaceholder('Search product...').click();
await page.getByPlaceholder('Search product...').fill('Shirt');
await page.getByPlaceholder('Search product...').press('Enter');
});
Zawsze dodawaj asercje po zakończeniu kroku testowego:
test('playwright can search products', async ({ page }) => {
await page.goto('https://playground.quality-frontiers.pl/');
await page.getByPlaceholder('Search product...').click();
await page.getByPlaceholder('Search product...').fill('Shirt');
await page.getByPlaceholder('Search product...').press('Enter');
await expect(page.locator(".products li")).toHaveCount(4);
});
4. Wybieraj selektory, które są odporne na zmiany w UI
Pisząc swoje testy, wybieraj selektory które są jednoznaczne, nie będą czasochłonne w utrzymaniu i w miarę możliwości wytrzymają ewentualne zmiany na UI.
Odpadają selektory takie jak poniższy:
/html/body/div[1]/main/div[1]/div/div/div/div[2]/div[2]/img
Powyższy selektor przestanie działać przy jakiejkolwiek zmianie w strukturze HTML strony.
Wystrzegaj się także selektorów zbyt ogólnych:
//img
5. Każda akcja powinna kończyć się oczekiwaniem
Dobrą praktyką jest kończenie każdej akcji asercją sprawdzającą, czy poprzednia akcja już się zakończyła i czy następny krok może być wykonany, np.
test("should open the side menu", async () => {
await page.locator('.button').click();
await page.locator('.side-menu').waitFor({ state: 'visible' });
});
6. Korzystaj z wzorca Page Object Model
Wzorzec Page Object Model (POM) w testach automatycznych polega na reprezentowaniu każdej strony lub komponentu interfejsu użytkownika za pomocą odpowiadającego mu obiektu, zwanej Page Object. Każdy Page Object zawiera metody, które odpowiadają konkretnym akcjom i operacjom wykonywanym na danej stronie. Ten wzorzec zwiększa czytelność, ułatwia utrzymanie testów poprzez modularyzację kodu oraz zapewnia łatwiejszą adaptację do zmian w interfejsie użytkownika poprzez izolowanie logiki dostępu do elementów strony.
Dokumentacja dotycząca POM znajduje się tutaj.
7. Korzystaj z hooków before i after
W automatyzacji często musimy wykonać akcje przygotowawcze przed samym testem, np. autoryzacja do aplikacji:
test("should find one record in the dashboard", async () => {
await homePage.logIn();
await dashboardPage.searchRecord(recordName);
await expect(page.locator(".record")).toHaveCount(1);
closePlaywright();
});
Powyższe podejście nie jest poprawne. W sekcji „test” powinien znajdować się tylko jeden przypadek testowy, bez zbędnych akcji które potencjalnie mogą zafałszować wynik testu – takich jak logowanie.
W takich przypadkach wykorzystaj hooki, takie jak before:
test.beforeEach(async ({ page }) => {
await homePage.logIn();
});
test("should find one record in the dashboard", async () => {
await dashboardPage.searchRecord(recordName);
await expect(page.locator(".record")).toHaveCount(1);
});
test.afterEach(async ({ page }) => {
closePlaywright();
});
8. Psuj testy!
Twoim celem nie jest napisanie testów, które zawsze będą świecić się na zielono, lecz testów, które realnie sprawdzą aplikację na podstawie jasno określonych przypadków i scenariuszy testowych. Co to oznacza?
Czasem nie zauważamy, że nasz test przechodzi… zawsze 🙂 najczęściej są to błędy podczas wyznaczania warunków w asercjach. Po napisaniu każdego testu, zepsuj go, na przykład wpisując w asercji wartość, która musi zwrócić błąd. Obserwuj wyniki testów i reaguj, jeśli efekt będzie inny od oczekiwanego.
9. Nie komentuj testów
Jeśli chcesz, by runner ominął jeden test (który np. wymaga naprawy i na razie nie powinien być uruchamiany) nie otaczaj go komentarzem, tak jak poniżej:
test("test which will be executed", async () => {
// some test
});
// test("test will be ignored", async () => {
// some test
// });
Playwright w swoim API zapewnia prostą możliwość oznaczenia testu jako ignorowany:
test('skipped test', async ({ page }) => {
test.skip();
// ...
});
W powyższym przypadku, test oznaczony metodą test.skip()
nie uruchomi się. Dokumentację tej funkcjonalności można znaleźć tutaj.
10. Sprzątaj po sobie
Po zakończeniu testu przywracaj aplikację do pierwotnego stanu, jeżeli test wprowadzał jakiekolwiek zmiany – czyli usuwaj obiekty które tworzy test. To zapobiegnie problemom, takim jak zanieczyszczenie danych testowych i utrzymanie spójności środowiska testowego.
Istnieją sytuacje, w których sprzątanie po testach może nie być konieczne lub może istnieć wyjątek od tego zasady. Jednak decyzja o pozostawieniu pewnych śladów po testach powinna być świadoma i uzasadniona. Może to być na przykład:
- potrzeba debugowania błędu, które wymaga obiektu utworzonego przez testy
- potrzeba wygenerowania dużej liczby danych np. w celu sprawdzenia jak aplikacja będzie się wtedy zachowywać