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!

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

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})

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.

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())

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

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.

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

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())

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 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.

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

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