pages: UI tests made easy
Posted on by Valerio MorsellaUI tests appear typically at the top of the well-known test pyramid, as they are deemed expensive in many ways, whether from performance to brittleness, or the maintenance burden they present. However, the degree of their adoption by the industry has undoubtedly grown in the last years, as witnessed by the popularity of the Selenium project and its ecosystem.
We want to give our own contribution to this area with pages: an open-source Python library we use in Skyscanner’s Hotels Tribe for building our UI acceptance tests. You can find it on GitHub.
pages is a lightweight yet powerful library which helps in building robust UI tests through a clever implementation of the Page Objects pattern. pages is built around Selenium WebDriver. However, the underlying design principles could be easily adapted to other driver technologies, including mobile native apps. It provides two main features: componentization and the ability to manage asynchronous loading.
Page Object Model (POM)
POM is a design pattern that proves useful when modelling web pages (or parts of them) into software objects, and helps design their APIs. In essence, you are building a page object if the client consuming the API (e.g. your tests) interacts with the page in the same way a human would do. For instance: navigate_to_next_page(), initiate_search() are ok APIs while click_on_next_page_button(), select_number_of_rooms_dropdown() (generally) smell of bad design. The reason is that the actual interactions with the page can change with screen sizes and over time, while use-cases are often invariant.
Componentization
The first pillar of pages is the ability to build pages through composition.
Skyscanner’s search panel is an example of a component. It is present in more than one page. Therefore, we should make it a reusable component.
We can achieve this by extending the UIComponent class:
1 2 3 4 5 6 |
class SearchPanel(UiComponent): def __init__(self, driver, name, locator): super(SearchPanel).__init__(driver, name, locator) def input_query(self, query_text): self.locate().find_element_by_<locator_strategy>(<relative_locator>).send_keys(query_text) |
The UIComponent class is responsible for lazily creating the WebElement wrapped inside it. With reference to the previous example, whatever action we define inside SearchPanel, the actual evaluation of the elements inside the scope (defined by the locator parameter) happens only when locate() is invoked.
This allows us to use the component as a building block for more complex objects.
1 2 3 |
class HomePage(object): def __init__(self, driver): search_panel = SearchPanel(driver, 'search panel', [By.ID, 'js-search-controls-container']) |
I can see that you are not impressed yet. Let’s take a real challenge then.
Lots of web pages contain tables or lists of items under the hood. Quite often, those items are good candidates for a reusable component.
For instance, Skyscanner’s search results page contains a list of flights. We have three component types here, nested inside each other: the page, the list and the list item.
A user can select a flight or visualize more details. We can create a model of it like this:
1 2 3 4 5 6 7 8 9 10 11 12 |
class FlightResultsListItem(UIComponent): def __init__(self, driver, name): super(FlightResultsListItem, self).__init__(driver, name) def select(self): self.locate().find_element_by_xpath(".//a[contains(@class, 'select-action')]").click() #locate scope-wide return FlightLandingPage(self.driver) def visualize_details(self): self.locate().find_element_by_xpath(".//button[contains(@class, 'details-action')]").click() #locate scope-wide return FlightDetailsLayOverPanel(self.driver) |
Notice how the XPath locators inside these methods are relative (.//) to the scope of the component. Astute readers must have noticed there is no locator in the constructor parameters. Read on.
The results list is also a component:
1 2 3 4 5 6 7 8 9 |
class FlightResultsList(UIComponent): def __init__(self, driver): super(FlightResultsList, self) .__init__(driver, 'flight result list', [By.XPATH, "//ul[contains(@class, 'day-list')]"]) #page-wide location: there is only one list. def get_items(self): return [FlightResultsListItem(self.driver, 'item #{0}'.format(index)).from_web_element(item) for index, item in enumerate(self.driver.find_elements(by=By.XPATH, value='./li')) ] |
FlightResultsListItem objects are created inside a list comprehension in the get_items() method. Notice that, instead of a locator, we pass a WebElement via from_web_element(): this is another option UiComponent provides.
Now we can use this class in an actual page.
1 2 3 4 5 6 7 8 9 10 |
class FlightsSearchResultPage(object): def __init__(self, driver): self.driver = driver def navigate_to_first_flight_details(self): return FlightResultsList(self.driver).get_items()[0].visualize_details() # ...and this is how we use it # assuming driver has been created and Flight search result page is already loaded flight_details = FlightsSearchResultPage(driver).navigate_to_first_flight_details() |
As this is a very common case, pages provides the Table component, which would make the previous implementation much more compact.
Notice how FlightResultsList is evaluated every time, in order to avoid StaleElementReferenceException.
In summary, we have built our page object by assembling together a number of smaller components. They are all separated and we can make them reusable in other parts of the project.
Asynchronous loading
Another common problem when implementing UI tests is waiting for pages or components to be fully loaded. For instance, waiting for the DOM readyState to become complete is generally a bad approach since scripts and AJAX requests might still be in progress.
Normally, the solution involves adding explicit waits after loading.
1 2 3 4 5 6 |
driver.get(A_URL) WebDriverWait(driver, 10).until(FIRST_CONDITION) WebDriverWait(driver, 10).until(SECOND_CONDITION) #… WebDriverWait(driver, 10).until(NTH_CONDITION) |
If we wanted to create a Page Object out of this snippet, we could write something like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class APage(object): def __init_(self, driver): self.driver = driver def load(self): self.driver.get(URL) return self def wait_until_loaded(self): WebDriverWait(driver, 10).until(FIRST_CONDITION) WebDriverWait(driver, 10).until(SECOND_CONDITION) #... WebDriverWait(driver, 10).until(NTH_CONDITION) return self page = APage(driver_instance).load().wait_until_loaded() |
pages provides a framework to reduce the amount of boilerplate implied by the approach above. Moreover, it solves some other common issues, such as having to handle StaleElementReferenceException.
Rewriting the example above using the Page class from pages, we have:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class APage(Page): def __init_(self, driver): self.driver = driver self.add_trait(FIRST_CONDITION, ‘first condition to hold’) self.add_trait(SECOND_CONDITION, ‘second condition to hold’) #... self.add_trait(NTH_CONDITION, ‘nth condition to hold’) def load(self): self.driver.get(URL) return self page = APage(driver_instance).load().wait_until_loaded() |
The ElementWithTraits base class already provides a wait_until_loaded() method, which checks one by one all the provided conditions. In the project jargon, they are called traits. In addition to this, we can also create components with traits since UIComponent extends ElementWithTraits.
Some more examples here.
Conclusions
Finally, pages is no rocket science. It brings to you a set of simple and intuitive conventions, which enables you to start creating robust and easy-to-read UI tests with a flat learning curve.
pages has worked great for us so far. But if you have ideas on how to improve the project, receiving your feedback would be great! Also, it goes without saying that contributions on GitHub are welcome.