diff --git a/CHANGES.rst b/CHANGES.rst index 7119ed3..982a48b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,10 @@ Changes ======= +- Added support for creating doctests as methods of + ``unittest.TestCase`` classes so that they can found automatically + by test runners, like *nose* that ignore test suites. + 4.2.1 (unreleased) ------------------ diff --git a/README.rst b/README.rst index fdd08cf..72ed8dd 100644 --- a/README.rst +++ b/README.rst @@ -61,6 +61,13 @@ wait A small utility for dealing with timing non-determinism See wait.txt. +doctestcase + Support for defining doctests as methods of ``unittest.TestCase`` + classes so that they can be more easily found by test runners, like + nose, that ignore test suites. + +.. contents:: + Getting started --------------- diff --git a/buildout.cfg b/buildout.cfg index 00d0ad1..69ddab0 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -1,9 +1,15 @@ [buildout] develop = . -parts = test +parts = py unzip = true [test] recipe = zc.recipe.testrunner eggs = zope.testing + +[py] +recipe = zc.recipe.egg +eggs = ${test:eggs} + nose +interpreter = py diff --git a/setup.py b/setup.py index 93fb608..cd989de 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ def read(*rnames): 'renormalizing.txt', 'setupstack.txt', 'wait.txt', + 'doctestcase.txt', ]] long_description='\n\n'.join( diff --git a/src/zope/testing/doctestcase.py b/src/zope/testing/doctestcase.py new file mode 100644 index 0000000..b269a63 --- /dev/null +++ b/src/zope/testing/doctestcase.py @@ -0,0 +1,224 @@ +r"""Doctests in TestCase classes + +The original ``doctest`` unittest integration was based on +``unittest`` test suites, which have fallen out of favor. This module +provides a way to define doctests inside of unittest ``TestCase`` +classes. It also provides better integration with unittest test +fixtures, because doctests use setup provided by the containing test +case class. It also provides access to unittest assertion +methods. + +You can define doctests in 4 ways: + +- references to named files + +- strings + +- decorated functions with docstrings + +- reference to named files decorating test-specific setup functions + +.. some setup + + >>> __name__ = 'tests' + +Here are some examples:: + + from zope.testing import doctestcase + import doctest + import unittest + + g = 'global' + class MyTest(unittest.TestCase): + + def setUp(self): + self.a = 1 + self.globs = dict(c=9) + + test1 = doctestcase.file('test1.txt', optionflags=doctest.ELLIPSIS) + + test2 = doctestcase.docteststring(''' + >>> self.a, g, c + (1, 'global', 9) + ''') + + @doctestcase.doctestmethod(optionflags=doctest.ELLIPSIS) + def test3(self): + ''' + >>> self.a, self.x, g, c + (1, 3, 'global', 9) + ''' + self.x = 3 + + @doctestcase.doctestfile('test4.txt') + def test4(self): + self.x = 5 + +In this example, 3 constructors were used: + +doctestfile (alias: file) + doctestfile makes a file-based test case. + + This can be used as a decorator, in which case, the decorated + function is called before the test is run, to provide test-specific + setup. + +docteststring (alias string) + docteststring constructs a doctest from a string. + +doctestmethod (alias method) + doctestmethod constructs a doctest from a method. + + The method's docstring provides the test. The method's body provides + optional test-specific setup. + +Note that short aliases are provided, which may be useful in certain +import styles. + +Tests have access to the following data: + +- Tests created with the ``docteststring`` and ``doctestmethod`` + constructors have access to the module globals of the defining + module. + +- In tests created with the ``docteststring`` and ``doctestmethod`` + constructors, the test case instance is available as the ``self`` + variable. + +- In tests created with the ``doctestfile`` constructor, the test case + instance is available as the ``test`` variable. + +- If a test case defines a globs attribute, it must be a dictionary + and its contents are added to the test globals. + +The constructors accept standard doctest ``optionflags`` and +``checker`` arguments. + +Note that the doctest IGNORE_EXCEPTION_DETAIL option flag is +added to optionflags. +""" +import doctest +import inspect +import os +import re +import sys +import types + +__all__ = ['doctestmethod', 'docteststring', 'doctestfile'] + +_parser = doctest.DocTestParser() + +def doctestmethod(test=None, optionflags=0, checker=None): + """Define a doctest from a method within a unittest.TestCase. + + The method's doc string provides the test source. Its body is + called before the test and may perform test-specific setup. + + You can pass doctest option flags and a custon checker. + + Variables defined in the enclosing module are available in the test. + + If a test case defines a globs attribute, it must be a dictionary + and its contents are added to the test globals. + + The test object is available as the variable ``self`` in the test. + """ + if test is None: + return lambda test: _doctestmethod(test, optionflags, checker) + + return _doctestmethod(test, optionflags, checker) + +method = doctestmethod + +def _doctestmethod(test, optionflags, checker): + doc = test.__doc__ + if not doc: + raise ValueError(test, "has no docstring") + setup = test + name = test.__name__ + path = inspect.getsourcefile(test) + lineno = inspect.getsourcelines(test)[1] + + fglobs = sys._getframe(3).f_globals + + def test_method(self): + setup(self) + _run_test(self, doc, fglobs.copy(), name, path, + optionflags, checker, lineno=lineno) + + return test_method + +def docteststring(test, optionflags=0, checker=None): + """Define a doctest from a string within a unittest.TestCase. + + You can pass doctest option flags and a custon checker. + + Variables defined in the enclosing module are available in the test. + + If a test case defines a globs attribute, it must be a dictionary + and its contents are added to the test globals. + + The test object is available as the variable ``self`` in the test. + """ + fglobs = sys._getframe(2).f_globals + + def test_string(self): + _run_test(self, test, fglobs.copy(), '', '', + optionflags, checker) + + return test_string + +string = docteststring + +def doctestfile(path, optionflags=0, checker=None): + """Define a doctest from a test file within a unittest.TestCase. + + The file path may be relative or absolute. If its relative (the + common case), it will be interpreted relative to the directory + containing the referencing module. + + You can pass doctest option flags and a custon checker. + + If a test case defines a globs attribute, it must be a dictionary + and its contents are added to the test globals. + + The test object is available as the variable ``self`` in the test. + + The resulting object can be used as a function decorator. The + decorated method is called before the test and may perform + test-specific setup. (The decorated method's doc string is ignored.) + """ + base = os.path.dirname(os.path.abspath( + sys._getframe(2).f_globals['__file__'] + )) + path = os.path.join(base, path) + with open(path) as f: + test = f.read() + name = os.path.basename(path) + + def test_file(self): + if isinstance(self, types.FunctionType): + setup = self + def test_file_w_setup(self): + setup(self) + _run_test(self, test, {}, name, path, optionflags, checker, + 'test') + + return test_file_w_setup + + _run_test(self, test, {}, name, path, optionflags, checker, 'test') + + return test_file + +file = doctestfile + +def _run_test(self, test, globs, name, path, + optionflags, checker, testname='self', lineno=0): + globs.update(getattr(self, 'globs', ())) + globs[testname] = self + optionflags |= doctest.IGNORE_EXCEPTION_DETAIL + doctest.DocTestCase( + _parser.get_doctest(test, globs, name, path, lineno), + optionflags=optionflags, + checker=checker, + ).runTest() diff --git a/src/zope/testing/doctestcase.txt b/src/zope/testing/doctestcase.txt new file mode 100644 index 0000000..d73c24a --- /dev/null +++ b/src/zope/testing/doctestcase.txt @@ -0,0 +1,222 @@ +Doctests in TestCase classes +============================ + +The original ``doctest`` unittest integration was based on +``unittest`` test suites, which have fallen out of favor. This module +provides a way to define doctests inside of unittest ``TestCase`` +classes. It also provides better integration with unittest test +fixtures, because doctests use setup provided by the containing test +case class. It also provides access to unittest assertion +methods. + +You can define doctests in 4 ways: + +- references to named files + +- strings + +- decorated functions with docstrings + +- reference to named files decorating test-specific setup functions + +.. some setup + + >>> __name__ = 'tests' + +Here are some examples:: + + >>> from zope.testing import doctestcase + >>> import doctest + >>> import unittest + + >>> g = 'global' + >>> class MyTest(unittest.TestCase): + ... + ... def setUp(self): + ... self.a = 1 + ... self.globs = dict(c=9) + ... + ... test1 = doctestcase.file('test1.txt', optionflags=doctest.ELLIPSIS) + ... + ... test2 = doctestcase.docteststring(''' + ... >>> self.a, g, c + ... (1, 'global', 9) + ... ''') + ... + ... @doctestcase.doctestmethod(optionflags=doctest.ELLIPSIS) + ... def test3(self): + ... ''' + ... >>> self.a, self.x, g, c + ... (1, 3, 'global', 9) + ... ''' + ... self.x = 3 + ... + ... @doctestcase.doctestfile('test4.txt') + ... def test4(self): + ... self.x = 5 + +.. We can run these tests with the ``unittest`` test runner. + + >>> loader = unittest.TestLoader() + >>> suite = loader.loadTestsFromTestCase(MyTest) + >>> import sys + >>> sys.stdout.writeln = lambda s: sys.stdout.write(s+'\n') + >>> result = suite.run(unittest.TextTestResult(sys.stdout, True, 3)) + test1 (tests.MyTest) ... ok + test2 (tests.MyTest) ... ok + test3 (tests.MyTest) ... ok + test4 (tests.MyTest) ... ok + + + >>> for _, e in result.errors: + ... print(e); print + +In this example, 3 constructors were used: + +doctestfile (alias: file) + doctestfile makes a file-based test case. + + This can be used as a decorator, in which case, the decorated + function is called before the test is run, to provide test-specific + setup. + +docteststring (alias string) + docteststring constructs a doctest from a string. + +doctestmethod (alias method) + doctestmethod constructs a doctest from a method. + + The method's docstring provides the test. The method's body provides + optional test-specific setup. + +Note that short aliases are provided, which maye be useful in certain +import styles. + +Tests have access to the following data: + +- Tests created with the ``docteststring`` and ``doctestmethod`` + constructors have access to the module globals of the defining + module. + +- In tests created with the ``docteststring`` and ``doctestmethod`` + constructors, the test case instance is available as the ``self`` + variable. + +- In tests created with the ``doctestfile`` constructor, the test case + instance is available as the ``test`` variable. + +- If a test case defines a globs attribute, it must be a dictionary + and it's contents are added to the test globals. + +The constructors accept standard doctest ``optionflags`` and +``checker`` arguments. + +Note that the doctest IGNORE_EXCEPTION_DETAIL option flag is +added to optionflags. + +.. Let's look at some failure cases: + + >>> class MyTest(unittest.TestCase): + ... + ... test2 = doctestcase.string(''' + ... >>> 1 + ... 1 + ... >>> 1 + 1 + ... 1 + ... ''') + ... + ... @doctestcase.method + ... def test3(self): + ... ''' + ... >>> self.x + ... 3 + ... >>> 1 + 1 + ... 1 + ... ''' + ... self.x = 3 + ... + ... @doctestcase.file('test4f.txt') + ... def test4(self): + ... self.x = 5 + + >>> suite = loader.loadTestsFromTestCase(MyTest) + >>> result = suite.run(unittest.TextTestResult(sys.stdout, True, 1)) + FFF + >>> for c, e in result.failures: + ... print(e) # doctest: +ELLIPSIS +NORMALIZE_WHITESPACE + Traceback (most recent call last): + ... + ...: Failed doctest test for + File "", line 0, in + + ---------------------------------------------------------------------- + File "", line 4, in + Failed example: + 1 + 1 + Expected: + 1 + Got: + 2 + + + Traceback (most recent call last): + ... + ...: Failed doctest test for test3 + File "None", line 10, in test3 + + ---------------------------------------------------------------------- + Line 4, in test3 + Failed example: + 1 + 1 + Expected: + 1 + Got: + 2 + + + Traceback (most recent call last): + ... + ...: Failed doctest test for test4f.txt + File "...test4f.txt", line 0, in txt + + ---------------------------------------------------------------------- + File "...test4f.txt", line 3, in test4f.txt + Failed example: + 1 + 1 + Expected: + 1 + Got: + 2 + + + +.. Verify setting optionflags and checker + + >>> class EasyChecker: + ... def check_output(self, want, got, optionflags): + ... return True + ... def output_difference(self, example, got, optionflags): + ... return '' + + >>> class MyTest(unittest.TestCase): + ... + ... test2 = doctestcase.string(''' + ... >>> 1 + ... 2 + ... ''', checker=EasyChecker()) + ... + ... @doctestcase.method(optionflags=doctest.ELLIPSIS) + ... def test3(self): + ... ''' + ... >>> 'Hello' + ... '...' + ... ''' + ... + ... @doctestcase.file('test4e.txt', optionflags=doctest.ELLIPSIS) + ... def test4(self): + ... self.x = 5 + >>> suite = loader.loadTestsFromTestCase(MyTest) + >>> result = suite.run(unittest.TextTestResult(sys.stdout, True, 2)) + test2 (tests.MyTest) ... ok + test3 (tests.MyTest) ... ok + test4 (tests.MyTest) ... ok diff --git a/src/zope/testing/test1.txt b/src/zope/testing/test1.txt new file mode 100644 index 0000000..2839fd5 --- /dev/null +++ b/src/zope/testing/test1.txt @@ -0,0 +1,2 @@ +>>> test.a +1 diff --git a/src/zope/testing/test4.txt b/src/zope/testing/test4.txt new file mode 100644 index 0000000..6b175ee --- /dev/null +++ b/src/zope/testing/test4.txt @@ -0,0 +1,2 @@ + >>> test.a, test.x, c + (1, 5, 9) diff --git a/src/zope/testing/test4e.txt b/src/zope/testing/test4e.txt new file mode 100644 index 0000000..a75b90e --- /dev/null +++ b/src/zope/testing/test4e.txt @@ -0,0 +1,2 @@ +>>> 'Hello' +'H...o' diff --git a/src/zope/testing/test4f.txt b/src/zope/testing/test4f.txt new file mode 100644 index 0000000..119b2eb --- /dev/null +++ b/src/zope/testing/test4f.txt @@ -0,0 +1,5 @@ + >>> test.x + 5 + >>> 1 + 1 + 1 + diff --git a/src/zope/testing/tests.py b/src/zope/testing/tests.py index 9c0c4e4..578a28b 100644 --- a/src/zope/testing/tests.py +++ b/src/zope/testing/tests.py @@ -23,6 +23,7 @@ def print_(*args): def setUp(test): test.globs['print_'] = print_ + def test_suite(): suite = unittest.TestSuite(( doctest.DocFileSuite( @@ -30,7 +31,8 @@ def test_suite(): # Python 3.3 changed exception messaging: # https://bugs.launchpad.net/zope.testing/+bug/1055720 checker=renormalizing.RENormalizing([ - (re.compile("No module named '?zope.testing.unlikelymodulename'?"), + (re.compile( + "No module named '?zope.testing.unlikelymodulename'?"), 'No module named unlikelymodulename'), (re.compile("No module named '?fake'?"), 'No module named fake')])), @@ -50,6 +52,8 @@ def test_suite(): ), )) + if sys.version_info[:2] >= (2, 7): + suite.addTests(doctest.DocFileSuite('doctestcase.txt')) if sys.version < '3': suite.addTests(doctest.DocTestSuite('zope.testing.server')) suite.addTests(doctest.DocFileSuite('formparser.txt'))