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.