| 1 | """ |
|---|
| 2 | Testing Plugins |
|---|
| 3 | =============== |
|---|
| 4 | |
|---|
| 5 | The plugin interface is well-tested enough to safely unit test your |
|---|
| 6 | use of its hooks with some level of confidence. However, there is also |
|---|
| 7 | a mixin for unittest.TestCase called PluginTester that's designed to |
|---|
| 8 | test plugins in their native runtime environment. |
|---|
| 9 | |
|---|
| 10 | Here's a simple example with a do-nothing plugin and a composed suite. |
|---|
| 11 | |
|---|
| 12 | >>> import unittest |
|---|
| 13 | >>> from nose.plugins import Plugin, PluginTester |
|---|
| 14 | >>> class FooPlugin(Plugin): |
|---|
| 15 | ... pass |
|---|
| 16 | >>> class TestPluginFoo(PluginTester, unittest.TestCase): |
|---|
| 17 | ... activate = '--with-foo' |
|---|
| 18 | ... plugins = [FooPlugin()] |
|---|
| 19 | ... def test_foo(self): |
|---|
| 20 | ... for line in self.output: |
|---|
| 21 | ... # i.e. check for patterns |
|---|
| 22 | ... pass |
|---|
| 23 | ... |
|---|
| 24 | ... # or check for a line containing ... |
|---|
| 25 | ... assert "ValueError" in self.output |
|---|
| 26 | ... def makeSuite(self): |
|---|
| 27 | ... class TC(unittest.TestCase): |
|---|
| 28 | ... def runTest(self): |
|---|
| 29 | ... raise ValueError("I hate foo") |
|---|
| 30 | ... return unittest.TestSuite([TC()]) |
|---|
| 31 | ... |
|---|
| 32 | >>> res = unittest.TestResult() |
|---|
| 33 | >>> case = TestPluginFoo('test_foo') |
|---|
| 34 | >>> case(res) |
|---|
| 35 | >>> res.errors |
|---|
| 36 | [] |
|---|
| 37 | >>> res.failures |
|---|
| 38 | [] |
|---|
| 39 | >>> res.wasSuccessful() |
|---|
| 40 | True |
|---|
| 41 | >>> res.testsRun |
|---|
| 42 | 1 |
|---|
| 43 | |
|---|
| 44 | And here is a more complex example of testing a plugin that has extra |
|---|
| 45 | arguments and reads environment variables. |
|---|
| 46 | |
|---|
| 47 | >>> import unittest, os |
|---|
| 48 | >>> from nose.plugins import Plugin, PluginTester |
|---|
| 49 | >>> class FancyOutputter(Plugin): |
|---|
| 50 | ... name = "fancy" |
|---|
| 51 | ... def configure(self, options, conf): |
|---|
| 52 | ... Plugin.configure(self, options, conf) |
|---|
| 53 | ... if not self.enabled: |
|---|
| 54 | ... return |
|---|
| 55 | ... self.fanciness = 1 |
|---|
| 56 | ... if options.more_fancy: |
|---|
| 57 | ... self.fanciness = 2 |
|---|
| 58 | ... if 'EVEN_FANCIER' in self.env: |
|---|
| 59 | ... self.fanciness = 3 |
|---|
| 60 | ... |
|---|
| 61 | ... def options(self, parser, env=os.environ): |
|---|
| 62 | ... self.env = env |
|---|
| 63 | ... parser.add_option('--more-fancy', action='store_true') |
|---|
| 64 | ... Plugin.options(self, parser, env=env) |
|---|
| 65 | ... |
|---|
| 66 | ... def report(self, stream): |
|---|
| 67 | ... stream.write("FANCY " * self.fanciness) |
|---|
| 68 | ... |
|---|
| 69 | >>> class TestFancyOutputter(PluginTester, unittest.TestCase): |
|---|
| 70 | ... activate = '--with-fancy' # enables the plugin |
|---|
| 71 | ... plugins = [FancyOutputter()] |
|---|
| 72 | ... args = ['--more-fancy'] |
|---|
| 73 | ... env = {'EVEN_FANCIER': '1'} |
|---|
| 74 | ... |
|---|
| 75 | ... def test_fancy_output(self): |
|---|
| 76 | ... assert "FANCY FANCY FANCY" in self.output, ( |
|---|
| 77 | ... "got: %s" % self.output) |
|---|
| 78 | ... def makeSuite(self): |
|---|
| 79 | ... class TC(unittest.TestCase): |
|---|
| 80 | ... def runTest(self): |
|---|
| 81 | ... raise ValueError("I hate fancy stuff") |
|---|
| 82 | ... return unittest.TestSuite([TC()]) |
|---|
| 83 | ... |
|---|
| 84 | >>> res = unittest.TestResult() |
|---|
| 85 | >>> case = TestFancyOutputter('test_fancy_output') |
|---|
| 86 | >>> case(res) |
|---|
| 87 | >>> res.errors |
|---|
| 88 | [] |
|---|
| 89 | >>> res.failures |
|---|
| 90 | [] |
|---|
| 91 | >>> res.wasSuccessful() |
|---|
| 92 | True |
|---|
| 93 | >>> res.testsRun |
|---|
| 94 | 1 |
|---|
| 95 | |
|---|
| 96 | """ |
|---|
| 97 | |
|---|
| 98 | import re |
|---|
| 99 | import sys |
|---|
| 100 | from warnings import warn |
|---|
| 101 | |
|---|
| 102 | try: |
|---|
| 103 | from cStringIO import StringIO |
|---|
| 104 | except ImportError: |
|---|
| 105 | from StringIO import StringIO |
|---|
| 106 | |
|---|
| 107 | __all__ = ['PluginTester', 'run'] |
|---|
| 108 | |
|---|
| 109 | |
|---|
| 110 | class PluginTester(object): |
|---|
| 111 | """A mixin for testing nose plugins in their runtime environment. |
|---|
| 112 | |
|---|
| 113 | Subclass this and mix in unittest.TestCase to run integration/functional |
|---|
| 114 | tests on your plugin. When setUp() is called, the stub test suite is |
|---|
| 115 | executed with your plugin so that during an actual test you can inspect the |
|---|
| 116 | artifacts of how your plugin interacted with the stub test suite. |
|---|
| 117 | |
|---|
| 118 | - activate |
|---|
| 119 | |
|---|
| 120 | - the argument to send nosetests to activate the plugin |
|---|
| 121 | |
|---|
| 122 | - suitepath |
|---|
| 123 | |
|---|
| 124 | - if set, this is the path of the suite to test. Otherwise, you |
|---|
| 125 | will need to use the hook, makeSuite() |
|---|
| 126 | |
|---|
| 127 | - plugins |
|---|
| 128 | |
|---|
| 129 | - the list of plugins to make available during the run. Note |
|---|
| 130 | that this does not mean these plugins will be *enabled* during |
|---|
| 131 | the run -- only the plugins enabled by the activate argument |
|---|
| 132 | or other settings in argv or env will be enabled. |
|---|
| 133 | |
|---|
| 134 | - args |
|---|
| 135 | |
|---|
| 136 | - a list of arguments to add to the nosetests command, in addition to |
|---|
| 137 | the activate argument |
|---|
| 138 | |
|---|
| 139 | - env |
|---|
| 140 | |
|---|
| 141 | - optional dict of environment variables to send nosetests |
|---|
| 142 | |
|---|
| 143 | """ |
|---|
| 144 | activate = None |
|---|
| 145 | suitepath = None |
|---|
| 146 | args = None |
|---|
| 147 | env = {} |
|---|
| 148 | argv = None |
|---|
| 149 | plugins = [] |
|---|
| 150 | ignoreFiles = None |
|---|
| 151 | |
|---|
| 152 | def makeSuite(self): |
|---|
| 153 | """returns a suite object of tests to run (unittest.TestSuite()) |
|---|
| 154 | |
|---|
| 155 | If self.suitepath is None, this must be implemented. The returned suite |
|---|
| 156 | object will be executed with all plugins activated. It may return |
|---|
| 157 | None. |
|---|
| 158 | |
|---|
| 159 | Here is an example of a basic suite object you can return :: |
|---|
| 160 | |
|---|
| 161 | >>> import unittest |
|---|
| 162 | >>> class SomeTest(unittest.TestCase): |
|---|
| 163 | ... def runTest(self): |
|---|
| 164 | ... raise ValueError("Now do something, plugin!") |
|---|
| 165 | ... |
|---|
| 166 | >>> unittest.TestSuite([SomeTest()]) # doctest: +ELLIPSIS |
|---|
| 167 | <unittest.TestSuite tests=[<...SomeTest testMethod=runTest>]> |
|---|
| 168 | |
|---|
| 169 | """ |
|---|
| 170 | raise NotImplementedError |
|---|
| 171 | |
|---|
| 172 | def _execPlugin(self): |
|---|
| 173 | """execute the plugin on the internal test suite. |
|---|
| 174 | """ |
|---|
| 175 | from nose.config import Config |
|---|
| 176 | from nose.core import TestProgram |
|---|
| 177 | from nose.plugins.manager import PluginManager |
|---|
| 178 | |
|---|
| 179 | suite = None |
|---|
| 180 | stream = StringIO() |
|---|
| 181 | conf = Config(env=self.env, |
|---|
| 182 | stream=stream, |
|---|
| 183 | plugins=PluginManager(plugins=self.plugins)) |
|---|
| 184 | if self.ignoreFiles is not None: |
|---|
| 185 | conf.ignoreFiles = self.ignoreFiles |
|---|
| 186 | if not self.suitepath: |
|---|
| 187 | suite = self.makeSuite() |
|---|
| 188 | |
|---|
| 189 | self.nose = TestProgram(argv=self.argv, config=conf, suite=suite, |
|---|
| 190 | exit=False) |
|---|
| 191 | self.output = AccessDecorator(stream) |
|---|
| 192 | |
|---|
| 193 | def setUp(self): |
|---|
| 194 | """runs nosetests with the specified test suite, all plugins |
|---|
| 195 | activated. |
|---|
| 196 | """ |
|---|
| 197 | self.argv = ['nosetests', self.activate] |
|---|
| 198 | if self.args: |
|---|
| 199 | self.argv.extend(self.args) |
|---|
| 200 | if self.suitepath: |
|---|
| 201 | self.argv.append(self.suitepath) |
|---|
| 202 | |
|---|
| 203 | self._execPlugin() |
|---|
| 204 | |
|---|
| 205 | |
|---|
| 206 | class AccessDecorator(object): |
|---|
| 207 | stream = None |
|---|
| 208 | _buf = None |
|---|
| 209 | def __init__(self, stream): |
|---|
| 210 | self.stream = stream |
|---|
| 211 | stream.seek(0) |
|---|
| 212 | self._buf = stream.read() |
|---|
| 213 | stream.seek(0) |
|---|
| 214 | def __contains__(self, val): |
|---|
| 215 | return val in self._buf |
|---|
| 216 | def __iter__(self): |
|---|
| 217 | return self.stream |
|---|
| 218 | def __str__(self): |
|---|
| 219 | return self._buf |
|---|
| 220 | |
|---|
| 221 | |
|---|
| 222 | def blankline_separated_blocks(text): |
|---|
| 223 | block = [] |
|---|
| 224 | for line in text.splitlines(True): |
|---|
| 225 | block.append(line) |
|---|
| 226 | if not line.strip(): |
|---|
| 227 | yield "".join(block) |
|---|
| 228 | block = [] |
|---|
| 229 | if block: |
|---|
| 230 | yield "".join(block) |
|---|
| 231 | |
|---|
| 232 | |
|---|
| 233 | def remove_stack_traces(out): |
|---|
| 234 | # this regexp taken from Python 2.5's doctest |
|---|
| 235 | traceback_re = re.compile(r""" |
|---|
| 236 | # Grab the traceback header. Different versions of Python have |
|---|
| 237 | # said different things on the first traceback line. |
|---|
| 238 | ^(?P<hdr> Traceback\ \( |
|---|
| 239 | (?: most\ recent\ call\ last |
|---|
| 240 | | innermost\ last |
|---|
| 241 | ) \) : |
|---|
| 242 | ) |
|---|
| 243 | \s* $ # toss trailing whitespace on the header. |
|---|
| 244 | (?P<stack> .*?) # don't blink: absorb stuff until... |
|---|
| 245 | ^ (?P<msg> \w+ .*) # a line *starts* with alphanum. |
|---|
| 246 | """, re.VERBOSE | re.MULTILINE | re.DOTALL) |
|---|
| 247 | blocks = [] |
|---|
| 248 | for block in blankline_separated_blocks(out): |
|---|
| 249 | blocks.append(traceback_re.sub(r"\g<hdr>\n...\n\g<msg>", block)) |
|---|
| 250 | return "".join(blocks) |
|---|
| 251 | |
|---|
| 252 | |
|---|
| 253 | def simplify_warnings(out): |
|---|
| 254 | warn_re = re.compile(r""" |
|---|
| 255 | # Cut the file and line no, up to the warning name |
|---|
| 256 | ^.*:\d+:\s |
|---|
| 257 | (?P<category>\w+): \s+ # warning category |
|---|
| 258 | (?P<detail>.+) $ \n? # warning message |
|---|
| 259 | ^ .* $ # stack frame |
|---|
| 260 | """, re.VERBOSE | re.MULTILINE) |
|---|
| 261 | return warn_re.sub(r"\g<category>: \g<detail>", out) |
|---|
| 262 | |
|---|
| 263 | |
|---|
| 264 | def remove_timings(out): |
|---|
| 265 | return re.sub( |
|---|
| 266 | r"Ran (\d+ tests?) in [0-9.]+s", r"Ran \1 in ...s", out) |
|---|
| 267 | |
|---|
| 268 | |
|---|
| 269 | def munge_nose_output_for_doctest(out): |
|---|
| 270 | """Modify nose output to make it easy to use in doctests.""" |
|---|
| 271 | out = remove_stack_traces(out) |
|---|
| 272 | out = simplify_warnings(out) |
|---|
| 273 | out = remove_timings(out) |
|---|
| 274 | return out.strip() |
|---|
| 275 | |
|---|
| 276 | |
|---|
| 277 | def run(*arg, **kw): |
|---|
| 278 | """ |
|---|
| 279 | Specialized version of nose.run for use inside of doctests that |
|---|
| 280 | test test runs. |
|---|
| 281 | |
|---|
| 282 | This version of run() prints the result output to stdout. Before |
|---|
| 283 | printing, the output is processed by replacing the timing |
|---|
| 284 | information with an ellipsis (...), removing traceback stacks, and |
|---|
| 285 | removing trailing whitespace. |
|---|
| 286 | |
|---|
| 287 | Use this version of run wherever you are writing a doctest that |
|---|
| 288 | tests nose (or unittest) test result output. |
|---|
| 289 | |
|---|
| 290 | Note: do not use doctest: +ELLIPSIS when testing nose output, |
|---|
| 291 | since ellipses ("test_foo ... ok") in your expected test runner |
|---|
| 292 | output may match multiple lines of output, causing spurious test |
|---|
| 293 | passes! |
|---|
| 294 | """ |
|---|
| 295 | from nose import run |
|---|
| 296 | from nose.config import Config |
|---|
| 297 | from nose.plugins.manager import PluginManager |
|---|
| 298 | |
|---|
| 299 | buffer = StringIO() |
|---|
| 300 | if 'config' not in kw: |
|---|
| 301 | plugins = kw.pop('plugins', []) |
|---|
| 302 | if isinstance(plugins, list): |
|---|
| 303 | plugins = PluginManager(plugins=plugins) |
|---|
| 304 | env = kw.pop('env', {}) |
|---|
| 305 | kw['config'] = Config(env=env, plugins=plugins) |
|---|
| 306 | if 'argv' not in kw: |
|---|
| 307 | kw['argv'] = ['nosetests', '-v'] |
|---|
| 308 | kw['config'].stream = buffer |
|---|
| 309 | |
|---|
| 310 | # Set up buffering so that all output goes to our buffer, |
|---|
| 311 | # or warn user if deprecated behavior is active. If this is not |
|---|
| 312 | # done, prints and warnings will either be out of place or |
|---|
| 313 | # disappear. |
|---|
| 314 | stderr = sys.stderr |
|---|
| 315 | stdout = sys.stdout |
|---|
| 316 | if kw.pop('buffer_all', False): |
|---|
| 317 | sys.stdout = sys.stderr = buffer |
|---|
| 318 | restore = True |
|---|
| 319 | else: |
|---|
| 320 | restore = False |
|---|
| 321 | warn("The behavior of nose.plugins.plugintest.run() will change in " |
|---|
| 322 | "the next release of nose. The current behavior does not " |
|---|
| 323 | "correctly account for output to stdout and stderr. To enable " |
|---|
| 324 | "correct behavior, use run_buffered() instead, or pass " |
|---|
| 325 | "the keyword argument buffer_all=True to run().", |
|---|
| 326 | DeprecationWarning, stacklevel=2) |
|---|
| 327 | try: |
|---|
| 328 | run(*arg, **kw) |
|---|
| 329 | finally: |
|---|
| 330 | if restore: |
|---|
| 331 | sys.stderr = stderr |
|---|
| 332 | sys.stdout = stdout |
|---|
| 333 | out = buffer.getvalue() |
|---|
| 334 | print munge_nose_output_for_doctest(out) |
|---|
| 335 | |
|---|
| 336 | |
|---|
| 337 | def run_buffered(*arg, **kw): |
|---|
| 338 | kw['buffer_all'] = True |
|---|
| 339 | run(*arg, **kw) |
|---|
| 340 | |
|---|
| 341 | if __name__ == '__main__': |
|---|
| 342 | import doctest |
|---|
| 343 | doctest.testmod() |
|---|