Clean architecture of Selenium tests
In this blog post, I would like to introduce a clean architecture for Selenium tests with best design patterns: page object, page element (often called HTML wrapper) and self-developed, very small but smart framework. The architecture is not restricted to Java which is used in the examples and can be applied to Selenium tests in any other language as well.
Definitions and relations.
Page Object. A page object encapsulates the behavior of a web page. There is one page object per web page that abstracts the page’s logic to the outside. That means, the interaction with the web page is encapsulated in the page object. Selenium’s By locators to find elements on the page are not disclosed to the outside as well. The page object’s caller should not be busy with the By locators, such as By.id, By.tageName, By.cssSelector, etc. Selenium test classes operate on page objects. Take an example from a web shop: the page object classes could be called e.g. ProductPage, ShoppingCartPage, PaymentPage, etc. These are always classes for the whole web pages with their own URLs.
Page Element (aka HTML Wrapper). A page element is another subdivision of a web page. It represents a HTML element and encapsulates the logic for the interaction with this element. I will term a page element as HTML wrapper. HTML wrappers are reusable because several pages can incorporate the same elements. For instance, a HTML wrapper for Datepicker can provide the following methods (API): “set a date into the input field”, “open the calendar popup”, “choose given day in the calendar popup”, etc. Other HTML wrappes would be e.g. Autocomplete, Breadcrumb, Checkbox, RadioButton, MultiSelect, Message, … A HTML Wrapper can be composite. That means, it can consist of multiple small elements. For instance, a product catalog consists of products, a shopping cart consists of items, etc. Selenium’s By locators for the inner elements are encapsulated in the composite page element.
Page Object and HTML Wrappers as design patterns were described by Martin Fowler.
The skeletal structure of a Selenium test class.
A test class is well structured. It defines the test sequence in form of single process steps. I suggest the following structure:
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) { ... } ... }
The class MyTestIT is an JUnit test class for an integration test. @FlowOnPage is a method annotation for the test logic on a web page. The step parameter defines a serial number in the test sequence. The numeration starts with 1. That means, the annotated method with the step = 1 will be processed before the method with the step = 2. The second parameter desc stands for description what the method is doing.
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface FlowOnPage { int step() default 1; String desc(); }
The annotated method is invoked with a page object as method parameter. A switch to the next page normally occurs per click on a button or link. The developed framework should make sure that the next page is completely loaded before the annotated method with next step gets called. The next diagram illustrates the relationship between a test class, page objects and HTML wrappers.
But stop. Where is the JUnit method annotated with @Test and where is the logic for the parsing of @FlowOnPage annotation? That code is hidden in the super class 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; } }
As you can see, there is only one test method testIt which parses the annotations, creates page objects with relations and starts the test flow.
The structure of a Page Object.
Every page object class inherits from the class LoadablePage which inherits again from the Selenium’s class LoadableComponent. A good explanation for LoadableComponent is available in this well written article: Simple and advanced usage of LoadableComponent. LoadablePage is our own class, implemented as follows:
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; } }
As you can see, every page object class needs to implement two abstract methods:
/** * 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;
Now I would like to show the code for a concrete page object and a test class which tests the SBB Ticket Shop, so that readers can acquire a taste for testing with page objects. The page object TimetablePage contains HTML wrappers for basic elements.
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; } }
In the page object, HTML wrappers (simple or composite) can be created either by the @FindBy, @FindBys, @FindAll annotations or dynamic on demand, e.g. as TimetableTable.create(element) where element is the underlying WebElement. Normally, the annotations don’t work with custom elements. They only work with the Selenium’s WebElement per default. But it is not difficult to get them working with the custom elements too. You have to implement a custom FieldDecorator which extends DefaultFieldDecorator. A custom FieldDecorator allows to use @FindBy, @FindBys, or @FindAll annotations for custom HTML wrappers. A sample project providing implementation details and examples of custom elements is available here. You can also catch the Selenium’s infamous StaleElementReferenceException in your custom FieldDecorator and recreate the underlying WebElement by the original locator. A framework user doesn’t see then StaleElementReferenceException and can call methods on WebElement even when the referenced DOM element was updated in the meantime (removed from the DOM and added with a new content again). This idea with code snippet is available here.
But ok, let me show the test class. In the test class, we want to test if a hint appears in the shopping cart when a child under 16 years travels without parents. Firts of all, we have to type the stations “from” and “to”, click on a desired connection in the timetable and add a child on the next page which shows travel offers for the choosen connection.
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)); } }
The structure of a HTML Wrapper.
I suggest to create an abstract base class for all HTML wrappers. Let’s call it HtmlWrapper. This class can provide some common methods, such as click, clickAndWaitUntil, findElement(s), getParentElement, getAttribute, isDisplayed, … For editable elements, you can create a class EditableWrapper which inherits from the HtmlWrapper. This class can provide some common methods for editable elements, such as clear (clears the input), enter (presses the enter key), isEnabled (checks if the element is enabled), … All editable elements should inherit from the EditableWrapper. Futhermore, you can provide two interfaces EditableSingleValue and EditableMultipleValue for single and multi value elements respectively. The next diagram demonstrates the idea. It shows the class hierarchy for three basic HTML wrappes:
- Datepicker. It inherits from the EditableWrapper and implements the EditableSingleValue interface.
- MultiSelect. It inherits from the EditableWrapper and implements the EditableMultiValue interface.
- Message. It extends directly the HtmlWrapper because a message is not editable.
Do you want more implementation details for HTML wrappers? Details for an jQuery Datepicker can be found for example in this great article. The MultiSelect is a wrapper around the famous Select2 widget. I have implemented the wrapper in my project in the following way:
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()); } } }
And an example of Message implementation for the sake of completeness:
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; } }
The Message wraps the Message component in PrimeFaces.
Conclusion
When you finished the writing of page objects and HTML wrappers, you can settle back and concentrate on the comfortable writing of Selenium tests. Feel free to share your thoughts.
Reference: | Clean architecture of Selenium tests from our JCG partner Oleg Varaksin at the Thoughts on software development blog. |