Backend

Obiekty domenowe w testach e2e

klawiatura i mysz

W trakcie mojej kariery zawodowej spotykałem się z testami Selenium pisanymi przez różnych testerów — od tych stawiających pierwsze kroki w świecie automatyzacji aż po “starych wyjadaczy” z wieloletnim doświadczeniem. Niejednokrotnie testy te były pisane przy użyciu dobrych praktyk i wzorców programowania, często przy użyciu frameworków opracowywanych wewnątrz organizacji, co może świadczyć o dobrym poziomie technicznym testerów.

Niemalże za każdym razem spotykałem różnego rodzaju implementacje i interpretacje podstawowego dla automatyzacji wzorca Page Object. Jednak mimo to, nigdy w swojej karierze nie natrafiłem na koncepcję, której dotyczy ten artykuł — obiektów domenowych w testach.


Maciej Lorenc. Z wykształcenia fizyk, z zawodu QA. Praktyk, przedkładający czyn nad teorię. Swoją przygodę z testowaniem oprogramowania rozpoczynał w 2009 roku w Naszej Klasie. Pracował dla takich firm jak Skąpiec.pl czy Qiagen. Konsultant w wielu mniejszych firmach. Obecnie Lead Quality Assurance w Hicron. Specjalista z zakresu testów automatycznych aplikacji webowych, pasjonat dobrych praktyk programowania. Fanatyk jakości i czystego kodu. Prowadził wysoko oceniane warsztaty na konferencji Quality Excites w 2017 i 2018 roku. Prywatnie pasjonat dobrego piwa oraz piwowar domowy.


Czym jest obiekt domenowy?

Domena to obszar, dziedzina czy zbiór zagadnień, których problemy biznesowe rozwiązuje testowana aplikacja. Słownikowo jest to zbiór zainteresowań lub działalności. Zatem jeśli testujemy sklep internetowy, to domeną może być e-commerce. W przypadku aplikacji służącej do rejestrowania wizyt w warsztacie samochodowym będzie to automotive. Obiekt domenowy to reprezentacja pewnych obiektów z domeny testowanej aplikacji. Przykładowo odwzorowanie produktu czy wręcz całego koszyka sklepu internetowego (produkty, ich liczba, cena, itd.). W przypadku aplikacji do rejestracji wizyt w warsztacie samochodowym będzie to na przykład obiekt odwzorowujący klienta (właściciela samochodu), obiekt odwzorowujący samochód czy wizytę (termin wraz z powiązanymi danymi). Wszystko to zależy od tego, w jaki sposób dane są prezentowane w aplikacji.

Idea ta jest bardzo prosta, ale pozwólmy przemówić przykładom. Weźmy pod lupę sklep internetowy do nauki testów automatycznych automationpractice.com. Jeśli wejdziemy do którejś z kategorii sklepu, możemy zobaczyć widok:

Widzimy na zrzucie dwa produkty w widoku jednej z kategorii sklepu. Spróbujmy więc zidentyfikować cechy widocznego produktu. Każdy z produktów posiada nazwę oraz obraz. Poniżej widoczna jest cena, ikony symbolizujące dostępne kolory oraz wskaźnik dostępności danego produktu, czy znajduje się on obecnie w magazynie. Klasa reprezentująca ten produkt mogłaby wyglądać np. tak:

@Data
@Builder
public class Product {
    private String name;
    private float price;
    private List<Color> availableColors;
    private boolean isOnStock;
}

Kod napisałem w Java z użyciem projektu Lombok. Analogiczny kod można napisać w dowolnym języku obiektowym. Celowo pominąłem obraz produktu — rzadko kiedy będzie on w testach wykorzystywany. Czym jest klasa Color użyta w liście dostępnych kolorów? Może być to obiekt domenowy, reprezentacja danego koloru, może być to też typ enumeratywny opisujący dany kolor. Niemalże każdy, nawet początkujący tester-automatyk z pewnością może wyobrazić sobie w jaki sposób z wykorzystaniem WebDriver można pozyskać dane, aby utworzyć instancję klasy Product. Jedynym problemem może być kolor, ale to zagadnienie nie jest przedmiotem tego artykułu. Skupmy się chwilowo na samym obiekcie domenowym, a nie na sposobie jego utworzenia. Kluczowe pytanie brzmi — po co mam stosować to rozwiązanie?

Dlaczego warto stosować obiekty domenowe?

Sprawa jest wyjątkowo prosta — dla czytelności kodu oraz uproszczenia interfejsów obiektów stron (czyli — raz jeszcze — dla czytelności kodu). Zobaczmy przykład testu napisanego z użyciem obiektów domenowych:

 @Test
    public void testFilterProductsByColor() {
        categoryPage.open(Category.DRESSES);

        List<Product> productsBeforeFiltering = categoryPage.getProducts();

        categoryPage.getFilterBox().filterByColor(Color.YELLOW);

        List<Product> productsAfterFiltering = categoryPage.getProducts();

        assertThat(productsAfterFiltering.size()).isLessThanOrEqualTo(productsBeforeFiltering.size());
        for (Product product : productsAfterFiltering) {
            assertThat(product.getAvailableColors()).contains(Color.YELLOW);
        }

    }

Test ten możemy czytać ze zrozumieniem i nie musimy zastanawiać się o co w nim chodzi. Jasno i czytelnie widać w asercjach co dokładnie jest sprawdzane. Inny przykład testu z wykorzystaniem obiektów domenowych:

@Test
    public void printedChiffonDressShouldBeInDressesCategory() {

        Product printedChiffonDress = new Product();
        printedChiffonDress.setName("Printed Chiffon Dress");
        printedChiffonDress.setPrice(16.4);
        printedChiffonDress.setAvailableColors(Arrays.asList(Color.GREEN, Color.YELLOW));

        categoryPage.open(Category.DRESSES);
        List<Product> products = categoryPage.getProducts();

        assertThat(products).contains(printedChiffonDress);
    }

Oczywiście, kod ten można uprościć wykorzystując budowniczego z projektu Lombok. Ponownie widzimy, że sens testu jest jasny i czytelny. Jeden rzut oka na asercję i wszystko staje się klarowne. W przytoczonych przykładach widać także na czym polega uproszczenie interfejsu obiektu strony. Zwróćmy uwagę, że strona CategoryPage posiada metodę getProducts zwracającą właśnie listę obiektów domenowych typu Product. Nie chcę nawet wyobrażać sobie jak wyglądałby interfejs obiektu strony dający taką samą pulę informacji zwrotnych bez użycia obiektu domenowego. Rozpatrzmy samo tylko pobieranie nazw i cen poszczególnych produktów — jak mamy zaimplementować stosowne rozwiązanie? Pobierać listę wszystkich nazw, a osobno listę wszystkich cen i “składać” to jakoś w warstwie testu? Albo tworzyć osobne metody do pobierania pierwszego, drugiego czy n-tego obiektu na liście?

Innym powodem, aby stosować obiekty domenowe jest możliwość łatwego ich porównywania. Być może nie do końca świadomie, ale już skorzystaliśmy z tej możliwości tworząc asercje z użyciem metod contains. Metoda contains wykorzystuje bowiem w swej implementacji metodę equals –– metodę, która służy właśnie do porównywania obiektów. Ale odstawmy to na chwilę i rozpatrzmy przykład z bezpośrednim użyciem metody equals. Takie zastosowanie będzie miało miejsce w sytuacji, kiedy nie mamy do czynienia z całą listą obiektów prezentowanych na stronie, lecz z pojedynczym obiektem lub z listą obiektów z której wybieramy jeden obiekt i wyświetlamy jego szczegóły. Przykład związany z naszym sklepem internetowym może być następujący — ze strony kategorii, której zrzut ekranowy widzieliśmy wcześniej wybieramy jeden z produktów i przechodzimy do strony produktów.

Widoczne tutaj powinny być takie same informacje jak na stronie kategorii, zatem łatwo możemy wyobrazić sobie test to sprawdzający:

@Test
    public void checkIfProductInformationIsTheSameOnCategoryAndProductPage() {
        categoryPage.open(Category.DRESSES);
        Product productFromCategoryPage = categoryPage.getProducts().stream()
                 .findAny()
                 .orElse(null);
        categoryPage.openProduct(productFromCategoryPage);

        Product productFromProductPage = productPage.getProduct();
 
        assertThat(productFromCategoryPage).isEqualTo(productFromProductPage);
    }

Spostrzegawczy czytelnik zauważy zapewne, że inne informacje o produkcie mogą być wyświetlane w tych dwóch miejscach aplikacji. I jest to oczywiście prawda, ale nie oznacza to równocześnie, że reprezentacja obiektu domenowego musi te informacje zawierać. Należy rozważyć jakie właściwości warto zawrzeć w klasie obiektu domenowego, a jakie nie. Wszystko zależy tutaj od kontekstu — zatem od testowanej aplikacji oraz jakie testy chcemy pisać. Nie chcę w tym miejscu wchodzić w szczegóły, więc wspomnę tylko, że nie musimy zawsze korzystać tylko z metody equals, obiekty domenowe możemy poddawać standardowym zabiegom programowania obiektowego — czyli między innymi je rozszerzać.

Bardzo ważnym aspektem wykorzystywania obiektów domenowych w testach jest także niezmienność interfejsów obiektów stron w przypadku dodania dodatkowych informacji w ramach obiektu. Jeśli w naszym sklepie internetowym pojawi się możliwość ustalania cen promocyjnych dla produktów, to wystarczy te informacje zawrzeć w samym produkcie (obiekcie domenowym) i sposobie jego pozyskiwania. Kolejny przykład związany będzie ze zmianami/przebudową strony — jeśli sklep przejdzie “renowację” może okazać się, że akcja kliknięcia w konkretny produkt będzie łatwiejsza do wykonania jeśli prócz jego nazwy będziemy mieli także cenę. W przypadku “standardowego” podejścia musielibyśmy zmienić implementację metody clickProduct(String name) na clickProduct(String productName, float price) oraz poprawić wszystkie jej wywołania. Jeśli korzystamy z obiektów domenowych zmieni się tylko implementacja metody clickProduct(Product product).

Jak “wypełnić” obiekt domenowy danymi?

Obiekt domenowy jest tworzony na podstawie danych, które są wyświetlane użytkownikowi na stronie, także oczywistym jest, że należy skorzystać z interfejsu WebDriver. O ile w przypadku stron prezentujących pojedyncze obiekty domenowe nie stanowi to problemu — wykorzystujemy pobieranie tekstów z odpowiednich elementów, jeśli jest potrzeba to “przerabiamy” te dane na odpowiednie typy i tworzymy obiekt. Odrobinę trudniej może być w przypadku list produktów. Najprostszą metodą jest pobranie listy obiektów WebElement odpowiadającym poszczególnym obiektom domenowym (np. linie w tabeli czy div’y okalające obiekt w widoku siatki) i odnalezieniu w nich elementów odpowiadających za właściwości obiektu domenowego. Dalej możemy postąpić analogicznie jak we wcześniejszym przykładzie.

Istnieje także alternatywna metoda — pobieramy kod html dla elementu listy obiektów domenowych i na jego podstawie tworzymy ich reprezentację. Takie podejście ma dużą zaletę w postaci krótszego czasu wykonania, bowiem nie wywołujemy co chwilę interfejsu WebDriver do pobierania tekstu kolejnych elementów, ale przetwarzamy kod html w Javie. Różnica może być znacząca. Oczywiście wadą tego rozwiązania jest to, że możliwe staje się niedokładne odwzorowanie tego, co widzi użytkownik na stronie.

Które z rozwiązań wybrać? Jeśli tylko widocznych obiektów nie jest bardzo dużo i nie są mocno skomplikowane, to sugerowałbym korzystanie z interfejsu WebDriver zamiast html ponieważ jest on bliższy temu, co widzi użytkownik.

Przykład wart jest więcej niż słowa, zatem zobaczmy w jaki sposób pobierane są obiekty domenowe produktów ze sklepu internetowego. W przypadku obiektu strony metoda może wyglądać na przykład tak:

public List<Product> getProducts() {
        List<Product> products = new ArrayList<>();
        List<WebElement> productTileElements = getProductTileElements();
        for (WebElement productElement : productTileElements) {
            Product product = ProductBuilder.buildProductFromWebElement(productElement);
            products.add(product);
        }
        return products;
    }

Jak widać korzystam tutaj z metody buildProductFromWebElement(), której implementacja znajduje się w specjalnie utworzonej klasie, której odpowiedzialnością jest właśnie “pozyskiwanie” obiektu domenowego produktu z WebElement:

public class ProductBuilder {

    private static By nameBy = By.xpath(".//*[@itemprop='name']");
    private static By priceBy = By.xpath(".//*[contains(@class,'right-block')]//*[@itemprop='price']");
    private static By colorBy = By.className("color_pick");

    public static Product buildProductFromWebElement(WebElement productElement) {
        Product product = new Product();

        product.setName(getName(productElement));
        product.setPrice(getPrice(productElement));

        List<Color> colors = getColors(productElement);
        if (!isNull(colors) || !colors.isEmpty()) {
            product.setAvailableColors(colors);
        }

        return product;
    }

    private static float getPrice(WebElement productElement) {
        return Float.parseFloat(productElement.findElement(priceBy).getText().trim().replace("$", ""));
    }

    private static String getName(WebElement productElement) {
        return productElement.findElement(nameBy).getText();
    }

    private static List<Color> getColors(WebElement productElement) {
        List<Color> colorsList = new ArrayList<>();

        for (WebElement colorPicker : productElement.findElements(colorBy)) {
            String rgb = colorPicker.getCssValue("background-color").replace("rgb", "").replace(" ", "").trim();
            Color color = Color.getColorForRgb(rgb);
            if (!isNull(color)) {
                colorsList.add(color);
            }
        }

        return colorsList;
    }
}

Zwróćmy uwagę na skomplikowanie tego kodu. Wynika ono z konieczności “przetworzenia” danych pobranych ze strony do takiego formatu, jaki jest pożądany w obiekcie domenowym. Stopień skomplikowania rośnie oczywiście wraz z liczbą specyficznych elementów jakie są dostępne na stronie. Spójrzmy na zrzut ekranowy:

Jak widać, nasz produkt może posiadać więcej właściwości — zamiast ceny mamy w tej chwili zwykłą ceną poza promocją, cenę promocyjną oraz wysokość zniżki. Wówczas kod będzie znacznie bardziej skomplikowany:

public class ProductBuilder {

    private static By nameBy = By.xpath(".//*[@itemprop='name']");
    private static By priceBy = By.xpath(".//*[contains(@class,'right-block')]//*[@itemprop='price']");
    private static By fullPriceBy = By.cssSelector(".right-block .old-price");
    private static By discountBy = By.cssSelector(".right-block .price-percent-reduction");
    private static By colorBy = By.className("color_pick");

    public static Product buildProductFromWebElement(WebElement productElement) {
        Product product = new Product();

        product.setName(getName(productElement));
        product.setPrice(getPrice(productElement));
        Float fullPrice = getFullPrice(productElement);
        product.setFullPrice(fullPrice);

        Integer discount = getDiscount(productElement);
        product.setDiscount(discount);

        List<Color> colors = getColors(productElement);
        if (!isNull(colors) || !colors.isEmpty()) {
            product.setAvailableColors(colors);
        }

        return product;
    }

    private static Integer getDiscount(WebElement productElement) {
        try {
            String discountString = productElement.findElement(discountBy).getText();
            if (discountString.isEmpty())
                return null;
            return Integer.parseInt(discountString.trim().replace("%", "").replace("-", ""));
        } catch (NoSuchElementException exception) {
            return null;
        }
    }

    private static float getPrice(WebElement productElement) {
        return Float.parseFloat(productElement.findElement(priceBy).getText().trim().replace("$", ""));
    }

    private static Float getFullPrice(WebElement productElement) {
        try {
            WebElement fullPriceElement = productElement.findElement(fullPriceBy);
            String fullPriceString = fullPriceElement.getText();
            if (fullPriceString.isEmpty())
                return null;
            return Float.parseFloat(fullPriceString.trim().replace("$", ""));
        } catch (NoSuchElementException exception) {
            return null;
        }
    }

    private static String getName(WebElement productElement) {
        return productElement.findElement(nameBy).getText();
    }

    private static List<Color> getColors(WebElement productElement) {
        List<Color> colorsList = new ArrayList<>();

        for (WebElement colorPicker : productElement.findElements(colorBy)) {
            String rgb = colorPicker.getCssValue("background-color").replace("rgb", "").replace(" ", "").trim();
            Color color = Color.getColorForRgb(rgb);
            if (!isNull(color)) {
                colorsList.add(color);
            }
        }

        return colorsList;
    }
}

Oczywiście, zazwyczaj nie będzie potrzeby tworzenia aż tak rozbudowanych operacji. Zazwyczaj jednak obiekty domenowe będą składać się z prostych tekstów, liczb czy różnego rodzaju indykatorów.

Nie ma róży bez kolców — wady wykorzystywania obiektów domenowych

Pierwszą z wad omawianego rozwiązania może być zwiększenie czasu wykonywania się testów. Może bowiem okazać się, że często pobieramy ze strony, za pomocą interfejsu WebDriver’a, znacznie więcej informacji niż w danym momencie potrzebujemy. Przykładowo — pobieramy listę widocznych na stronie produktów, aby wybrać z niej jeden, w który chcemy kliknąć. Z biznesowego punktu widzenia taki scenariusz jest sensowny, natomiast patrząc z punktu widzenia technicznego nie potrzebujemy pobierać informacji o cenie, dostępnych kolorach itp. lecz np. tylko nazwę jednego z dostępnych produktów. Ten przykład pokazuje jak dużo nadmiarowych informacji możemy pobrać ze strony, a wiadomo, że każde wywołanie metody z interfejsu WebDriver jest czasochłonne.

Rozwiązaniem tego problemu może być pobieranie obiektów domenowych na podstawie html, o czym pisałem wcześniej. To rozwiązanie jest szybkie, ale, niestety, posiada inne wady, o których już wspominałem.

Kolejną wadą, która została wymieniona już “między wierszami” jest możliwość konieczności przygotowania rozbudowanego, czasem także skomplikowanego kodu do pobierania obiektów ze strony. Jednak jest to koszt, który płacimy za uproszczenie interfejsu obiektów stron czy wręcz uproszczenie warstwy samego testu. Nie zapominajmy też, że często ten rozbudowany czy skomplikowany kod i tak pojawiłby się w naszym projekcie — jednak rozproszony po różnych metodach obiektu strony.

O następnym problemie, z którym możemy się spotkać także wspominałem już wcześniej — obiekt domenowy na jednym widoku (stronie) aplikacji może mieć trochę inny zestaw informacji niż na innym widoku. Można sobie z tym radzić na różne sposoby, jednak trzeba przyznać, że burzy to nieco ideę prostoty tego rozwiązania. W konsekwencji uciążliwym możeby być porównywanie ze sobą takich obiektów domenowych.

Czy warto stosować obiekty domenowe w testach?

Moim zdaniem tak. Staram się je zawsze implementować w moich projektach. Należy sobie jednak zadać kluczowe pytanie — czy nasze testy są bardziej “interfejsowe” czy “biznesowe” — tj. czy sprawdzają procesy biznesowe zaimplementowane w testowanej aplikacji, czy może sprawdzają występowanie poszczególnych elementów na stronie. Przykładem testu biznesowego będzie test sprawdzający możliwość zakupienia przedmiotu — od dodania go do koszyka poprzez przejście procesu płatności. W ten sposób test pokrywa pewną funkcjonalność biznesową, która ma sens dla użytkownika końcowego. Inne przykłady to możliwość założenia konta w sklepie internetowym, sprawdzenie wyszukiwania z użyciem wielu parametrów wyszukiwania.

Trudno także nie wspomnieć, że pisanie testów w sposób “biznesowy” jest bliższe koncepcji piramidy testów oraz że testów interfejsowych powinno się unikać. Zatem kontekst jest zawsze bardzo ważnym czynnikiem wpływającym na podjęcie decyzji. Jeśli mamy testy “interfejsowe”, to obiekty domenowe mogą nam się zupełnie do niczego nie przydać. Jeśli zaś piszemy testy “biznesowe”, zgodnie z piramidą testów, to obiekty domenowe mogą się nam przydać i ułatwić pracę.

W swojej karierze wprowadziłem testy domenowe w wielu projektach i tylko jeden raz żałowałem swojej decyzji. Myślę, że warto ten przykład przytoczyć. Jakiś czas temu, wraz z zespołem pracowaliśmy nad aplikacją, w której skład wchodziła aplikacja frontendowa budowana w Angularze oraz aplikacja backendowa pisana za pomocą Spring Boot. Po krótkich dyskusjach stwierdziliśmy wraz z drugim testerem, że skorzystamy z dobrodziejstw Protractora do naszych testów end-to-end. Początkowo, kiedy nasze obiekty domenowe nie były jeszcze zbyt skomplikowane, wszystko dobrze działało. Jednak kiedy skomplikowanie obiektów domenowych rosło, szybko okazało się, że pisanie kodu obiektów domenowych jest wręcz utrapieniem. Wynikało to z faktu, że Protraktor posługuje się promisami — zamiast tekstu z WebElementu otrzymujemy obietnicę, że ten tekst dostaniemy przy rozwiązywaniu promisy. Często trzeba było rozwiązać promisę, aby uzyskać tekst czy inne informacje do dalszej obróbki.

Kiedy doszliśmy do trzeciego poziomu zagłębienia w rozwiązywaniu promis szybko wycofaliśmy się z Protractora i przeszliśmy na rozwiązanie oparte na Java. Być może to nasza wiedza (nie jestem specjalistą od Java Script czy Type Script), a może kształt frameworka, w którym przyszło nam pracować spowodował niepowodzenie implementacji obiektów domenowych — jeśli ktoś z czytelników wie, jak zrobić to dobrze — chętnie wymienię się z nim doświadczeniem.

Kod

Kod użyty w artykule jest dostępny na githubie autora. Zachęcam do głębszego zapoznania się z nim: https://github.com/maclor/stdbp.git. Uwaga: jest to kod poglądowy — niekoniecznie wszystko działa 🙂 Korzystasz na własną odpowiedzialność 🙂


Zainteresowanych tematem testów i QA zapraszamy do przeczytania tekstu pt. Jak wprowadzić testy automatyczne i nie zwariować. Poznaj Kakunina. Artykuł przygotował developer z The Software House, który opowiedział o tym, jak na własne potrzebny stworzyli framework od podstaw, który dosłownie łączy najlepsze istniejące narzędzia do testowania.

najwięcej ofert html

Zdjęcie główne artykułu pochodzi z pexels.com.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://justjoin.it/blog/obiekty-domenowe-testach-e2e" order_type="social" width="100%" count_of_comments="8" ]