User documentation¶
Setup¶
For using the test browser, just decorate your test methods with the @browsing decorator.
from ftw.testbrowser import browsing
from unittest2 import TestCase
from plone.app.testing import PLONE_FUNCTIONAL_TESTING
class TestMyView(TestCase):
layer = PLONE_FUNCTIONAL_TESTING
@browsing
def test_view_displays_things(self, browser):
browser.visit(view='my_view')
Warning
Make sure that you use a functional testing layer!
See also
By default there is only one, global browser, but it is also possible to instantiate a new browser and to set it up manually:
from ftw.testbrowser.core import Browser
browser = Browser()
app = zope_app
with browser(app):
browser.open()
Warning
Page objects and forms usually use the global browser. Creating a new browser manually will not set it as global browser and page objects / forms will not be able to access it!
Choosing the default driver¶
The default driver is chosen automatically, depending on the Zope version and
whether the browser is setup with a Zope app or not. Without a Zope app the
default driver is LIB_REQUESTS
. With Zope 4 the default is LIB_WEBTEST
and with Zope 2 it’s LIB_MECHANIZE
.
LIB_WEBTEST
is only available with Zope 4 (Plone 5.2 and later) while
LIB_MECHANIZE
and LIB_TRAVERSAL
are only available with Zope 2.
The default driver can be changed on the browser instance, overriding the automatic driver selection:
from ftw.testbrowser.core import Browser
from ftw.testbrowser.core import LIB_MECHANIZE
from ftw.testbrowser.core import LIB_REQUESTS
from ftw.testbrowser.core import LIB_TRAVERSAL
from ftw.testbrowser.core import LIB_WEBTEST
browser = Browser()
# always use mechanize:
browser.default_driver = LIB_MECHANIZE
# or always use webtest:
browser.default_driver = LIB_WEBTEST
# or always use requests:
browser.default_driver = LIB_REQUESTS
# or use traversal in the same transactions with same connection:
browser.default_driver = LIB_TRAVERSAL
When using the testbrowser in a plone.testing
layer, the driver can be
chosen by using a standard plone.testing
fixture:
from ftw.testbrowser import MECHANIZE_BROWSER_FIXTURE
from ftw.testbrowser import REQUESTS_BROWSER_FIXTURE
from ftw.testbrowser import TRAVERSAL_BROWSER_FIXTURE
from ftw.testbrowser import WEBTEST_BROWSER_FIXTURE
from plone.app.testing import PLONE_FIXTURE
from plone.app.testing import FunctionalTesting
MY_FUNCTIONAL_TESTING_WITH_MECHANIZE = FunctionalTesting(
bases=(PLONE_FIXTURE,
MECHANIZE_BROWSER_FIXTURE),
name='functional:mechanize')
MY_FUNCTIONAL_TESTING_WITH_REQUESTS = FunctionalTesting(
bases=(PLONE_FIXTURE,
REQUESTS_BROWSER_FIXTURE),
name='functional:requests')
MY_FUNCTIONAL_TESTING_WITH_TRAVERSAL = FunctionalTesting(
bases=(PLONE_FIXTURE,
TRAVERSAL_BROWSER_FIXTURE),
name='functional:traversal')
MY_FUNCTIONAL_TESTING_WITH_WEBTEST = FunctionalTesting(
bases=(PLONE_FIXTURE,
WEBTEST_BROWSER_FIXTURE),
name='functional:webtest')
Visit pages¶
For visiting a page, use the visit or open method on the browser (those methods do the same).
Visiting the Plone site root:
browser.open()
print browser.url
See also
Visiting a full url:
browser.open('http://nohost/plone/sitemap')
Visiting an object:
folder = portal.get('the-folder')
browser.visit(folder)
Visit a view on an object:
folder = portal.get('the-folder')
browser.visit(folder, view='folder_contents')
The open method can also be used to make POST request:
browser.open('http://nohost/plone/login_form',
{'__ac_name': TEST_USER_NAME,
'__ac_password': TEST_USER_PASSWORD,
'form.submitted': 1})
See also
Logging in¶
The login method sets the Authorization request header.
Login with the plone.app.testing default test user (TEST_USER_NAME):
browser.login().open()
Logging in with another user:
browser.login(username='john.doe', password='secret')
Logout and login a different user:
browser.login(username='john.doe', password='secret').open()
browser.logout()
browser.login().open()
Finding elements¶
Elements can be found using CSS-Selectors (css method) or using XPath-Expressions (xpath method). A result set (Nodes) of all matches is returned.
See also
CSS:
browser.open()
heading = browser.css('.documentFirstHeading').first
self.assertEquals('Plone Site', heading.normalized_text())
See also
ftw.testbrowser.core.Browser.css()
,
ftw.testbrowser.nodes.NodeWrapper.normalized_text()
XPath:
browser.open()
heading = browser.xpath('h1').first
self.assertEquals('Plone Site', heading.normalized_text())
See also
Finding elements by text:
browser.open()
browser.find('Sitemap').click()
The find method will look for theese elements (in this order):
- a link with this text (normalized, including subelements’ texts)
- a field which has a label with this text
- a button which has a label with this text
See also
Matching text content¶
In HTML, most elements can contain direct text but the elements can also contain sub-elements which also have text.
When having this HTML:
<a id="link">
This is
<b>a link
</a>
We can get only direct text of the link:
>>> browser.css('#link').first.text
'\n This is\n '
or the text recursively:
>>> browser.css('#link').first.text_content()
'\n This is\n a link\n '
or the normalized recursive text:
>>> browser.css('#link').first.normalized_text()
'This is a link'
See also
ftw.testbrowser.nodes.NodeWrapper.normalized_text()
Functions such as find usually use the normalized_text.
See also
Get the page contents / json data¶
The page content of the currently loaded page is always available on the browser:
browser.open()
print browser.contents
If the result is a JSON string, you can access the JSON data (converted to python data structure already) with the json property:
browser.open(view='a-json-view')
print browser.json
See also
Filling and submitting forms¶
The browser’s fill method helps to easily fill forms by label text without knowing the structure and details of the form:
browser.visit(view='login_form')
browser.fill({'Login Name': TEST_USER_NAME,
'Password': TEST_USER_PASSWORD}).submit()
The fill method returns the browser instance which can be submitted with submit. The keys of the dict with the form data can be either field labels (<label> text) or the name of the field. Only one form can be filled at a time.
File uploading¶
For uploading a file you need to pass at least the file data (string or stream) and the filename to the fill method, optionally you can also declare a mime type.
There are two syntaxes which can be used.
Tuple syntax:
browser.fill({'File': ('Raw file data', 'file.txt', 'text/plain')})
Stream syntax
file_ = StringIO('Raw file data')
file_.filename = 'file.txt'
file_.content_type = 'text/plain'
browser.fill({'File': file_})
You can also pass in filesystem files directly, but you need to make sure that the file stream is opened untill the form is submitted.
with open('myfile.pdf') as file_:
browser.fill({'File': file_}).submit()
Tables¶
Tables are difficult to test without the right tools. For making the tests easy and readable, the table components provide helpers especially for easily extracting a table in a readable form.
For testing the content of this table:
<table id="shopping-cart">
<thead>
<tr>
<th>Product</th>
<th>Price</th>
</tr>
</thead>
<tbody>
<tr>
<td>Socks</td>
<td>12.90</td>
</tr>
<tr>
<td>Pants</td>
<td>35.00</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>TOTAL:</td>
<td>47.90</td>
</tr>
</tfoot>
</table>
You could use the lists
method:
self.assertEquals(
[['Product', 'Price'],
['Socks', '12.90'],
['Pants', '35.00'],
['TOTAL:', '47.90']],
browser.css('#shopping-cart').first.lists())
See also
or the dicts
method:
self.assertEquals(
[{'Product': 'Socks',
'Price': '12.90'},
{'Product': 'Pants',
'Price': '35.00'},
{'Product': 'TOTAL:',
'Price': '47.90'}],
browser.css('#shopping-cart').first.dicts())
See also
See the tables API for more details.
Page objects¶
ftw.testbrowser ships some basic page objects for Plone. Page objects represent a page or a part of a page and provide an API to this part. This allows us to write simpler and more expressive tests and makes the tests less brittle.
Read the post by Martin Fowler for better explenation about what page objects are.
You can and should write your own page objects for your views and pages.
See the API documentation for the page objects included in ftw.testbrowser:
- The plone page object provides general information about this page, such as if the user is logged in or the view / portal type of the page.
- The factoriesmenu page object helps to add new content through the browser or to test the addable types.
- The statusmessages page object helps to assert the current status messages.
- The dexterity page object provides helpers related to dexterity
- The z3cform page object provides helpers related to z3cforms, e.g. for asserting validation errors in the form.
See also
XML Support¶
When the response mimetype is text/xml
or application/xml
, the response body is
parsed as XML instead of HTML.
This can lead to problems when having XML-Documents with a default namespace, because lxml only supports XPath 1, which does not support default namespaces.
You can either solve the problem yourself by parsing the browser.contents
or you
may switch back to HTML parsing.
HTML parsing will modify your document though, it will insert a html
node for example.
Re-parsing with another parser:
browser.webdav(view='something.xml') # XML document
browser.parse_as_html() # HTML document
browser.parse_as_xml() # XML document
See also
HTTP requests¶
ftw.testbrowser also supports not following redirects. This is useful for testing the bodies of redirect responses or inspecting Location headers.
This is currently not implemented for mechanize.
from ftw.testbrowser import browsing
from unittest2 import TestCase
class TestRedirects(TestCase):
@browsing
def test_redirects_are_followed_automatically(self, browser):
browser.open(view='test-redirect-to-portal')
self.assertEquals(self.portal.absolute_url(), browser.url)
self.assertEquals(('listing_view', 'plone-site'), plone.view_and_portal_type())
@browsing
def test_redirect_following_can_be_prevented(self, browser):
browser.allow_redirects = False
browser.open(view='test-redirect-to-portal')
self.assertEquals('/'.join((self.portal.absolute_url(), 'test-redirect-to-portal')), browser.url)
self.assertEquals((None, None), plone.view_and_portal_type())
WebDAV requests¶
ftw.testbrowser supports doing WebDAV requests, although it requires a ZServer to be running because of limitations in mechanize.
Use a testing layer which bases on plone.app.testing.PLONE_ZSERVER
:
from plone.app.testing import FunctionalTesting
from plone.app.testing import PLONE_FIXTURE
from plone.app.testing import PLONE_ZSERVER
from plone.app.testing import PloneSandboxLayer
class MyPackageLayer(PloneSandboxLayer):
defaultBases = (PLONE_FIXTURE, )
MY_PACKAGE_FIXTURE = MyPackageLayer()
MY_PACKAGE_ZSERVER_TESTING = FunctionalTesting(
bases=(MY_PACKAGE_FIXTURE,
PLONE_ZSERVER),
name='my.package:functional:zserver')
Then use the webdav
method for making requests in the test:
from ftw.testbrowser import browsing
from my.package.testing import MY_PACKAGE_ZSERVER_TESTING
from unittest2 import TestCase
class TestWebdav(TestCase):
layer = MY_PACKAGE_ZSERVER_TESTING
@browsing
def test_DAV_option(self, browser):
browser.webdav('OPTIONS')
self.assertEquals('1,2', browser.response.headers.get('DAV'))
Error handling¶
The testbrowser raises exceptions by default when a request was not successful.
When the response has a status code of 4xx, a
ftw.testbrowser.exceptions.HTTPClientError
is raised,
when the status code is 5xx, a
ftw.testbrowser.exceptions.HTTPServerError
is raised.
When the requests is sent to a Plone CMS and causes an “insufficient privileges”
result, a
ftw.testbrowser.exceptions.InsufficientPrivileges
is raised.
The exception is raised for anonymous users (rendering the login form) as well
as for logged in users (rendering the “Insufficient Privileges” page).
Disabling HTTP exceptions¶
Disable the raise_http_errors
flag when the test browser should not raise
any HTTP exceptions:
@browsing
def test(self, browser):
browser.raise_http_errors = False
browser.open(view='not-existing')
Expecting HTTP exceptions¶
Sometimes we want to make sure that the server responds with a certain bad status. For making that easy, the testbrowser provides assertion context managers:
@browsing
def test(self, browser):
with browser.expect_http_error():
browser.open(view='failing')
with browser.expect_http_error(code=404):
browser.open(view='not-existing')
with browser.expect_http_error(reason='Bad Request'):
browser.open(view='get-record-by-id')
Expecting unauthoirzed exceptions (Plone)¶
When a user is not logged in and is not authorized to access a resource,
Plone will redirect the user to the login form (require_login
).
The expect_unauthorized
context manager knows how Plone behaves and provides
an easy interface so that the developer does not need to handle it.
@browsing
def test(self, browser):
with browser.expect_unauthorized():
browser.open(view='plone_control_panel')
Exception bubbling¶
Exceptions happening in views can not be catched in the browser by default.
When using an internally dispatched driver such as Mechanize,
the option exception_bubbling
makes the Zope Publisher and Mechanize
let the exceptions bubble up into the test method, so that it can be catched
and asserted there.
@browsing
def test(self, browser):
browser.exception_bubbling = True
with self.assertRaises(ValueError) as cm:
browser.open(view='failing')
self.assertEquals('No valid value was submitted', str(cm.exception))
Plone 5: resource registries disabled¶
In Plone 5, the resource registries are cooked when the resource registry viewlets are rendered.
Cooking the bundles takes a lot of time.
Since ftw.testbrowser
does nothing with JavaScript or CSS, cooking of resources is disabled
by default for performance improvement.
That means that <script>
and <styles>
tags are missing in the HTML.
This can make the tests up to 4-5 times faster.
Tests or projects which require to have the resource tags in the HTML can reenable them.
Enable by browser flag:
@browsing
def test(self, browser):
browser.disable_resource_registries = False
browser.open()
Enable by environment variable:
TESTBROWSER_DISABLE_RESOURCE_REGISTRIES = false