WebDriverWait and Python
I’m pretty sure I don’t like the whole implicit wait thing that WebDriver introduced. Yes, it can help out with your ‘brittle’ scripts by waiting awhile before blowing up, but to me, that’s just sloppy automation. You want your synchronization to be crisp, and intentional. And for that, you use the WebDriverWait class.
The class itself takes 3 possible arguments
- A driver instance (mandatory)
- The max number of seconds to wait before blowing (mandatory)
- The polling frequency (optional, default value of 0.5 seconds)
Now part of the problem with being a consultant is that I work in [way] too many languages. And sometimes they get muddled in my brain. As was the case recently about how to write nice WebDriverWaits. Seems I have been trying to solve a PHP problem using Python… Anyways. The problem I thought I was having was that my locators (which I stash in a module level dictionary) were having to leak into my WebDriverWaits.
Don’t do this at home.
<pre lang="python">import pytest
from selenium.webdriver import Firefox
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.support.select import Select
locators = {
"number of results": "resultsInPage",
"number of results with locator": "id=resultsInPage"
}
class TestWait(object):
_timeout = 5
def setup_method(self, method):
self.driver = Firefox()
self.driver.get("http://walmart.ca")
self.driver.find_element_by_id("searchbox_new").send_keys("oil")
self.driver.find_element_by_id("searchbutton").click()
def teardown_method(self, method):
self.driver.quit()
def test_leaky(self):
w = WebDriverWait(self.driver, self._timeout)
w.until(lambda driver: driver.find_element_by_id("resultsInPage"))
s = Select(self.driver.find_element_by_id(locators["number of results"]))
t = s.first_selected_option.text
assert(t == '48')
Notice the reference to both the locators dictionary (good) and the embedded locator (bad).
With a bit of a prod from David Burns I actually wrote out some figure-out-wth-is-going-on examples. Using the same setup/teardown as before…
This first example is what you normally see in examples with locators embedded when they are needed. This is of course a fair bit of maintenance burden when (not if, when) the locator changes.
<pre lang="python">def test_ordinary(self):
w = WebDriverWait(self.driver, self._timeout)
w.until(lambda driver: driver.find_element_by_id("resultsInPage"))
s = Select(self.driver.find_element_by_id("resultsInPage"))
t = s.first_selected_option.text
assert(t == '48')
A better way to approach this is to minimize the number of times something changes by putting the locator in a variable somewhere. In this case I put it in the method itself, but at the class level likely makes more sense for this style.
<pre lang="python">def test_better(self):
_local = "resultsInPage"
w = WebDriverWait(self.driver, self._timeout)
w.until(lambda d: d.find_element_by_id(_local))
s = Select(self.driver.find_element_by_id(_local))
t = s.first_selected_option.text
assert(t == '48')
But that only solves the problem if your locator string changes. If the locator type changes you have to make a bunch more changes to your code which we should always be trying to minimize. Borrowing from some code David pointed me to you can use Python’s argument unpacking to your advantage. (The unpacking of the tuple _local is done due to the preceding *)
<pre lang="python">def test_still_better(self):
_local = (By.ID, "resultsInPage")
w = WebDriverWait(self.driver, self._timeout)
w.until(lambda d: d.find_element(*_local))
s = Select(self.driver.find_element(*_local))
t = s.first_selected_option.text
assert(t == '48')
Had I known about the unpacking trick when I started writing Page Objects and WebDriver I can see myself liking it a fair bit. And if I didn’t as a matter of course wrap WebDriver in my own bit of facade I likely use it. But I do wrap it. And included in there is usually something like this (which is lifted from Py.Saunter)
<pre lang="python">def find_element_by_locator(self, locator):
locator_type = locator[:locator.find("=")]
if locator_type == "":
raise saunter.exceptions.InvalidLocatorString(locator)
locator_value = locator[locator.find("=") + 1:]
if locator_type == 'class':
return WebElement(self.find_element_by_class_name(locator_value))
elif locator_type == 'css':
return WebElement(self.find_element_by_css_selector(locator_value))
elif locator_type == 'id':
return WebElement(self.find_element_by_id(locator_value))
elif locator_type == 'link':
return WebElement(self.find_element_by_link_text(locator_value))
elif locator_type == 'name':
return WebElement(self.find_element_by_name(locator_value))
elif locator_type == 'plink':
return WebElement(self.find_element_by_partial_link_text(locator_value))
elif locator_type == 'tag':
return WebElement(self.find_element_by_tag_name(locator_value))
elif locator_type == 'xpath':
return WebElement(self.find_element_by_xpath(locator_value))
else:
raise saunter.exceptions.InvalidLocatorString(locator)
This lets me embed the locator strategy right at the beginning of the locator string. A site that people converting to WebDriver from Se-RC will be used to seeing. And more importantly, used to writing.
<pre lang="python">def test_best(self):
w = WebDriverWait(self.driver, self._timeout)
w.until(lambda d: d.find_element_by_locator(locators["number of results with locator"]))
s = Select(self.driver.find_element_by_locator(locators["number of results with locator"]))
t = s.first_selected_option.text
assert(t == '48')
To wrap up;
- Explicit waits are most certainly your friend
- Done poorly, they can increase maintenance costs
- Consider your audience and tailor your WebDriverWait pattern to the