Out-of-the-box test tools like nose are very useful. But, eventually, we reach a point where the options don't match our needs. Nose has the powerful ability to code custom plugins, that gives us the ability to fine tune nose to meet our needs. This recipe will help us write a plugin that allows us to selectively choose test methods by matching their method names using a regular expression when we run nosetests
.
We need to have easy_install
loaded in order to install the nose plugins which we are about to create. If you don't already have it, please visit http://pypi.python.org/pypi/setuptools to download and install the package as indicated at the site.
If you just installed it now, then you will have to:
virtualenv
used for running code samples in this booknose
using pip
With the following steps, we will code a nose plugin that picks test methods to run by using a regular expression.
recipe13.py
to contain the code for this recipe.class ShoppingCart(object): def __init__(self): self.items = [] def add(self, item, price): self.items.append(Item(item, price)) return self def item(self, index): return self.items[index-1].item def price(self, index): return self.items[index-1].price def total(self, sales_tax): sum_price = sum([item.price for item in self.items]) return sum_price*(1.0 + sales_tax/100.0) def __len__(self): return len(self.items) class Item(object): def __init__(self, item, price): self.item = item self.price = price
test
.import unittest class ShoppingCartTest(unittest.TestCase): def setUp(self): self.cart = ShoppingCart().add("tuna sandwich", 15.00) def length(self): self.assertEquals(1, len(self.cart)) def test_item(self): self.assertEquals("tuna sandwich", self.cart.item(1)) def test_price(self): self.assertEquals(15.00, self.cart.price(1)) def test_total_with_sales_tax(self): self.assertAlmostEquals(16.39, self.cart.total(9.25), 2)
nosetests
from the command line, with verbosity
turned on. How many test methods get run? How many test methods did we define?recipe15_plugin.py
to write a nose plugin for this recipe.sys.stderr
to support debugging and verbose output.import sys err = sys.stderr
RegexPicker
by subclassing nose.plugins.Plugin
.import nose import re from nose.plugins import Plugin class RegexPicker(Plugin): name = "regexpicker" def __init__(self): Plugin.__init__(self) self.verbose = False
Nose plugin requires a class level name. This is used to define the— with-<name>
command-line option.
Plugin.options
and add an option to provide the pattern on the command line.def options(self, parser, env): Plugin.options(self, parser, env) parser.add_option("--re-pattern", dest="pattern", action="store", default=env.get("NOSE_REGEX_PATTERN", "test.*"), help=("Run test methods that have a method name matching this regular expression"))
Plugin.configuration
by having it fetch the pattern and verbosity level from the options.def configure(self, options, conf): Plugin.configure(self, options, conf) self.pattern = options.pattern if options.verbosity >= 2: self.verbose = True if self.enabled: err.write("Pattern for matching test methods is %s " % self.pattern)
When we extend Plugin
, we inherit some other features, like self.enabled
, which is switched on when –with--<name>
is used with nose.
Plugin.wantedMethod
, so that it accepts test methods that match our regular expression.def wantMethod(self, method): wanted = re.match(self.pattern, method.func_name) is not None if self.verbose and wanted: err.write("nose will run %s " % method.func_name) return wanted
Write a test runner that programmatically tests our plugin by running the same test case that we ran earlier. if __name__ == "__main__": args = ["", "recipe13", "--with-regexpicker", "--re-pattern=test.*|length", "--verbosity=2"] print "With verbosity..." print "====================" nose.run(argv=args, plugin=[RegexPicker()]) print "Without verbosity..." print "====================" args = args[:-1] nose.run(argv=args, plugin=[RegexPicker()])
setup.py
script that allows us to install and register our plugin with nosetests
.import sys try: import ez_setup ez_setup.use_setuptools() except ImportError: pass from setuptools import setup setup( name="RegexPicker plugin", version="0.1", author="Greg L. Turnquist", author_email="[email protected]", description="Pick test methods based on a regular expression", license="Apache Server License 2.0", py_modules=["recipe13_plugin"], entry_points = { 'nose.plugins': [ 'recipe13_plugin = recipe13_plugin:RegexPicker' ] } )
nosetests
using --with-regexpicker
from the command line.Writing a nose plugin has some requirements. First of all, we need the class level name
attribute. It is used in several places that also includes defining the command-line switch to invoke our plugin, --with-<name>
.
Next, we write options
. There is no requirement to override Plugin.options
but, in this case, we need a way to supply our plugin with the regular expression. To avoid destroying the useful machinery of Plugin.options
, we call it first, and then add a line for our extra parameter using parser.add_option
.
-rp
and --re-pattern
if we wanted to.Dest
: This is the name of the attribute that stores the results (see configure).Action
: This is specifies what to do with the value of the parameter (store, append, and so on.).Default
: This is specifies what value to store when none is provided (notice we use test.*
to match standard unittest behavior).Help
: Provides help information to print out on the command line.Nose uses Python's optparse.OptionParser
library to define options.
To find out more about Python's optparse.OptionParser please refer to: http://docs.python.org/library/optparse.html.
Then, we write configure
. There is also no requirement to override Plugin.configure
. Because we had an extra option, --pattern
, we need to harvest it. We also want to turn on a flag driven by verbosity
, a standard nose option.
There are many things we can do when writing a nose plugin. In our case, we wanted to zero in on test selection. There are several ways to load tests, including by module, and filename. After loading, they are then run through a method where they are voted in or out. These voters are called the want*
methods and they include wantModule
, wantName
, wantFunction
, and wantMethod
, as well as some others. We implemented wantMethod
where we tested if method.func_name
matches our pattern using Python's re
module. want*
methods. These methods have three return value types:
True
: This test is wantedFalse
: This test is not wanted (and will not be considered by another plugin)None
: The plugin does not care. Another plugin (or nose) gets to choose. This can succinctly be achieved by not returning anything from the want* method.
wantMethod
only looks at functions defined inside classes. nosetests
is geared to find tests by many different methods and is not confined to just searching subclasses of unittest.TestCase
. If tests are found in the module, but not as class methods, then this pattern matching is not utilized. For this plugin to be more robust, we would need a lot of different tests and we would probably need to override the other want*
test selectors.
This recipe just scratches the surface on plugin functionality. It focuses on the test selection process.
Later in this chapter, we will explore generating a specialized report. This involves using other plugin hooks that gather information after each test is run, as well as generating the report after the test suite is exhausted. Nose provides a robust set of hooks allowing detailed customization to meet our changing needs.
Plugins should subclass nose.plugins.Plugin
There is a lot of valuable machinery built into Plugin
. Subclassing is the recommended means of developing a plugin. If you don't, you may have to add on methods and attributes, which – you didn't realize – were needed by nose and that come for free when you subclass.
It's a good rule of thumb to subclass the parts of the nose API that we are plugging into instead of overriding.
Online documentation of the nose API is a little incomplete. It tends to assume too much knowledge of the reader. If we override and our plugin doesn't work correctly, it may be difficult to debug what is happening.