Java >> Java tutorial >  >> Java

Ren arkitektur af selen test

I dette blogindlæg vil jeg gerne introducere en ren arkitektur for Selenium-tests med de bedste designmønstre:sideobjekt, sideelement (ofte kaldet HTML wrapper) og selvudviklet, meget lille, men smart framework. Arkitekturen er ikke begrænset til Java, som bruges i eksemplerne og kan også anvendes til Selenium-tests på ethvert andet sprog.

Definitioner og relationer.

Sideobjekt . Et sideobjekt indkapsler adfærden af ​​en webside. Der er et sideobjekt pr. webside, der abstraherer sidens logik udadtil. Det betyder, at interaktionen med websiden er indkapslet i sideobjektet. Seleniums Af locatorer til at finde elementer på siden afsløres heller ikke udadtil. Sideobjektets opkald bør ikke være optaget med  Af locatorer, såsom By.id , By.tageName , By.cssSelector osv. Selen testklasser fungerer på sideobjekter. Tag et eksempel fra en webshop:sideobjektklasserne kunne hedde f.eks. ProduktsideShoppingCartPageBetalingsside osv. Dette er altid klasser for hele websiderne med deres egne URL'er.

Sideelement (alias HTML Wrapper ). Et sideelement er en anden underopdeling af en webside. Det repræsenterer et HTML-element og indkapsler logikken for interaktionen med dette element. Jeg vil betegne et sideelement som HTML-indpakning. HTML-indpakninger kan genbruges, fordi flere sider kan inkorporere de samme elementer. For eksempel kan en HTML-indpakning til Datepicker give følgende metoder (API):"indstil en dato i indtastningsfeltet", "åbn kalender-pop op", "vælg en given dag i kalender-pop op" osv. Andre HTML-indpakninger ville være f.eks. Autofuldførelse, Breadcrumb, Checkbox, RadioButton, MultiSelect, Message, … En HTML Wrapper kan være sammensat. Det betyder, at den kan bestå af flere små elementer. For eksempel består et produktkatalog af produkter, en indkøbskurv består af varer osv. Seleniums Af locatorer for de indre elementer er indkapslet i det sammensatte sideelement.

Page Object og HTML Wrappers som designmønstre blev beskrevet af Martin Fowler.

Skeletstrukturen af ​​en selen testklasse.

En testklasse er velstruktureret. Den definerer testsekvensen i form af enkelte procestrin. Jeg foreslår følgende struktur:

public class MyTestIT extends AbstractSeleniumTest {

    @FlowOnPage(step = 1, desc = "Description for this method")
    void flowSomePage(SomePage somePage) {
        ...
    }

    @FlowOnPage(step = 2, desc = "Description for this method")
    void flowAnotherPage(AnotherPage anotherPage) {
        ...
    }

    @FlowOnPage(step = 3, desc = "Description for this method")
    void flowYetAnotherPage(YetAnotherPage yetAnotherPage) {
        ...
    }

    ...
}

Klassen MyTestIT er en JUnit testklasse til en integrationstest. @FlowOnPage er en metodeanmærkning til testlogikken på en webside. trinnet parameter definerer et serienummer i testsekvensen. Nummeringen starter med 1. Det betyder, at den kommenterede metode med trin =1 vil blive behandlet før metoden med trin =2. Den anden parameter desc står for beskrivelse, hvad metoden gør.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface FlowOnPage {

    int step() default 1;
 
    String desc();
}

Den kommenterede metode påkaldes med et sideobjekt som metodeparameter. Et skift til næste side sker normalt pr. klik på en knap eller et link. Den udviklede ramme skal sikre, at den næste side er fuldstændig indlæst, før den kommenterede metode med næste trin bliver kaldt. Det næste diagram illustrerer forholdet mellem en testklasse, sideobjekter og HTML-indpakninger.

Men stop. Hvor er JUnit-metoden kommenteret med @Test og hvor er logikken for parsing af @FlowOnPage anmærkning? Den kode er skjult i superklassen AbstractSeleniumTest .

public abstract class AbstractSeleniumTest {

    // configurable base URL
    private final String baseUrl = System.getProperty("selenium.baseUrl", "http://localhost:8080/contextRoot/");

    private final WebDriver driver;

    public AbstractSeleniumTest() {
        // create desired WebDriver
        driver = new ChromeDriver();

        // you can also set here desired capabilities and so on
        ...
    }

    /**
     * The single entry point to prepare and run test flow.
     */
    @Test
    public void testIt() throws Exception {
        LoadablePage lastPageInFlow = null;
        List <Method> methods = new ArrayList<>();

        // Seach methods annotated with FlowOnPage in this and all super classes
        Class c = this.getClass();
        while (c != null) {
            for (Method method: c.getDeclaredMethods()) {
                if (method.isAnnotationPresent(FlowOnPage.class)) {
                    FlowOnPage flowOnPage = method.getAnnotation(FlowOnPage.class);
                    // add the method at the right position
                    methods.add(flowOnPage.step() - 1, method);
                }
            }

            c = c.getSuperclass();
        }

        for (Method m: methods) {
            Class<?>[] pTypes = m.getParameterTypes();

            LoadablePage loadablePage = null;
            if (pTypes != null && pTypes.length > 0) {
                loadablePage = (LoadablePage) pTypes[0].newInstance();
            }

            if (loadablePage == null) {
                throw new IllegalArgumentException("No Page Object as parameter has been found for the method " +
                    m.getName() + ", in the class " + this.getClass().getName());
            }

            // initialize Page Objects Page-Objekte and set parent-child relationship
            loadablePage.init(this, m, lastPageInFlow);
            lastPageInFlow = loadablePage;
        }

        if (lastPageInFlow == null) {
            throw new IllegalStateException("Page Object to start the test was not found");
        }

        // start test
        lastPageInFlow.get();
    }

    /**
     * Executes the test flow logic on a given page.
     *
     * @throws AssertionError can be thrown by JUnit assertions
     */
    public void executeFlowOnPage(LoadablePage page) {
        Method m = page.getMethod();
        if (m != null) {
            // execute the method annotated with FlowOnPage
            try {
                m.setAccessible(true);
                m.invoke(this, page);
            } catch (Exception e) {
                throw new AssertionError("Method invocation " + m.getName() +
                    ", in the class " + page.getClass().getName() + ", failed", e);
            }
        }
    }

    @After
    public void tearDown() {
        // close browser
        driver.quit();
    }

    /**
     * This method is invoked by LoadablePage.
     */
    public String getUrlToGo(String path) {
        return baseUrl + path;
    }

    public WebDriver getDriver() {
        return driver;
    }
}

Som du kan se, er der kun én testmetode testIt som analyserer annoteringerne, opretter sideobjekter med relationer og starter testflowet.

Strukturen af ​​et sideobjekt.

Hver sideobjektklasse arver fra klassen LoadablePage som arver igen fra Seleniums klasse LoadableComponent. En god forklaring på LoadableComponent er tilgængelig i denne velskrevne artikel: Simpel og avanceret brug af LoadableComponent. LoadablePage er vores egen klasse, implementeret som følger:

import org.openqa.selenium.support.ui.WebDriverWait;
import org.junit.Assert;
import org.openqa.selenium.By;
import org.openqa.selenium.Keys;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedCondition;
import org.openqa.selenium.support.ui.LoadableComponent;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.util.List;

public abstract class LoadablePage<T extends LoadableComponent<T>> extends LoadableComponent<T> {

    private final static Logger LOGGER = LoggerFactory.getLogger(LoadablePage.class);

    private AbstractSeleniumTest seleniumTest;
    private String pageUrl;
    private Method method;
    private LoadablePage parent;

    /**
     * Init method (invoked by the framework).
     *
     * @param seleniumTest instance of type AbstractSeleniumTest
     * @param method to be invoked method annotated with @FlowOnPage
     * @param parent parent page of type LoadablePage
     */
    void init(AbstractSeleniumTest seleniumTest, Method method, LoadablePage parent) {
        this.seleniumTest = seleniumTest;
        this.pageUrl = seleniumTest.getUrlToGo(getUrlPath());
        this.method = method;
        this.parent = parent;

        PageFactory.initElements(getDriver(), this);
    }

    /**
     * Path of the URL without the context root for this page.
     *
     * @return String path of the URL
     */
    protected abstract String getUrlPath();

    /***
     * Specific check which has to be implemented by every page object.
     * A rudimentary check on the basis of URL is undertaken by this class.
     * This method is doing an extra check if the page has been proper loaded.
     *
     * @throws Error thrown when the check fails
     */
    protected abstract void isPageLoaded() throws Error;

    @Override
    protected void isLoaded() throws Error {
        // min. check against the page URL
        String url = getDriver().getCurrentUrl();
        Assert.assertTrue("You are not on the right page.", url.equals(pageUrl));

        // call specific check which has to be implemented on every page
        isPageLoaded();
    }

    @Override
    protected void load() {
        if (parent != null) {
            // call the logic in the parent page
            parent.get();

            // parent page has navigated to this page (via click on button or link).
            // wait until this page has been loaded.
            WebDriverWait wait = new WebDriverWait(getDriver(), 20, 250);
            wait.until(new ExpectedCondition<Boolean> () {
                @Override
                public Boolean apply(WebDriver d) {
                    try {
                        isLoaded();
                        return true;
                    } catch (AssertionError e) {
                        return false;
                    }
                }
            });
        } else {
            // Is there no parent page, the page should be navigated directly
            LOGGER.info("Browser: {}, GET {}", getDriver(), getPageUrl());
            getDriver().get(getPageUrl());
        }
    }

    /**
     * Ensure that this page has been loaded and execute the test code on the this page.
     *
     * @return T LoadablePage
     */
    public T get() {
        T loadablePage = super.get();

        // execute flow logic
        seleniumTest.executeFlowOnPage(this);

        return loadablePage;
    }

    /**
     * See {@link WebDriver#findElement(By)}
     */
    public WebElement findElement(By by) {
        return getDriver().findElement(by);
    }

    /**
     * See {@link WebDriver#findElements(By)}
     */
    public List<WebElement> findElements(By by) {
        return getDriver().findElements(by);
    }

    public WebDriver getDriver() {
        return seleniumTest.getDriver();
    }

    protected String getPageUrl() {
        return pageUrl;
    }

    Method getMethod() {
        return method;
    }
}

Som du kan se, skal hver sideobjektklasse implementere to abstrakte metoder:

/**
 * Path of the URL without the context root for this page.
 *
 * @return String path of the URL
 */
protected abstract String getUrlPath();

/***
 * Specific check which has to be implemented by every page object.
 * A rudimentary check on the basis of URL is undertaken by the super class.
 * This method is doing an extra check if the page has been proper loaded.
 *
 * @throws Error thrown when the check fails
 */
protected abstract void isPageLoaded() throws Error;

Nu vil jeg gerne vise koden til et konkret sideobjekt og en testklasse, som tester SBB Ticket Shop, så læserne kan få smag for at teste med sideobjekter. Sideobjektet TimetablePage indeholder HTML-indpakninger til grundlæggende elementer.

public class TimetablePage extends LoadablePage<TimetablePage> {

    @FindBy(id = "...")
    private Autocomplete from;
    @FindBy(id = "...")
    private Autocomplete to;
    @FindBy(id = "...")
    private Datepicker date;
    @FindBy(id = "...")
    private TimeInput time;
    @FindBy(id = "...")
    private Button search;

    @Override
    protected String getUrlPath() {
        return "pages/fahrplan/fahrplan.xhtml";
    }

    @Override
    protected void isPageLoaded() throws Error {
        try {
            assertTrue(findElement(By.id("shopForm_searchfields")).isDisplayed());
        } catch (NoSuchElementException ex) {
            throw new AssertionError();
        }
    }

    public TimetablePage typeFrom(String text) {
        from.setValue(text);
        return this;
    }

    public TimetablePage typeTo(String text) {
        to.setValue(text);
        return this;
    }

    public TimetablePage typeTime(Date date) {
        time.setValue(date);
        return this;
    }

    public TimetablePage typeDate(Date date) {
        date.setValue(date);
        return this;
    }

    public TimetablePage search() {
        search.clickAndWaitUntil().ajaxCompleted().elementVisible(By.cssSelector("..."));
        return this;
    }

    public TimetableTable getTimetableTable() {
        List<WebElement> element = findElements(By.id("..."));
        if (element.size() == 1) {
            return TimetableTable.create(element.get(0));
        }

        return null;
    }
}

I sideobjektet kan HTML-indpakninger (simpelt eller sammensat) oprettes enten ved hjælp af @FindBy@FindBys@FindAlle annoteringer eller dynamisk on demand, f.eks. som TimetableTable.create(element) hvor element er det underliggende WebElement . Normalt fungerer annoteringerne ikke med brugerdefinerede elementer. De fungerer kun med Seleniums WebElement som standard. Men det er ikke svært også at få dem til at arbejde med de tilpassede elementer. Du skal implementere en tilpasset FieldDecorator som udvider DefaultFieldDecorator. En tilpasset FieldDecorator giver mulighed for at bruge @FindBy@FindBys , eller @FindAlle annoteringer til tilpassede HTML-indpakninger. Et eksempelprojekt med implementeringsdetaljer og eksempler på tilpassede elementer er tilgængeligt her. Du kan også fange Seleniums berygtede StaleElementReferenceException i din tilpassede FieldDecorator og genskabe det underliggende WebElement af den originale locator. En framework-bruger kan derefter ikke se StaleElementReferenceException og kan kalde metoder på WebElement selv når det refererede DOM-element blev opdateret i mellemtiden (fjernet fra DOM og tilføjet et nyt indhold igen). Denne idé med kodestykke er tilgængelig her.

Men ok, lad mig vise testklassen. I testklassen vil vi teste, om der kommer et hint i indkøbskurven, når et barn under 16 år rejser uden forældre. Først og fremmest skal vi indtaste stationerne "fra" og "til", klikke på en ønsket forbindelse i køreplanen og tilføje et barn på næste side, som viser rejsetilbud for den valgte forbindelse.

public class HintTravelerIT extends AbstractSeleniumTest {

    @FlowOnPage(step = 1, desc = "Seach a connection from Bern to Zürich and click on the first 'Buy' button")
    void flowTimetable(TimetablePage timetablePage) {
        // Type from, to, date and time
        timetablePage.typeFrom("Bern").typeTo("Zürich");
        Date date = DateUtils.addDays(new Date(), 2);
        timetablePage.typeDate(date);
        timetablePage.typeTime(date);

        // search for connections
        timetablePage.search();

        // click on the first 'Buy' button
        TimetableTable table = timetablePage.getTimetableTable();
        table.clickFirstBuyButton();
    }

    @FlowOnPage(step = 2, desc = "Add a child as traveler and test the hint in the shopping cart")
    void flowOffers(OffersPage offersPage) {
        // Add a child
        DateFormat df = new SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN);
        String birthDay = df.format(DateUtils.addYears(new Date(), -10));
        offersPage.addTraveler(0, "Max", "Mustermann", birthDay);
        offersPage.saveTraveler();

        // Get hints
        List<String> hints = offersPage.getShoppingCart().getHints();

        assertNotNull(hints);
        assertTrue(hints.size() == 1);
        assertEquals("A child can only travel under adult supervision", hints.get(0));
    }
}

Strukturen af ​​en HTML-indpakning.

Jeg foreslår at oprette en abstrakt basisklasse for alle HTML-indpakninger. Lad os kalde det HtmlWrapper . Denne klasse kan give nogle almindelige metoder, såsom klikclickAndWaitUntilfindElement(er)getParentElementgetAttributevises , … For redigerbare elementer kan du oprette en klasse EditableWrapper som arver fra HtmlWrapper . Denne klasse kan give nogle almindelige metoder til redigerbare elementer, såsom ryd (rydder inputtet), enter (trykker på Enter-tasten), er Aktiveret (kontrollerer, om elementet er aktiveret), … Alle redigerbare elementer skal arve fra EditableWrapper . Derudover kan du tilbyde to grænseflader EditableSingleValue og EditableMultipleValue for henholdsvis enkelt- og flerværdielementer. Det næste diagram viser ideen. Det viser klassehierarkiet for tre grundlæggende HTML-indpakninger:

  • Datevælger . Det arver fra EditableWrapper og implementerer EditableSingleValue grænseflade.
  • MultiSelect . Det arver fra EditableWrapper og implementerer EditableMultiValue grænseflade.
  • Besked . Det udvider HtmlWrapper direkte fordi en besked ikke kan redigeres.

Ønsker du flere implementeringsdetaljer for HTML-indpakning? Oplysninger om en jQuery Datepicker kan f.eks. findes i denne fantastiske artikel. MultiSelect er en indpakning omkring den berømte Select2-widget. Jeg har implementeret indpakningen i mit projekt på følgende måde:

public class MultiSelect extends EditableWrapper implements EditableMultiValue<String> {

    protected MultiSelect(WebElement element) {
        super(element);
    }

    public static MultiSelect create(WebElement element) {
        assertNotNull(element);
        return new MultiSelect(element);
    }

    @Override
    public void clear() {
        JavascriptExecutor js = (JavascriptExecutor) getDriver();
        js.executeScript("jQuery(arguments[0]).val(null).trigger('change')", element);
    }

    public void removeValue(String...value) {
        if (value == null || value.length == 0) {
            return;
        }

        JavascriptExecutor js = (JavascriptExecutor) getDriver();
        Object selectedValues = js.executeScript("return jQuery(arguments[0]).val()", element);
        String[] curValue = convertValues(selectedValues);
        String[] newValue = ArrayUtils.removeElements(curValue, value);

        if (newValue == null || newValue.length == 0) {
            clear();
        } else {
            changeValue(newValue);
        }
    }

    public void addValue(String...value) {
        if (value == null || value.length == 0) {
            return;
        }

        JavascriptExecutor js = (JavascriptExecutor) getDriver();
        Object selectedValues = js.executeScript("return jQuery(arguments[0]).val()", element);
        String[] curValue = convertValues(selectedValues);
        String[] newValue = ArrayUtils.addAll(curValue, value);

        changeValue(newValue);
    }

    @Override
    public void setValue(String...value) {
        clear();
        if (value == null || value.length == 0) {
            return;
        }

        changeValue(value);
    }

    @Override
    public String[] getValue() {
        JavascriptExecutor js = (JavascriptExecutor) getDriver();
        Object values = js.executeScript("return jQuery(arguments[0]).val()", element);

        return convertValues(values);
    }

    private void changeValue(String...value) {
        Gson gson = new Gson();
        String jsonArray = gson.toJson(value);

        String jsCode = String.format("jQuery(arguments[0]).val(%s).trigger('change')", jsonArray);
        JavascriptExecutor js = (JavascriptExecutor) getDriver();
        js.executeScript(jsCode, element);
    }

    @SuppressWarnings("unchecked")
    private String[] convertValues(Object values) {
        if (values == null) {
            return null;
        }

        if (values.getClass().isArray()) {
            return (String[]) values;
        } else if (values instanceof List) {
            List<String> list = (List<String> ) values;
            return list.toArray(new String[list.size()]);
        } else {
            throw new WebDriverException("Unsupported value for MultiSelect: " + values.getClass());
        }
    }
}

Og et eksempel på meddelelsesimplementering for fuldstændighedens skyld:

public class Message extends HtmlWrapper {

    public enum Severity {
        INFO("info"),
        WARNING("warn"),
        ERROR("error");

        Severity(String severity) {
            this.severity = severity;
        }

        private final String severity;

        public String getSeverity() {
            return severity;
        }
    }

    protected Message(WebElement element) {
        super(element);
    }

    public static Message create(WebElement element) {
        assertNotNull(element);
        return new Message(element);
    }

    public boolean isAnyMessageExist(Severity severity) {
        List<WebElement> messages = findElements(
      By.cssSelector(".ui-messages .ui-messages-" + severity.getSeverity()));
        return messages.size() > 0;
    }

    public boolean isAnyMessageExist() {
        for (Severity severity: Severity.values()) {
            List<WebElement> messages = findElements(
       By.cssSelector(".ui-messages .ui-messages-" + severity.getSeverity()));
            if (messages.size() > 0) {
                return true;
            }
        }

        return false;
    }

    public List<String> getMessages(Severity severity) {
        List<WebElement> messages = findElements(
      By.cssSelector(".ui-messages .ui-messages-" + severity.getSeverity() + "-summary"));
        if (messages.isEmpty()) {
            return null;
        }

        List<String> text = new ArrayList<> ();
        for (WebElement element: messages) {
            text.add(element.getText());
        }

        return text;
    }
}

Meddelelsen omslutter meddelelseskomponenten i PrimeFaces.

Konklusion

Når du er færdig med at skrive sideobjekter og HTML-indpakninger, kan du læne dig tilbage og koncentrere dig om den behagelige skrivning af Selenium-tests. Del gerne dine tanker.

Java tag