This post was written specifically for my talk at the San Francisco Selenium Meetup. It covers my implementation of a Selenium testing framework from the ground up. It includes example code, in addition to a number of links that were useful when developing my framework. There is also a companion GitHub repository named basic_selenium_framework.
Design
When you're building a framework, it's important to think about how it's going to be used and who is going to be using it. Here were my basic requirements:
- Tests should be easy to write
- Tests should be easy to debug
- Tests shouldn't take forever (concurrency)
- Tests should integrate well with developer workflow (all of the above, plus the ability to run locally)
- Tests should integrate with existing tools (like Jenkins)
Implementation
I chose to write my framework in Python. It's a great language for building tools no matter what your application is written in or what your developers know. Some of the reasons I love Python:
- Great libraries
- Dynamic typing
- Minimal boilerplate
A basic suite
One of the libraries I have used for a number of testing projects is nose. It is the foundation of my testing framework and offers:
- Fixtures (setup and teardown)
- Test collection and discovery
- Built-in multiprocessing support
- xUnit report output
First, let's install the dependencies below. You can use a virtualenv (preferred), but you can also install the packages system-wide.
nose
selenium
simplejson
pyvirtualdisplay
(optional)
Open your favorite editor, and let's create an abstract test case with fixtures we can use for all test cases:
from selenium import webdriver
class SeleniumTestCase(object):
def setup(self):
self.driver = webdriver.Firefox()
self.driver.implicitly_wait(10)
def teardown(self):
self.driver.quit()
Fixtures are wrappers around tests that are run before and after the test. In the above example, we create a new browser for each test and quit the browser when the test has completed. Even if the test fails, the teardown will still run.
The Firefox driver is great because it requires no external dependencies on any platform.
The Firefox WebDriver binary is included in Python's Selenium module.
Chrome can be used with self.driver = webdriver.Chrome()
, but you will need to have
the ChromeDriver binary in your path.
It's important to include the implicit wait configuration due to the nature of the JavaScript and browser interaction. JavaScript can modify the DOM even after the page is loaded from the browser's perspective. Implicit waits ensure that Selenium will look for the element for a number of seconds after the DOM is loaded initially.
Let's write our first test:
class TestBasic(SeleniumTestCase):
def test_search(self):
self.driver.get('http://duckduckgo.com')
search_box = self.driver.find_element_by_name('q')
search_box.send_keys('Selenium')
search_box.submit()
results = self.driver.find_element_by_id('links')
assert 'Selenium' in self.driver.title
assert 'Selenium' in results.text
This test searches DuckDuckGo for 'Selenium' and checks that the browser title and displayed results contain the search term. Easy.
If you're wondering how I came up with the correct name
and id
, I prefer to use Chrome's built-in Developer Tools.
Just right click anywhere on a page and select 'Inspect Element'. If you're wondering about methods like
find_element_by_id()
, I highly recommend the
unofficial Selenium with Python
documentation by Baiju Muthukadan. It is far better than the
official API documentation.
Save the abstract test case and first test in a file called test_selenium.py
and run it by invoking nosetests
in
the same directory (with the verbose flag so the name of the test is displayed as it is run):
$ ls
test_selenium.py
$ nosetests -v
test_selenium.TestBasic.test_search ... ok
----------------------------------------------------------------------
Ran 1 test in 5.122s
OK
One of my favorite features of nose
is the ability to use tests generators. Using generators we can run the
same test multiple times using different parameters, and have the results properly output.
Let's refactor our first test in a new class and include a few more test cases with different parameters. We can do this with just three additional lines of code:
class TestGenerator(SeleniumTestCase):
def test_search(self):
for search_term in ['Python', 'Selenium', 'San Francisco', 'Sauce Labs']:
yield self.verify_search, search_term
def verify_search(self, search_term):
self.driver.get('http://duckduckgo.com')
search_box = self.driver.find_element_by_name('q')
search_box.send_keys(search_term)
search_box.submit()
results = self.driver.find_element_by_id('links')
assert search_term in self.driver.title
assert search_term in results.text
Save the file and run again.
$ nosetests -v
test_selenium.TestBasic.test_search ... ok
test_selenium.TestGenerator.test_search('Python',) ... ok
test_selenium.TestGenerator.test_search('Selenium',) ... ok
test_selenium.TestGenerator.test_search('San Francisco',) ... ok
test_selenium.TestGenerator.test_search('Sauce Labs',) ... ok
----------------------------------------------------------------------
Ran 5 tests in 57.678s
The yield
keyword invokes the designated method with the given parameter(s) and the appropriate fixtures, and reports
the output as if individual tests had been run.
Configuration
One of the most common cases where configuration helps is with URLs. If you want to run the suite against multiple endpoints (localhost, staging, production, etc), it is useful to keep the base netloc in a config file and have a method to create URLs from a path.
Let's add some features to our abstract test case:
from selenium import webdriver
import simplejson as json
import urlparse
_multiprocess_shared_ = True
def setup():
config_json = """
{
"endpoint": "http://duckduckgo.com",
"timeout": 10
}
"""
global config
config = json.loads(config_json)
class SeleniumTestCase(object):
def setup(self):
self.driver = webdriver.Firefox()
self.driver.implicitly_wait(config['timeout'])
def teardown(self):
self.driver.quit()
def get_path(self, path):
url = urlparse.urljoin(config['endpoint'], path)
self.driver.get(url)
Note the use of a module level fixture for our setup. We don't need to create a new configuration dictionary
for every single test. nose
supports fixtures at the package, module, class, and test level.
_multiprocess_shared_
allows multiple processes to share the same module fixture.
I chose JSON because it parses directly into a Python dictionary and it can be generated by something as simple as echoing text to a file. It's easy to see how we could get the JSON above from an external file. See testcases.py for an example of external configuration.
In the example tests, replace self.driver.get('http://duckduckgo.com')
with self.get_path('/')
. The tests use the
endpoint in the config in combination with the requested path. In this case we are just requesting the root path of the
endpoint, but it also accepts paths including queryparams.
Expanding the suite
At this point, we have a minimum viable product. Next we will look at additional features that testing easier and improve integration with other tools.
Tailoring the abstract test case
You may want to add methods specific to your application to ease things like logging in.
For our current example, let's subclass SeleniumTestCase and add any useful methods:
class DuckDuckGoTestCase(SeleniumTestCase):
def search(self, search_term):
self.get_path('/')
search_box = self.driver.find_element_by_name('q')
search_box.send_keys(search_term)
search_box.submit()
results = self.driver.find_element_by_id('links')
return results
Be sure to change the test classes to subclass the new app specific test case and leverage the new method:
class TestBasic(DuckDuckGoTestCase):
def test_search(self):
results = self.search('Selenium')
assert 'Selenium' in self.driver.title
assert 'Selenium' in results.text
class TestGenerator(DuckDuckGoTestCase):
def test_search(self):
for search_term in ['Python', 'Selenium', 'San Francisco', 'Sauce Labs']:
yield self.verify_search, search_term
def verify_search(self, search_term):
results = self.search(search_term)
assert search_term in self.driver.title
assert search_term in results.text
Let's also write a third test method using our tailored class to test DuckDuckGo's calculator:
class TestCalculator(DuckDuckGoTestCase):
def test_calculator(self):
self.search('3 + 3')
assert '= 6' in self.driver.find_element_by_id('zero_click').text
If you're up for it, try refactoring the calculator test using generators to test additional equations.
Jenkins integration
Jenkins is a great tool for automating the deployment process. Post-commit git hooks can trigger a build in Jenkins every time a developer pushes code. Tests should run as part the build.
Jenkins also supports test reporting on the front-end from an xUnit test report. A nice graph shows pass, fail, and test volume history. Each test is visible from Jenkins built-in test browser.
nose has a built-in xUnit output plugin. Unfortunately, it does not play nice with multiprocessing. In fact, many plugins can break or behave unexpectedly when multiprocessing is enabled. nose2 is supposed to remedy these issues. I evaluated it recently and it is not quite ready for primetime.
Rosen Diankov developed a set of patched plugins to fix the issue and integrate better with Jenkins.
You can find his plugins
embedded in one of his project repositories
on GitHub. The patches also bring another desirable change to xUnit reports, output capture for all tests. By default,
only failing test output is captured in the result file. In addition, it also manages to allow generated tests to run
in parallel, which is not possible with the built-in multiprocessing
plugin.
Test runner
Additional features, like headless testing and use of Rosen Diankov's custom plugins require some setup before invoking nose. A test runner handles the setup. See run_tests.py in the repo. If you want to use the additional features, you will need to use the test runner.
Sauce Labs
Sauce Labs allows you to run your tests in the cloud on over 150 platform/browser combinations.
To use Sauce Labs, we just need to modify our setup fixture to create a remote webdriver instead of the Firefox one we were using previously. Let's modify our module and test level fixtures:
...
def setup():
config_json = """
{
"endpoint": "http://duckduckgo.com",
"timeout": 10,
"use_sauce": true,
"sauce_username": "my_username",
"sauce_access_key": "accesske-y012-3456-789a-bcdef0123456",
"sauce_browser": "INTERNETEXPLORER"
}
"""
global config
config = json.loads(config_json)
class SeleniumTestCase(object):
def setup(self):
if config['use_sauce']:
desired_capabilities = getattr(webdriver.DesiredCapabilities, config['sauce_browser'])
self.driver = webdriver.Remote(
desired_capabilities=desired_capabilities,
command_executor="http://%s:%s@ondemand.saucelabs.com:80/wd/hub" % (
config['sauce_username'], config['sauce_access_key']
)
)
else:
self.driver = webdriver.Firefox()
self.driver.implicitly_wait(config['timeout'])
...
Cross-browser testing
There are some advanced ways to do cross-browser testing, using a custom nose plugin and method decorators, but it's more trouble than it's worth. It's easier to just run the suite multiple times with a different configuration file. In Jenkins, use multiple jobs with different configuration files to make it easier to track test results. This way you can also fail a build on Firefox test failures, but let the build continue even if tests fail on Internet Explorer. Personally, I have had a lot of trouble getting tests to be 100% stable on anything but the browser they were written for once you start writing tests that interact with a lot of JavaScript and navigate between multiple pages.
Look at Selenium's own Jenkins instance to understand how browsers may behave differently.
Sauce Connect
You can even test against local, non-internet facing, environments using a Sauce Connect tunnel. They are incredibly easy to use, just execute the .jar with your credentials. Some things to watch out for when using Sauce Connect:
- Tunnels get stale, and should be refreshed daily using a scheduled job. They can also die unpredictably. See Keeping Sauce Connect Fresh.
- All traffic during Sauce tests go through the tunnel, not just traffic to the local environment. This can slow things down quite a bit.
- Only one tunnel can be active per account. If you need to tunnel to multiple environments, you should create sub-accounts and manage credentials accordingly.
Sauce API
Sauce Labs has a dashboard where you can view tests. If you want the dashboard to contain any meaningful data:
- Set the
name
parameter indesired_capabilities
in the setup. Unfortunately a setup has no good way of knowing what method called it. I like to use'%s.%s.?' % (__module__, __class__.__name__)
. This would result in file_name.ClassName.? - Set the
build
parameter indesired_capabilities
. Jenkins provides a $BUILD_TAG parameter that contains the job name and build number so you know which build the test originated from. - Use Sauce Labs' API to update the job after the test has completed. I parse the xUnit report and use the API to complete the test name and update the pass/fail status. See job_update_sauce.py.
API Bindings
Applications more often than not rely heavily on data, therefore tests will too.
There are two good ways to handle test data:
- For data that won't be modified, write a script to create the necessary test data and leave it in the database.
- For data that may or may not be modified, create the necessary test data, test against it, and delete it.
The best way to handle either case is to write simple API bindings in a Python module
(using requests makes it easy).
Then you can import the module in a script or call a create()
in the setup()
and a delete()
in the teardown()
of a test.
Selectors
The most difficult element of testing is writing robust selectors. Brittle selectors can break whenever slight changes occur in the DOM. Selectors should be:
- Unique
- Descriptive
- Short
CSS selectors are preferred over XPath. They are faster (especially in IE) and more readable. Here's a blog post and video from Santi of Sauce Labs about the advantages of CSS selectors. Not covered in Santi's post is the javascript-xpath library, which should yield significantly better XPath performance. This tip came from Dr. Wenhua Wang. You should be able to find his presentation with details on javascript-xpath at his Meetup event page.
Here's a quick guide to targeting elements using find_element_by_css_selector()
:
Target | Selector example |
---|---|
Tag | div |
Direct child | table > tr |
Child or subchild | table td |
Id | #my_id |
Class | .myclass |
Attribute | [name=my_name] |
Chained Locators | a.my_class#my_id[name=my_name] |
And here's a comparison of CSS and XPath syntax from John Resig. Targeting sibling or parent elements are two situations where you cannot use CSS and need to use XPath.
Retrofitting selectors
Ideally, every element in the DOM will have a selector that meets the criteria stated above. Unfortunately, that is almost never the case. Retrofitting selectors can be a problem, particularly if functionality of the site depends on id or name attributes that cannot be modified.
Fortunately, HTML5 allows for custom data attributes.
These are attributes beginning with data-
that are valid HTML and can be leveraged as locators. For example,
you might give your otherwise unlocatable element a 'data-selenium' attribute:
<!--Before-->
<a href="/dynamic_path" class="generic" id="random_secure">Marketing's Text</a>
<!--After-->
<a href="/dynamic_path" class="generic" id="random_secure" data-selenium="the_link">Marketing's Text</a>
Now we can locate the element using the following CSS locator: [data-selenium=the_link]
.
I have experimented with a script that automatically adds a random 6 hex digit data-*
attribute to certain elements in
all HTML files passed to it.
The first iteration parsed the HTML using Python's lxml module and added the attributes,
but I found that it changed the HTML unexpectedly, mainly due to bugs in the HTML itself.
The second iteration just uses regular expressions, but in general that's a
bad idea.
The other issue is that this method cannot account for elements that are dynamically generated by application code.
A key aspect of testing is having a testable application. It is important to have a strategy for tagging elements during development so that they can be located easily and elegantly. Try to document a strategy and get buy-in from other developers.
Headless testing
The PyVirtualDisplay
module allows you to wrap your program in Xvfb
(a virtual X display). This is useful for running tests on your
CI server (without Sauce), or locally if you don't want a ton of browser windows taking over your display. See
this post
from Corey Goldberg for more details, then add it to your test runner.
Complex input actions
Action chains allow you to do complex input manipulation.
Here's an example of a hover or mouseover method for the abstract test case:
from selenium.webdriver.common.action_chains import ActionChains
def hover(self, element):
ActionChains(self.driver).move_to_element(element).perform()
Handling multiple windows
Here's a method you may want to add to the abstract test case if your application has popups or multiple windows/tabs.
import time
def find_window(self, title):
start = time.time()
while (time.time() - start) <= config['timeout']:
for window_handle in self.driver.window_handles:
self.driver.switch_to_window(window_handle)
if self.driver.title == title:
return True
time.sleep(0.5)
raise Exception("Could not find window '%s'" % title)
Cookie injection
Cookie injection is easy until you try to do it cross-browser. I highly recommend avoiding it altogether.
In my case, I had a developer create a secret page for me that allowed me to add a cookie to prevent a
first-use prompt from appearing. To add the cookie, just navigate to the page using
self.get_path('/secret_page')
and click the provided button.
JavaSript execution
execute_script()
is useful for interacting with JavaScript and the browser, particularly libraries like jQuery. Lot of cross-browser
issues can be remedied by using jQuery. If you abstract driver methods into test case methods, it is very
easy to try out different interaction strategies. You could probably rebuild the entire selenium API using jQuery and
execute_script()
.
Debugging
The pdb plugin for nose can be used to drop into the Python debugger on test failures. The DOM and browser remain available in the failed state for analysis using the browser or Selenium bindings.
Documentation
Developers can understand and fix tests more easily if they are documented. Non-developers should be aware of what is being tested and how.
Here's an example of a documented test:
import uuid
def test_user_creation(self):
"""
Author: Mr. Developer
Description: Create a user
"""
# step: Get user creation page
self.get_path('/create_user')
# step: Fill in username with a random UUID
self.driver.find_element_by_id('username').send_keys(uuid.uuid4())
# step: Fill in password
self.driver.find_element_by_id('password').send_keys('letmein')
# step: Click create user button
self.driver.find_element_by_id('create_user_btn').click()
# assert: Success message is displayed
assert 'Success' in self.find_element_by_id('status_message').text
Now, let's say every single test is documented using this same format. It is relatively easy to write a parser using tokenize that can generate a CSV spreadsheet documenting the entire test suite. No more spreadsheet rot. See generate_summary.py.
Conversely, you could also generate code stubs from a spreadsheet that non-developers use to request new tests.
Note: by default, the first line of a method's docstring are used to name the tests in verbose mode. The ignore-docstring plugin allows you to override this behavior.
Where to put the suite
Early on in my framework, when it was in a state of constant flux, I did not want to put it in the application repository. This was a mistake. Tests should live with the code. Using a commit log, you can easily check and see what code came in with tests. Tests with code also allows you to easily roll back the clock to an older commit to view the behavior of a test that was passing but is now failing.
Coverage
There is always a question of how much to test on the front-end. Despite the power and flexibility of Selenium, I think it's important to stay as close to the metal as possible. If Selenium tests are written with the goal of providing significant coverage, the resulting suite will be brittle. Unit testing should be the primary source of coverage.
An additional strategy is to appoint someone to decide where tests for newly discovered bugs should be written. Closing out a bug should require that a test exists somewhere for the bug.
Other potential features
These are features I have considered and think are valuable, but have not had the time or need to implement for my use case.
Page Object Model - A page object model allows you to maintain selectors and page actions in one place rather than individual tests.
Performance testing - Write a script to stand up a large number of remote webdrivers in the cloud and have the framework run the same test or a group of tests repeatedly against the application.
Re-run failed tests - Tests fail because of environment wobble. It happens. It would be nice to re-run the tests that failed so that testing during build is more robust and less time consuming.
Help!
Ask a question on the user group, in the IRC channel (#selenium on Freenode), or attend a Meetup.
All of the above resources were invaluable to me when learning about Selenium, as well as developing and debugging my framework.