Testing behaviors

How to write unit tests for behaviors

Behaviors, like any other code, should be tested. If you are writing a behavior with just a marker interface or schema interface, it is probably not necessary to test the interface. However, any actual code, such as a behavior adapter factory, ought to be tested.

Writing a behavior integration test is not very difficult if you are happy to depend on Dexterity in your test. You can create a dummy type by instantiating a Dexterity FTI in portal_types. Then, enable your behavior by adding its interface name to the behaviors property.

In many cases, however, it is better not to depend on Dexterity at all. It is not too difficult to mimic what Dexterity does to enable behaviors on its types. The following example is taken from collective.gtags and tests the ITags behavior we saw on the first page of this manual.

Behaviors
=========

This package provides a behavior called `collective.gtags.behaviors.ITags`.
This adds a `Tags` field called `tags` to the "Categorization" fieldset, with
a behavior adapter that stores the chosen tags in the Subject metadata field.

To learn more about the `Tags` field and how it works, see `tagging.rst`.

Test setup
----------

Before we can run these tests, we need to load the collective.gtags
configuration. This will configure the behavior.

    >>> configuration = """\
    ... <configure
    ...      xmlns="http://namespaces.zope.org/zope"
    ...      i18n_domain="collective.gtags">
    ...
    ...     <include package="Products.Five" file="meta.zcml" />
    ...     <include package="collective.gtags" file="behaviors.zcml" />
    ...
    ... </configure>
    ... """

    >>> from StringIO import StringIO
    >>> from zope.configuration import xmlconfig
    >>> xmlconfig.xmlconfig(StringIO(configuration))

This behavior can be enabled for any `IDublinCore`. For the purposes of
testing, we will use the CMFDefault Document type and a custom
IBehaviorAssignable adapter to mark the behavior as enabled.

    >>> from Products.CMFDefault.Document import Document

    >>> from plone.behavior.interfaces import IBehaviorAssignable
    >>> from collective.gtags.behaviors import ITags
    >>> from zope.component import adapter
    >>> from zope.interface import implementer
    >>> @adapter(Document)
    ... @implementer(IBehaviorAssignable)
    ... class TestingAssignable(object):
    ...
    ...     enabled = [ITags]
    ...
    ...     def __init__(self, context):
    ...         self.context = context
    ...
    ...     def supports(self, behavior_interface):
    ...         return behavior_interface in self.enabled
    ...
    ...     def enumerate_behaviors(self):
    ...         for e in self.enabled:
    ...             yield queryUtility(IBehavior, name=e.__identifier__)

    >>> from zope.component import provideAdapter
    >>> provideAdapter(TestingAssignable)

Behavior installation
---------------------

We can now test that the behavior is installed when the ZCML for this package
is loaded.

    >>> from zope.component import getUtility
    >>> from plone.behavior.interfaces import IBehavior
    >>> tags_behavior = getUtility(IBehavior, name='collective.gtags.behaviors.ITags')
    >>> tags_behavior.interface
    <InterfaceClass collective.gtags.behaviors.ITags>

We also expect this behavior to be a form field provider. Let's verify that.

    >>> from plone.autoform.interfaces import IFormFieldProvider
    >>> IFormFieldProvider.providedBy(tags_behavior.interface)
    True

Using the behavior
------------------

Let's create a content object that has this behavior enabled and check that
it works.

    >>> doc = Document('doc')
    >>> tags_adapter = ITags(doc, None)
    >>> tags_adapter is not None
    True

We'll check that the `tags` set is built from the `Subject()` field:

    >>> doc.setSubject(['One', 'Two'])
    >>> doc.Subject()
    ('One', 'Two')

    >>> tags_adapter.tags == set(['One', 'Two'])
    True

    >>> tags_adapter.tags = set(['Two', 'Three'])
    >>> doc.Subject() == ('Two', 'Three')
    True

This test tries to prove that the behavior is correctly installed and works as intended on a suitable content class. It is not a true unit test, however. For a true unit test, we would simply test the Tags adapter directly on a dummy context, but that is not terribly interesting, since all it does is convert sets to tuples.

First, we configure the package. To keep the test small, we limit ourselves to the behaviors.zcml file, which in this case will suffice. We still need to include a minimal set of ZCML from Five.

Next, we implement an IBehaviorAssignable*adapter. This is a low-level component used by *plone.behavior to determine if a behavior is enabled on a particular object. Dexterity provides an implementation that checks the type’s FTI. Our test version is much simpler - it hardcodes the supported behaviors.

With this in place, we first check that the IBehavior utility has been correctly registered. This is essentially a test to show that we’ve used the <plone:behavior /> directive as intended. We also verify that our schema interface is an IFormFieldsProvider. For a non-form behavior, we’d omit this.

Finally, we test the behavior. We’ve chosen to use CMFDefault’s Document type for our test, as the behavior adapter requires an object providing IDublinCore. Ideally, we’d write our own class and implement IDublinCore directly. However, in many cases, the types from CMFDefault are going to provide convenient test fodder.

If our behavior was more complex we’d add more intricate tests. By the last section of the doctest, we have enough context to test the adapter factory.

To run the test, we need a test suite. In tests.py, we have:

from zope.app.testing import setup
import doctest
import unittest

def setUp(test):
    pass

def tearDown(test):
    setup.placefulTearDown()

def test_suite():
    return unittest.TestSuite((
        doctest.DocFileSuite(
            'behaviors.rst',
            setUp=setUp, tearDown=tearDown,
            optionflags=doctest.NORMALIZE_WHITESPACE|doctest.ELLIPSIS),
        ))

This runs the behaviors.rst doctest from the same directory as the tests.py file. To run the test, we can use the usual test runner:

$ ./bin/instance test -s collective.gtags

Testing a dexterity type with a behavior

Not all behaviors are enabled by default. Let’s say you want to test your Dexterity type when a behavior is enabled or disabled. To do this, you will need to setup the behavior in your test. There is an example of this kind of test in the collective.cover product. There is a behavior that adds the capability for the cover page to refresh itself. The test checks if the behavior is not yet enabled, enables the behavior, check its effect and then disables it again. Here is the code:

# -*- coding: utf-8 -*-
from collective.cover.behaviors.interfaces import IRefresh
from collective.cover.interfaces import ICoverLayer
from collective.cover.testing import INTEGRATION_TESTING
from plone import api
from plone.behavior.interfaces import IBehavior
from plone.dexterity.interfaces import IDexterityFTI
from plone.dexterity.schema import SchemaInvalidatedEvent
from zope.component import queryUtility
from zope.event import notify
from zope.interface import alsoProvides

import unittest


class RefreshBehaviorTestCase(unittest.TestCase):

    layer = INTEGRATION_TESTING

    def _enable_refresh_behavior(self):
        fti = queryUtility(IDexterityFTI, name='collective.cover.content')
        behaviors = list(fti.behaviors)
        behaviors.append(IRefresh.__identifier__)
        fti.behaviors = tuple(behaviors)
        # invalidate schema cache
        notify(SchemaInvalidatedEvent('collective.cover.content'))

    def _disable_refresh_behavior(self):
        fti = queryUtility(IDexterityFTI, name='collective.cover.content')
        behaviors = list(fti.behaviors)
        behaviors.remove(IRefresh.__identifier__)
        fti.behaviors = tuple(behaviors)
        # invalidate schema cache
        notify(SchemaInvalidatedEvent('collective.cover.content'))

    def setUp(self):
        self.portal = self.layer['portal']
        self.request = self.layer['request']
        alsoProvides(self.request, ICoverLayer)
        with api.env.adopt_roles(['Manager']):
            self.cover = api.content.create(
                self.portal, 'collective.cover.content', 'c1')

    def test_refresh_registration(self):
        registration = queryUtility(IBehavior, name=IRefresh.__identifier__)
        self.assertIsNotNone(registration)

    def test_refresh_behavior(self):
        view = api.content.get_view(u'view', self.cover, self.request)
        self.assertNotIn('<meta http-equiv="refresh" content="300" />', view())
        self._enable_refresh_behavior()
        self.cover.enable_refresh = True
        self.assertIn('<meta http-equiv="refresh" content="300" />', view())
        self.cover.ttl = 5
        self.assertIn('<meta http-equiv="refresh" content="5" />', view())
        self._disable_refresh_behavior()
        self.assertNotIn('<meta http-equiv="refresh" content="5" />', view())

The methods _enable_refresh_behavior and _disable_refresh_behavior use the IDexterityFTI to get the Factory Type Information for the Dexterity type (collective.cover.content in this case). Then the FTI of collective.cover.content is used by both methods to get a list of enabled behaviors. To enable it, add the desired behavior to the FTI behaviors: behaviors.append(IRefresh.__identifier__). To disable it, remove the behavior from the FTI behaviors: behaviors.remove(IRefresh.__identifier__). Assign the resulting behaviors list to the behaviors attribute of the FTI as a tuple: fti.behaviors = tuple(behaviors). Finally, to have the changes take effect, invalidate the schema cache: notify(SchemaInvalidatedEvent('collective.cover.content')).

A note about marker interfaces

Marker interface support depends on code that is implemented in Dexterity and is non-trivial to reproduce in a test. If you need a marker interface in a test, set it manually with zope.interface.alsoProvides, or write an integration test with Dexterity content.