418 lines
13 KiB
Python
418 lines
13 KiB
Python
"""
|
|
Testing Plugins
|
|
===============
|
|
|
|
The plugin interface is well-tested enough to safely unit test your
|
|
use of its hooks with some level of confidence. However, there is also
|
|
a mixin for unittest.TestCase called PluginTester that's designed to
|
|
test plugins in their native runtime environment.
|
|
|
|
Here's a simple example with a do-nothing plugin and a composed suite.
|
|
|
|
>>> import unittest
|
|
>>> from nose.plugins import Plugin, PluginTester
|
|
>>> class FooPlugin(Plugin):
|
|
... pass
|
|
>>> class TestPluginFoo(PluginTester, unittest.TestCase):
|
|
... activate = '--with-foo'
|
|
... plugins = [FooPlugin()]
|
|
... def test_foo(self):
|
|
... for line in self.output:
|
|
... # i.e. check for patterns
|
|
... pass
|
|
...
|
|
... # or check for a line containing ...
|
|
... assert "ValueError" in self.output
|
|
... def makeSuite(self):
|
|
... class TC(unittest.TestCase):
|
|
... def runTest(self):
|
|
... raise ValueError("I hate foo")
|
|
... return [TC('runTest')]
|
|
...
|
|
>>> res = unittest.TestResult()
|
|
>>> case = TestPluginFoo('test_foo')
|
|
>>> _ = case(res)
|
|
>>> res.errors
|
|
[]
|
|
>>> res.failures
|
|
[]
|
|
>>> res.wasSuccessful()
|
|
True
|
|
>>> res.testsRun
|
|
1
|
|
|
|
And here is a more complex example of testing a plugin that has extra
|
|
arguments and reads environment variables.
|
|
|
|
>>> import unittest, os
|
|
>>> from nose.plugins import Plugin, PluginTester
|
|
>>> class FancyOutputter(Plugin):
|
|
... name = "fancy"
|
|
... def configure(self, options, conf):
|
|
... Plugin.configure(self, options, conf)
|
|
... if not self.enabled:
|
|
... return
|
|
... self.fanciness = 1
|
|
... if options.more_fancy:
|
|
... self.fanciness = 2
|
|
... if 'EVEN_FANCIER' in self.env:
|
|
... self.fanciness = 3
|
|
...
|
|
... def options(self, parser, env=os.environ):
|
|
... self.env = env
|
|
... parser.add_option('--more-fancy', action='store_true')
|
|
... Plugin.options(self, parser, env=env)
|
|
...
|
|
... def report(self, stream):
|
|
... stream.write("FANCY " * self.fanciness)
|
|
...
|
|
>>> class TestFancyOutputter(PluginTester, unittest.TestCase):
|
|
... activate = '--with-fancy' # enables the plugin
|
|
... plugins = [FancyOutputter()]
|
|
... args = ['--more-fancy']
|
|
... env = {'EVEN_FANCIER': '1'}
|
|
...
|
|
... def test_fancy_output(self):
|
|
... assert "FANCY FANCY FANCY" in self.output, (
|
|
... "got: %s" % self.output)
|
|
... def makeSuite(self):
|
|
... class TC(unittest.TestCase):
|
|
... def runTest(self):
|
|
... raise ValueError("I hate fancy stuff")
|
|
... return [TC('runTest')]
|
|
...
|
|
>>> res = unittest.TestResult()
|
|
>>> case = TestFancyOutputter('test_fancy_output')
|
|
>>> _ = case(res)
|
|
>>> res.errors
|
|
[]
|
|
>>> res.failures
|
|
[]
|
|
>>> res.wasSuccessful()
|
|
True
|
|
>>> res.testsRun
|
|
1
|
|
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from warnings import warn
|
|
|
|
try:
|
|
from io import StringIO
|
|
except ImportError:
|
|
from io import StringIO
|
|
|
|
__all__ = ['PluginTester', 'run']
|
|
|
|
from os import getpid
|
|
class MultiProcessFile(object):
|
|
"""
|
|
helper for testing multiprocessing
|
|
|
|
multiprocessing poses a problem for doctests, since the strategy
|
|
of replacing sys.stdout/stderr with file-like objects then
|
|
inspecting the results won't work: the child processes will
|
|
write to the objects, but the data will not be reflected
|
|
in the parent doctest-ing process.
|
|
|
|
The solution is to create file-like objects which will interact with
|
|
multiprocessing in a more desirable way.
|
|
|
|
All processes can write to this object, but only the creator can read.
|
|
This allows the testing system to see a unified picture of I/O.
|
|
"""
|
|
def __init__(self):
|
|
# per advice at:
|
|
# http://docs.python.org/library/multiprocessing.html#all-platforms
|
|
self.__master = getpid()
|
|
self.__queue = Manager().Queue()
|
|
self.__buffer = StringIO()
|
|
self.softspace = 0
|
|
|
|
def buffer(self):
|
|
if getpid() != self.__master:
|
|
return
|
|
|
|
from queue import Empty
|
|
from collections import defaultdict
|
|
cache = defaultdict(str)
|
|
while True:
|
|
try:
|
|
pid, data = self.__queue.get_nowait()
|
|
except Empty:
|
|
break
|
|
if pid == ():
|
|
#show parent output after children
|
|
#this is what users see, usually
|
|
pid = ( 1e100, ) # googol!
|
|
cache[pid] += data
|
|
for pid in sorted(cache):
|
|
#self.__buffer.write( '%s wrote: %r\n' % (pid, cache[pid]) ) #DEBUG
|
|
self.__buffer.write( cache[pid] )
|
|
def write(self, data):
|
|
# note that these pids are in the form of current_process()._identity
|
|
# rather than OS pids
|
|
from multiprocessing import current_process
|
|
pid = current_process()._identity
|
|
self.__queue.put((pid, data))
|
|
def __iter__(self):
|
|
"getattr doesn't work for iter()"
|
|
self.buffer()
|
|
return self.__buffer
|
|
def seek(self, offset, whence=0):
|
|
self.buffer()
|
|
return self.__buffer.seek(offset, whence)
|
|
def getvalue(self):
|
|
self.buffer()
|
|
return self.__buffer.getvalue()
|
|
def __getattr__(self, attr):
|
|
return getattr(self.__buffer, attr)
|
|
|
|
try:
|
|
from multiprocessing import Manager
|
|
Buffer = MultiProcessFile
|
|
except ImportError:
|
|
Buffer = StringIO
|
|
|
|
class PluginTester(object):
|
|
"""A mixin for testing nose plugins in their runtime environment.
|
|
|
|
Subclass this and mix in unittest.TestCase to run integration/functional
|
|
tests on your plugin. When setUp() is called, the stub test suite is
|
|
executed with your plugin so that during an actual test you can inspect the
|
|
artifacts of how your plugin interacted with the stub test suite.
|
|
|
|
- activate
|
|
|
|
- the argument to send nosetests to activate the plugin
|
|
|
|
- suitepath
|
|
|
|
- if set, this is the path of the suite to test. Otherwise, you
|
|
will need to use the hook, makeSuite()
|
|
|
|
- plugins
|
|
|
|
- the list of plugins to make available during the run. Note
|
|
that this does not mean these plugins will be *enabled* during
|
|
the run -- only the plugins enabled by the activate argument
|
|
or other settings in argv or env will be enabled.
|
|
|
|
- args
|
|
|
|
- a list of arguments to add to the nosetests command, in addition to
|
|
the activate argument
|
|
|
|
- env
|
|
|
|
- optional dict of environment variables to send nosetests
|
|
|
|
"""
|
|
activate = None
|
|
suitepath = None
|
|
args = None
|
|
env = {}
|
|
argv = None
|
|
plugins = []
|
|
ignoreFiles = None
|
|
|
|
def makeSuite(self):
|
|
"""returns a suite object of tests to run (unittest.TestSuite())
|
|
|
|
If self.suitepath is None, this must be implemented. The returned suite
|
|
object will be executed with all plugins activated. It may return
|
|
None.
|
|
|
|
Here is an example of a basic suite object you can return ::
|
|
|
|
>>> import unittest
|
|
>>> class SomeTest(unittest.TestCase):
|
|
... def runTest(self):
|
|
... raise ValueError("Now do something, plugin!")
|
|
...
|
|
>>> unittest.TestSuite([SomeTest()]) # doctest: +ELLIPSIS
|
|
<unittest...TestSuite tests=[<...SomeTest testMethod=runTest>]>
|
|
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def _execPlugin(self):
|
|
"""execute the plugin on the internal test suite.
|
|
"""
|
|
from nose.config import Config
|
|
from nose.core import TestProgram
|
|
from nose.plugins.manager import PluginManager
|
|
|
|
suite = None
|
|
stream = Buffer()
|
|
conf = Config(env=self.env,
|
|
stream=stream,
|
|
plugins=PluginManager(plugins=self.plugins))
|
|
if self.ignoreFiles is not None:
|
|
conf.ignoreFiles = self.ignoreFiles
|
|
if not self.suitepath:
|
|
suite = self.makeSuite()
|
|
|
|
self.nose = TestProgram(argv=self.argv, config=conf, suite=suite,
|
|
exit=False)
|
|
self.output = AccessDecorator(stream)
|
|
|
|
def setUp(self):
|
|
"""runs nosetests with the specified test suite, all plugins
|
|
activated.
|
|
"""
|
|
self.argv = ['nosetests', self.activate]
|
|
if self.args:
|
|
self.argv.extend(self.args)
|
|
if self.suitepath:
|
|
self.argv.append(self.suitepath)
|
|
|
|
self._execPlugin()
|
|
|
|
|
|
class AccessDecorator(object):
|
|
stream = None
|
|
_buf = None
|
|
def __init__(self, stream):
|
|
self.stream = stream
|
|
stream.seek(0)
|
|
self._buf = stream.read()
|
|
stream.seek(0)
|
|
def __contains__(self, val):
|
|
return val in self._buf
|
|
def __iter__(self):
|
|
return iter(self.stream)
|
|
def __str__(self):
|
|
return self._buf
|
|
|
|
|
|
def blankline_separated_blocks(text):
|
|
"a bunch of === characters is also considered a blank line"
|
|
block = []
|
|
for line in text.splitlines(True):
|
|
block.append(line)
|
|
line = line.strip()
|
|
if not line or line.startswith('===') and not line.strip('='):
|
|
yield "".join(block)
|
|
block = []
|
|
if block:
|
|
yield "".join(block)
|
|
|
|
|
|
def remove_stack_traces(out):
|
|
# this regexp taken from Python 2.5's doctest
|
|
traceback_re = re.compile(r"""
|
|
# Grab the traceback header. Different versions of Python have
|
|
# said different things on the first traceback line.
|
|
^(?P<hdr> Traceback\ \(
|
|
(?: most\ recent\ call\ last
|
|
| innermost\ last
|
|
) \) :
|
|
)
|
|
\s* $ # toss trailing whitespace on the header.
|
|
(?P<stack> .*?) # don't blink: absorb stuff until...
|
|
^(?=\w) # a line *starts* with alphanum.
|
|
.*?(?P<exception> \w+ ) # exception name
|
|
(?P<msg> [:\n] .*) # the rest
|
|
""", re.VERBOSE | re.MULTILINE | re.DOTALL)
|
|
blocks = []
|
|
for block in blankline_separated_blocks(out):
|
|
blocks.append(traceback_re.sub(r"\g<hdr>\n...\n\g<exception>\g<msg>", block))
|
|
return "".join(blocks)
|
|
|
|
|
|
def simplify_warnings(out):
|
|
warn_re = re.compile(r"""
|
|
# Cut the file and line no, up to the warning name
|
|
^.*:\d+:\s
|
|
(?P<category>\w+): \s+ # warning category
|
|
(?P<detail>.+) $ \n? # warning message
|
|
^ .* $ # stack frame
|
|
""", re.VERBOSE | re.MULTILINE)
|
|
return warn_re.sub(r"\g<category>: \g<detail>", out)
|
|
|
|
|
|
def remove_timings(out):
|
|
return re.sub(
|
|
r"Ran (\d+ tests?) in [0-9.]+s", r"Ran \1 in ...s", out)
|
|
|
|
|
|
def munge_nose_output_for_doctest(out):
|
|
"""Modify nose output to make it easy to use in doctests."""
|
|
out = remove_stack_traces(out)
|
|
out = simplify_warnings(out)
|
|
out = remove_timings(out)
|
|
return out.strip()
|
|
|
|
|
|
def run(*arg, **kw):
|
|
"""
|
|
Specialized version of nose.run for use inside of doctests that
|
|
test test runs.
|
|
|
|
This version of run() prints the result output to stdout. Before
|
|
printing, the output is processed by replacing the timing
|
|
information with an ellipsis (...), removing traceback stacks, and
|
|
removing trailing whitespace.
|
|
|
|
Use this version of run wherever you are writing a doctest that
|
|
tests nose (or unittest) test result output.
|
|
|
|
Note: do not use doctest: +ELLIPSIS when testing nose output,
|
|
since ellipses ("test_foo ... ok") in your expected test runner
|
|
output may match multiple lines of output, causing spurious test
|
|
passes!
|
|
"""
|
|
from nose import run
|
|
from nose.config import Config
|
|
from nose.plugins.manager import PluginManager
|
|
|
|
buffer = Buffer()
|
|
if 'config' not in kw:
|
|
plugins = kw.pop('plugins', [])
|
|
if isinstance(plugins, list):
|
|
plugins = PluginManager(plugins=plugins)
|
|
env = kw.pop('env', {})
|
|
kw['config'] = Config(env=env, plugins=plugins)
|
|
if 'argv' not in kw:
|
|
kw['argv'] = ['nosetests', '-v']
|
|
kw['config'].stream = buffer
|
|
|
|
# Set up buffering so that all output goes to our buffer,
|
|
# or warn user if deprecated behavior is active. If this is not
|
|
# done, prints and warnings will either be out of place or
|
|
# disappear.
|
|
stderr = sys.stderr
|
|
stdout = sys.stdout
|
|
if kw.pop('buffer_all', False):
|
|
sys.stdout = sys.stderr = buffer
|
|
restore = True
|
|
else:
|
|
restore = False
|
|
warn("The behavior of nose.plugins.plugintest.run() will change in "
|
|
"the next release of nose. The current behavior does not "
|
|
"correctly account for output to stdout and stderr. To enable "
|
|
"correct behavior, use run_buffered() instead, or pass "
|
|
"the keyword argument buffer_all=True to run().",
|
|
DeprecationWarning, stacklevel=2)
|
|
try:
|
|
run(*arg, **kw)
|
|
finally:
|
|
if restore:
|
|
sys.stderr = stderr
|
|
sys.stdout = stdout
|
|
out = buffer.getvalue()
|
|
print(munge_nose_output_for_doctest(out))
|
|
|
|
|
|
def run_buffered(*arg, **kw):
|
|
kw['buffer_all'] = True
|
|
run(*arg, **kw)
|
|
|
|
if __name__ == '__main__':
|
|
import doctest
|
|
doctest.testmod()
|
|
|