Funktionale und Systemtests mit zope.testbrowser

Während unit tests und DocTests die Gültigkeit von einzelnen Methoden und Modulen überprüfen, überprüfen funktionale Tests die Anwendungen als Ganzes. Häufig nehmen sie dabei die Sicht des Nutzers ein und üblicherweise orientieren sie sich an den Nutzungsfällen (Use cases) der Anwendung. Systemtests hingegen testen die Anwendung als Blackbox.

Funktionale Tests sind kein Ersatz für Unit tests sondern überprüfen den funktionalen Ablauf, wie ihn der Nutzer wahrnimmt. Ein funktionaler Test überprüft z.B., ob ein Delete-Button vorhanden ist und wie erwartet funktioniert. Um die Tests überschaubar zu halten, wird meist nur überprüft, ob die entsprechenden Templates vorhanden sind und für Nutzer mit verschiedenen Rollen und Rechten wie erwartet funktioniert.

Mit Zope 3 kommt die Bibliothek zope.testbrowser, die es ermöglicht, DocTests zu schreiben, die sich wie ein Webbrowser verhalten. Sie können URLs öffnen, Links anklicken, Formularfelder ausfüllen und abschicken und dann die zurückgelieferten HTTP headers, URLs und Seiteninhalte überprüfen.

Hinweis: Da der testbrowser kein JavaScript unterstützt, empfiehlt sich zum Testen dynamischer User Interfaces selenium oder Windmill.

Im folgenden nun ein Auszug aus einem zope.testbrowser-Test aus vs.registration:

Setting up and logging in
-------------------------

    >>> from Products.Five.testbrowser import Browser
    >>> browser = Browser()
    >>> portal_url = self.portal.absolute_url()

    [...]

    >>> from Products.PloneTestCase.setup import portal_owner, default_password

    >>> browser.open(portal_url + '/login_form?came_from=' + portal_url)
    >>> browser.getControl(name='__ac_name').value = portal_owner
    >>> browser.getControl(name='__ac_password').value = default_password
    >>> browser.getControl(name='submit').click()

Zunächst wird jedoch wieder eine Basisklasse für funktionale Tests in src/vs.registration/vs/registration/tests/base.py geschreiben:

from Products.Five import zcml
from Products.Five import fiveconfigure

from Testing import ZopeTestCase as ztc

from Products.PloneTestCase import PloneTestCase as ptc
from Products.PloneTestCase.layer import onsetup

@onsetup
def setup_vs_registration():

    fiveconfigure.debug_mode = True
    import vs.registration
    zcml.load_config('configure.zcml', vs.registration)
    fiveconfigure.debug_mode = False

    ztc.installPackage('vs.registration')

setup_vs_registration()
ptc.setupPloneSite(products=['vs.registration'])

…

class RegistrationFunctionalTestCase(ptc.FunctionalTestCase):
    """Test case class used for functional (doc-)tests
    """

RegistrationFunctionalTestCase verwendet dabei PloneTestCase.FunctionalTestCase als Basisklasse. Damit nun solch ein funktionaler Test aufgerufen werden kann, wird tests/test_doctest.py mit folgendem Inhalt erstellt:

import unittest
import doctest

from zope.testing import doctestunit
from zope.component import testing, eventtesting

from Testing import ZopeTestCase as ztc

from vs.registration.tests import base

def test_suite():
    return unittest.TestSuite([

        ztc.ZopeDocFileSuite(
            'README.txt', package='vs.registration',
            test_class=base.RegistrationFunctionalTestCase,
            optionflags=doctest.REPORT_ONLY_FIRST_FAILURE | doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS),
        ])

if __name__ == '__main__':
    unittest.main(defaultTest='test_suite')

Damit stehen nun auch die Hilfsmethoden von PloneTestCase in Doctests zur Verfügung.

Die README.txt-Datei, in der der funktionale Test für vs.registration geschrieben wird, beginnt nun zunächst mit der Konfiguration des zope.testbrowser:

>>> from Products.Five.testbrowser import Browser
>>> browser = Browser()
>>> portal_url = self.portal.absolute_url()

>>> browser.handleErrors = False
>>> self.portal.error_log._ignored_exceptions = ()

Die letzten beiden Zeilen erleichtern das Schreiben und Debuggen von testbrowser-Tests.

Nach der Anmeldung testen wir, ob unsere Inhaltstypen auf der Plone-Site hinzugefügt werden können. Hierzu sollte im Root-Verzeichnis der Site ein Link mit der ID registration vorhanden sein. Desweiteren sollte registrant hier nicht hinzugefügt werden können:

Addable content
---------------

    >>> browser.open(portal_url)
    >>> browser.getLink(id='registration').url.endswith("createObject?type_name=Registration")
    True

    >>> browser.getLink(id='registrant')
    Traceback (most recent call last):
    ...
    LinkNotFoundError

So können Sie sich auch den weiteren Test durchlesen und herausfinden, wie neue Inhalte erstellt und deren Ausgabe überprüft wird: README.txt

Auch zope.testbrowser schreibt seine Dokumentation in einen DocTest in seiner README.txt-Datei. Die bedeutendsten Methoden sind

open(url)
Öffnet eine gegebene URL.
reload()
läd die aktuelle Seite erneut.
goBack(count=1)
Simuliert den Zurück-Button mit der Anzahl der Schritte, die zurückgegangen werden soll.
getLink(text=None, url=None, id=None)
gibt einen Link anhand seines Textes, der Ziel-URL oder der ID zurück.
getControl(label=None, name=None, index=None)

gibt ein Kontrollelement eines Formulars zurück, entweder anhand des Werts des Submit-Buttons oder des Inhalts des label-Tags.

Index kann verwendet werden um zwischen verschiedenen Kontrollelementen unterscheiden zu können, so wird mit index=0 das erste dieser Elemente genommen.

Auch hier kann mit click() ein Klick auf dieses Kontrollelement simuliert werden.

Das IBrowser-Interface bietet ebenfalls einige Eigenschaften, mit dem der Status der aktuellen Seite untersucht werden kann. Die bedeutendsten sind:

url
Die vollständige URL der aktuellen Seite.
contents
Der vollständige Inhalt der aktuellen Seite als String.
headers
Ein Dict der HTTP-Headers.

In der interfaces und README-Dateien erhalten Sie weitere Informationen zu Methoden, Attributen und Interfaces.

Debugging

Manchmal sind die funktionalen Tests selber fehlerhaft und müssen analysiert werden. Mit folgender Anweisung bekommen Sie alle Fehler ausgegeben:

>>> browser.handleErrors = False

Damit werden nicht die HTTPError ausgegeben sondern die Exceptions von Zope. Dies ist meist sinnvoll zum Analysieren von Fehlern in den Tests.

Zudem kann bei der Verwendung von PloneTestCase das Error log von Plone verwendet werden:

>>> self.portal.error_log._ignored_exceptions = ()

Damit werden Fehler wie NotFound und Unauthorized im error log angezeigt. Selbstverständlich sollte die Instanz in der Buildout-Konfigurationsdatei so konfiguriert sein, dass Verbose Security auf on steht.

Nun kann die Zeile des Tests, die zu einem Fehler führt, folgendes eingegeben werden:

>>> try:
...     browser.getControl('Save').click()
... except:

...     print self.portal.error_log.getLogEntries()[0]['tb_text']

...     import pdb; pdb.set_trace()

Hiermit wird der letzte Eintrag im *error log* ausgegeben und ein *PDB Break Point* gesetzt.

Funktionale Tests vs. Systemtests

Ein Systemtest überprüft ein System als sog. Blackbox. Ein funktionaler Test konzentriert sich auf die geforderten Funktionsabläufe, die meist in Nutzungsfällen (Use Cases) beschrieben sind.

Für einen funktionalen Test mag es akzeptabel sein, Annahmen auf einem festgelegten Status einer Site, der Testsuite, zu machen. Der Systemtest macht hingegen keine solchen Annahmen. Daher benötigt ein zope.testbrowser-Test idealerweise nicht das PloneTestCase-Test fixture:

import unittest
from zope.testing import doctest

def test_suite():
    return unittest.TestSuite((
        doctest.DocFileSuite('TestSystem.txt'),
        ))

if __name__ == '__main__':
    unittest.main(defaultTest='test_suite')

Abgesehen davon bleiben die verwendeten Methoden für einen Systemtest dieselben.