Better Testing with Page Object Design in Django

Page Object Design Pattern

Published:
Last modified:
Tags: django, testing

Overview

A Page Object Model is a design pattern that helps to write better functional tests in a Django project, by encapsulating web page elements in Page objects, and avoiding dealing with the browser driver in your tests at all.

Following this design, each webpage of your application would have a corresponding Page Object class that it is used to interact with from your tests scripts. A tipical operation would be to find an element and perform an operation on it, e.g.: find a text box and fill it with sendKeys.

graph TB page[Login Page Object Model] page-->t1 page-->t2 subgraph test_login.py t1[test_login_common_user] t2[test_login_denies_access] end subgraph Pages page-->a[login.html template] end

Idea

When doing functional tests with tools like Selenium, you have an API to handle a browser and end up mixing a lot of code to locate UI elements in pages to be able to test some behaviour on them.

To have cleaner code, we can use Page Object models to represent each page of our app as an object thus helping us to:

  • use DRY (Don’t repeat yourself) principle to minimize code duplication
    • Have all ID-locators in one place
  • Set an interface between web page’s elements and tests.
  • Avoid using WebDriver APIs in test methods
  • Encapsulate the services of web pages, not only exposing their elements.

With the Page Object model approach, we focus in setting a wrapper of web pages’ actions, and focus in writing tests instead of always deal with HTML elements.

The elements and actions we should expose in a Page object should be restricted only to useful elements to a test, they are not meant to be a wrapper of all the Webpages’ elements like headers or footers.

Accessors

Page Object should encapsulate web pages elements like check-boxes, text-boxes, buttons, and links, by having accessor methods (getters and setters).

It acts as a Repository of web User Interface elements relevant to your app.

The rule of thumb is to model the structure in the page that makes sense to the user of the application.

Actions

When we are writing functional tests we need to simulate many actions that would be repeated in different tests.

Base Page

We can also have a Base Page Object class BasePage to expose actions used in all pages like

  • getting page title
  • looking for elements by name and ids
  • visit that page, etc.

Then all other pages would inherit these attributes and methods.

Using

To navigate between pages, a Page Object should return another Page Object representing the other web page.

page object operations should return fundamental types (strings, dates) or other page objects.

Assertions

Where to put assertions? Assertions may be located inside Page Objects or in tests.

Assertions in page objects helps minimize duplication of assertions in tests.

On the other hand, Page Objects without assertions helps to have a clear separation of concerns, Page Objects shouldn’t be responsible of making assertions, only provide access to web page elements.

Example

For example to test a login page that it is using allauth package, we can have pagemodelobjects/page.py:

class BasePage(object):
    url = None

    def __init__(self, driver, live_server_url):
        self.driver = driver
        self.live_server_url = live_server_url

    def fill_form_by_css(self, form_css, value):
        elem = self.driver.find(form_css)
        elem.send_keys(value)

    def fill_form_by_id(self, form_element_id, value):
        elem = self.driver.find_element_by_id(form_element_id)
        elem.send_keys(value)

    def fill_form_by_name(self, name, value):
        elem = self.driver.find_element_by_name(name)
        elem.send_keys(value)

    @property
    def title(self):
        return self.driver.title
    
    def navigate(self):
        self.driver.get(
			"{}{}".format(
				self.live_server_url,
				self.url
			)
        )

Then for the Login page we would have:

class LoginPage(BasePage):
    USERNAME_NAME = "login"
    PASSWORD_NAME = "password"
    FORM_SUBMIT_TEXT = 'Sign In'
    url = "/accounts/login/"
    ERRORS_CLASS = 'errorlist'

    def set_username(self, username):
        self.fill_form_by_name(self.USERNAME_NAME, username)

    def set_password(self, password):
        self.fill_form_by_name(self.PASSWORD_NAME, password)

    def getSignupForm(self):
        return SignupPage(self.driver, self.live_server_url)

    def get_errors(self):
        return self.driver.find_element_by_class_name(self.ERRORS_CLASS)

    def submit(self):
        btn = self.driver.find_element(By.XPATH, '//button[text()="{}"]'.format(self.FORM_SUBMIT_TEXT))
        btn.click()
        return ProfilePage(self.driver, self.live_server_url)

    def submitExpectingFailure(self):
        btn = self.driver.find_element(By.XPATH, '//button[text()="{}"]'.format(self.FORM_SUBMIT_TEXT))
        btn.click()
        return LoginPage(self.driver, self.live_server_url)

class ProfilePage(BasePage):
    pass

class Homepage(BasePage):
    url = "/"
    def getLoginForm(self):
        return LoginPage(self.driver, self.live_server_url)

And using them from a test tests/test_login.py:

from django.test.utils import override_settings

from tests.functional_tests.base import FunctionalTest
from pagemodelobjects.pages import Homepage

from users.factories import CustomUserFactory

@override_settings(
    ACCOUNT_EMAIL_VERIFICATION='none',
)
class LoginTests(FunctionalTest):
    """ 
    Loads the index webpage only once and use it through all testcases
    """

    def setUp(self):
        pass
        

    def test_login_common_user(self):
        # creates an user to log in
        password = "hello"
        user = CustomUserFactory.create(password=password)
        homepage = Homepage(self.browser, self.live_server_url)
        homepage.navigate()
        login_page = homepage.getLoginForm()
        login_page.navigate()
        login_page.set_username(user.username)
        login_page.set_password(password)
        profile_page = login_page.submit()
        self.assertIn(user.username, profile_page.title)

    def test_login_denies_access(self):
        homepage = Homepage(self.browser, self.live_server_url)
        homepage.navigate()
        login_page = homepage.getLoginForm()
        login_page.navigate()
        login_page.set_username("aaa")
        login_page.set_password("foo")
        login_page = login_page.submitExpectingFailure()
        self.assertIn("not correct", login_page.get_errors().text)

With a place where we set up the browser driver tests/functional_tests/base.py:

from django.contrib.staticfiles.testing import StaticLiveServerTestCase, LiveServerTestCase
from selenium.webdriver.firefox.webdriver import WebDriver

class FunctionalTest(StaticLiveServerTestCase):
    """
    Launches a live Django server in the background on setup, and
    shuts it down on teardown. This allows the use of automated test
    clients other than the Django dummy client such as, for example,
    the Selenium client, to execute a series of functional tests
    inside a browser and simulate a real user’s actions.

    We’ll use the StaticLiveServerTestCase subclass with serves static
    files during the execution of tests similar to what we get at
    development time with DEBUG=True, i.e. without having to collect
    them using collectstatic.
    """
    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.browser = WebDriver()
        cls.browser.implicitly_wait(10)

    @classmethod
    def tearDownClass(cls):
        cls.browser.quit()
        super().tearDownClass()

Just adjust CustomUserFactory to yours and you can have it a try.

I have found this approach improved my tests by making them more readable and reusable.

References

Uruguay
Marcelo Canina
I'm Marcelo Canina, a developer from Uruguay. I build websites and web-based applications from the ground up and share what I learn here.
comments powered by Disqus


Except as otherwise noted, the content of this page is licensed under CC BY-NC-ND 4.0 . Terms and Policy.

Powered by SimpleIT Hugo Theme

·