Mar 8, 2017

Make selenium wait for -anything you want- before continuing

Yesterday I wrote my first set of tests with Selenium in Django. It was relatively easy, except for when I realized that Selenium has no concept of page loads. It just executes one command after another, and it will eventually fail when it doesn’t find the element you need.

I specifically needed for Selenium to wait until a page load and an Ajax call finished, both fired with a click, before continuing. I couldn’t find an easy answer to my problem, so decided to write my own.

My most common use case is to wait after a click event for an element to exist or to disappear before continuing, this is the full solution. Keep reading for an explanation.

"""My selenium tests with waiting capabilities."""

from importlib import import_module

from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.core.urlresolvers import reverse
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.support.ui import WebDriverWait

# Per environment Driver:
WebDriver = getattr(import_module('selenium.webdriver'), settings.SELENIUM_DRIVER)


class MyColletionOfTests(StaticLiveServerTestCase):
    """Tests class."""

    @classmethod
    def setUpClass(cls):
        """Setup selenium instance."""
        super().setUpClass()
        cls.selenium = WebDriver()

    @classmethod
    def tearDownClass(cls):
        """Destroy selenium instance."""
        cls.selenium.quit()
        super().tearDownClass()

    class FindElements(object):
        """Evaluate if element exists "number" times."""

        def __init__(self, css_selector, number):
            """Define selector and number of times elements must exist."""
            self.css_selector = css_selector
            self.number = number

        def __call__(self, driver):
            """
            Search for element, and return true when found self.number times.

            Some WebDriver (like Safari) throw an error when no elements are
            found, but some (Firefox) return an empty list.
            Account for both options.
            """
            try:
                found = len(driver.find_elements_by_css_selector(self.css_selector))
            except NoSuchElementException as e:
                found = 0

            return found == self.number

    def setUp(self):
        """Setup initial data for tests."""
        self.user = User.objects.create_user(username='a_user', password='pass')

        # Always login user
        self.login_user()

    def login_user(self):
        """
        Login user in Selenium.

        Most of the apps I write have sessions. A user needs to be logged in
        to access the pages and run the tests.
        """
        self.selenium.get(self.live_server_url + settings.LOGIN_URL)
        # The first wait: Make sure we have a login button to continue.
        WebDriverWait(self.selenium, 10).until(self.FindElements('input[value=login]', 1))

        username_input = self.selenium.find_element_by_name("username")
        username_input.send_keys(self.user.username)
        password_input = self.selenium.find_element_by_name("password")
        password_input.send_keys('pass')
        self.selenium.find_element_by_css_selector('input[value=login]').click()

        # The second wait: wait until no login button is found, which means it
        # left the page already.
        WebDriverWait(self.selenium, 10).until(self.FindElements('input[value=login]', 0))

    def test_whatever_you_want(self):
        """Test whatever functionality you want, and use waits to be happy."""
        # Load the page you want to test.
        self.selenium.get('%s%s' % (self.live_server_url, reverse('named_route')))

        # Wait until the link/button/input you want to test exists.
        # In this case, ".your .css.selector" must eist twice before continuing.
        WebDriverWait(self.selenium, 10).until(self.FindElements('.your .css.selector', 2))
        # Click the element you are testing.
        self.selenium.find_element_by_css_selector('.your .css.selector').click()

        # And lets say it should create a new element.
        WebDriverWait(self.selenium, 10).until(self.FindElements('#my-new-element', 1))

        # The helper can also be used for assertions.
        # Lets assertthat there are no more of the ".your .css.selector" clicked
        # buttons. They were removed after the click!
        self.assertTrue(self.FindElements('.your .css.selector', 0))

Comments should help you understand what is going on. I’m going to explain my helper.

Using a lambda function for the method would constrain me in terms of readability and extensibility. Selenium has some helpers, but I didn't like them. I decided to create my own, based on how selenium does it.

First, the call:

WebDriverWait(self, driver, timeout, poll_frequency=POLL_FREQUENCY, ignored_exceptions=None).until(self, method, message=‘')

until() expects a method that will be called every poll_frequency, until it returns true or until the the timeout is exceded, so we pass it an instance of our class. This is my call:

WebDriverWait(self.selenium, 10).until(self.FindElements('#my-new-element', 1))

Using python magic methods I define a class which when instantiated receives the parameters I pass to it, and that when called, receives the driver in which to search for the elements:

    class FindElements(object):
        """Evaluate if element exists "number" times."""

        def __init__(self, css_selector, number):
            """Define selector and number of times elements must exist."""
            self.css_selector = css_selector
            self.number = number

        def __call__(self, driver):
            """
            Search for element, and return true when found self.number times.

            Some WebDriver (like Safari) throw an error when no elements are
            found, but some (Firefox) return an empty list.
            Account for both options.
            """
            try:
                found = len(driver.find_elements_by_css_selector(self.css_selector))
            except NoSuchElementException as e:
                found = 0

            return found == self.number

Firefox and Safari behave different when not finding the elements I searched for, the Safari driver raises an exception, while the Firefox driver returns an empty list, and I can account for that implementing my own logic in the helper. The class is actually simple, but can be very helpful.

Using this same structure, it is possible to have very complex or specific waiting conditions.

Do you have any other solutions?

Comments