Such: a Functional-Test Friendly DSL
Note
New in version 0.4
Such is a DSL for writing tests with expensive, nested fixtures – which typically means functional tests. It requires the layers plugin (see Organizing Test Fixtures into Layers).
What does it look like?
Unlike some python testing DSLs, such is just plain old python.
import unittest
from nose2.tools import such
class SomeLayer:
@classmethod
def setUp(cls):
it.somelayer = True
@classmethod
def tearDown(cls):
del it.somelayer
#
# Such tests start with a declaration about the system under test
# and will typically bind the test declaration to a variable with
# a name that makes nice sentences, like 'this' or 'it'.
#
with such.A("system with complex setup") as it:
#
# Each layer of tests can define setup and teardown methods.
# setup and teardown methods defined here run around the entire
# group of tests, not each individual test.
#
@it.has_setup
def setup():
it.things = [1]
@it.has_teardown
def teardown():
it.things = []
#
# The 'should' decorator is used to mark tests.
#
@it.should("do something")
def test():
assert it.things
#
# Tests can use all of the normal unittest TestCase assert
# methods by calling them on the test declaration.
#
it.assertEqual(len(it.things), 1)
#
# The 'having' context manager is used to introduce a new layer,
# one that depends on the layer(s) above it. Tests in this
# new layer inherit all of the fixtures of the layer above.
#
with it.having("an expensive fixture"):
@it.has_setup
def setup(): # noqa: F811
it.things.append(2)
#
# Tests that take an argument will be passed the
# unittest.TestCase instance that is generated to wrap
# them. Tests can call any and all TestCase methods on this
# instance.
#
@it.should("do more things")
def test(case): # noqa: F811
case.assertEqual(it.things[-1], 2)
#
# Layers can be nested to any depth.
#
with it.having("another precondition"):
@it.has_setup
def setup(): # noqa: F811
it.things.append(3)
@it.has_teardown
def teardown(): # noqa: F811
it.things.pop()
@it.should("do that not this") # type: ignore[no-redef]
def test(case): # noqa: F811
it.things.append(4)
#
# Tests can add their own cleanup functions.
#
case.addCleanup(it.things.pop)
case.assertEqual(it.things[-1], 4, it.things)
@it.should("do this not that") # type: ignore[no-redef]
def test(case): # noqa: F811
case.assertEqual(it.things[-1], 3, it.things[:])
#
# A layer may have any number of sub-layers.
#
with it.having("a different precondition"):
#
# A layer defined with ``having`` can make use of
# layers defined elsewhere. An external layer
# pulled in with ``it.uses`` becomes a parent
# of the current layer (though it doesn't actually
# get injected into the layer's MRO).
#
it.uses(SomeLayer)
@it.has_setup
def setup(): # noqa: F811
it.things.append(99)
@it.has_teardown
def teardown(): # noqa: F811
it.things.pop()
#
# Layers can define setup and teardown methods that wrap
# each test case, as well, corresponding to TestCase.setUp
# and TestCase.tearDown.
#
@it.has_test_setup
def test_setup(case):
it.is_funny = True
case.is_funny = True
@it.has_test_teardown
def test_teardown(case):
delattr(it, "is_funny")
delattr(case, "is_funny")
@it.should("do something else") # type: ignore[no-redef]
def test(case): # noqa: F811
assert it.things[-1] == 99
assert it.is_funny
assert case.is_funny
@it.should("have another test") # type: ignore[no-redef]
def test(case): # noqa: F811
assert it.is_funny
assert case.is_funny
@it.should("have access to an external fixture") # type: ignore[no-redef]
def test(case): # noqa: F811
assert it.somelayer
with it.having("a case inside the external fixture"):
@it.should("still have access to that fixture") # type: ignore[no-redef] # noqa: E501
def test(case): # noqa: F811
assert it.somelayer
#
# To convert the layer definitions into test cases, you have to call
# `createTests` and pass in the module globals, so that the test cases
# and layer objects can be inserted into the module.
#
it.createTests(globals())
#
# Such tests and normal tests can coexist in the same modules.
#
class NormalTest(unittest.TestCase):
def test(self):
pass
The tests it defines are unittest tests, and can be used with nose2
with just the layers plugin. You also have the option of activating a
reporting plugin (nose2.plugins.layers.LayerReporter
) to
provide a more discursive brand of output:
test (test_such.NormalTest) ... ok
A system with complex setup
should do something ... ok
having an expensive fixture
should do more things ... ok
having another precondition
should do that not this ... ok
should do this not that ... ok
having a different precondition
should do something else ... ok
should have another test ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.002s
OK
How does it work?
Such uses the things in python that are most like anonymous code blocks to allow you to construct tests with meaningful names and deeply-nested fixtures. Compared to DSLs in languages that do allow blocks, it is a little bit more verbose – the block-like decorators that mark fixture methods and test cases need to decorate something, so each fixture and test case has to have a function definition. You can use the same function name over and over here, or give each function a meaningful name.
The set of tests begins with a description of the system under test as
a whole, marked with the A
context manager:
from nose2.tools import such
with such.A('system described here') as it:
# ...
Groups of tests are marked by the having
context manager:
with it.having('a description of a group'):
# ...
Within a test group (including the top-level group), fixtures are marked with decorators:
@it.has_setup
def setup():
# ...
@it.has_test_setup
def setup_each_test_case():
# ...
And tests are likewise marked with the should
decorator:
@it.should('exhibit the behavior described here')
def test(case):
# ...
Test cases may optionally take one argument. If they do, they will be
passed the unittest.TestCase
instance generated for the
test. They can use this TestCase
instance to execute assert methods,
among other things. Test functions can also call assert methods on the
top-level scenario instance, if they don’t take the case
argument:
@it.should("be able to use the scenario's assert methods")
def test():
it.assertEqual(something, 'a value')
@it.should("optionally take an argument")
def test(case):
case.assertEqual(case.attribute, 'some value')
Finally, to actually generate tests, you must call createTests
on
the top-level scenario instance:
it.createTests(globals())
This call generates the unittest.TestCase
instances for all
of the tests, and the layer classes that hold the fixtures defined in
the test groups. See Organizing Test Fixtures into Layers for more about test
layers.
Running tests
Since order is often significant in functional tests, such DSL tests always execute in the order in which they are defined in the module. Parent groups run before child groups, and sibling groups and sibling tests within a group execute in the order in which they are defined.
Otherwise, tests written in the such DSL are collected and run just like any
other tests, with one exception: their names. The name of a such test
case is the name of its immediately surrounding group, plus the
description of the test, prepended with test ####:
, where ####
is the test’s (0
-indexed) position within its group.
To run a case individually, you must pass in this full name – usually you’ll have to quote it. For example, to run the case should do more things
defined above (assuming the layers plugin is activated by a config
file, and the test module is in the normal path of test collection),
you would run nose2 like this:
nose2 "test_such.having an expensive fixture.test 0000: should do more things"
That is, for the generated test case, the group description is the class name, and the test case description is the test case name. As you can see if you run an individual test with the layer reporter active, all of the group fixtures execute in proper order when a test is run individually:
$ nose2 "test_such.having an expensive fixture.test 0000: should do more things"
A system with complex setup
having an expensive fixture
should do more things ... ok
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
Reference
- nose2.tools.such.A(description)[source]
Test scenario context manager.
Returns a
nose2.tools.such.Scenario
instance, which by convention is bound toit
:with such.A('test scenario') as it: # tests and fixtures
- class nose2.tools.such.Scenario(description)[source]
A test scenario.
A test scenario defines a set of fixtures and tests that depend on those fixtures.
- createTests(mod)[source]
Generate test cases for this scenario.
Warning
You must call this, passing in
globals()
, to generate tests from the scenario. If you don’t, no tests will be created.it.createTests(globals())
- has_setup(func)[source]
Add a
setup()
method to this group.The
setup()
method will run once, before any of the tests in the containing group.A group may define any number of
setup()
functions. They will execute in the order in which they are defined.@it.has_setup def setup(): # ...
- has_teardown(func)[source]
Add a
teardown()
method to this group.The
teardown()
method will run once, after all of the tests in the containing group.A group may define any number of
teardown()
functions. They will execute in the order in which they are defined.@it.has_teardown def teardown(): # ...
- has_test_setup(func)[source]
Add a test case
setup()
method to this group.The
setup()
method will run before each of the tests in the containing group.A group may define any number of test case
setup()
functions. They will execute in the order in which they are defined.Test
setup()
functions may optionally take one argument. If they do, they will be passed theunittest.TestCase
instance generated for the test.@it.has_test_setup def setup(case): # ...
- has_test_teardown(func)[source]
Add a test case
teardown()
method to this group.The
teardown()
method will run before each of the tests in the containing group.A group may define any number of test case
teardown()
functions. They will execute in the order in which they are defined.Test
teardown()
functions may optionally take one argument. If they do, they will be passed theunittest.TestCase
instance generated for the test.@it.has_test_teardown def teardown(case): # ...
- having(description)[source]
Define a new group under the current group.
Fixtures and tests defined within the block will belong to the new group.
with it.having('a description of this group'): # ...
- should(desc)[source]
Define a test case.
Each function marked with this decorator becomes a test case in the current group.
The decorator takes one optional argument, the description of the test case: what it should do. If this argument is not provided, the docstring of the decorated function will be used as the test case description.
Test functions may optionally take one argument. If they do, they will be passed the
unittest.TestCase
instance generated for the test. They can use this TestCase instance to execute assert methods, among other things.@it.should('do this') def dothis(case): # .... @it.should def dothat(): "do that also" # ....