Skip to content

Commit

Permalink
Add doctest support. Resolves #88 and #214.
Browse files Browse the repository at this point in the history
  • Loading branch information
CleanCut committed Jan 4, 2020
1 parent 3bd2b43 commit 9a2f2a7
Show file tree
Hide file tree
Showing 11 changed files with 265 additions and 19 deletions.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# Version 3.1.0
## 3 Jan 2019

- You can now financially support Green by sponsoring @CleanCut at
https://github.com/sponsors/CleanCut

- Added support for DocTests. I got interested in why @charles-l's attempt to
crash instead of hang when doctests were encountered didn't work, and ended
up just adding support as a feature. To parse/run doctests for a particular
module, in a _test_ module add `doctest_modules = [ ... ]` where each item
is (preferably) an imported module or a dotted string representing a module
to be imported that contains tests in docstrings. Resolves #88, #214.

# Version 3.0.0
## 26 Aug 2019

Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ This tutorial covers:
- How to import stuff from your project into your test module
- Gotchas about naming...everything.
- Where to run green from and what the output could look like.
- DocTests

For more in-depth online training please check out
[Python Testing with Green](https://github.com/CleanCut/green/blob/master/PythonTestingWithGreen.md):
Expand Down Expand Up @@ -387,6 +388,68 @@ Notes:
method docstring to describe the test if it is present, and the name of the
method if it is not. Notice the difference in the output below.

### DocTests ###

Green can also run tests embedded in documentation via Python's built-in
[doctest] module. Returning to our previous example, we could add docstrings
with example code to our `foo.py` module:

[doctest]: https://docs.python.org/3.6/library/doctest.html


```python
def answer():
"""
>>> answer()
42
"""
return 42

class School():

def food(self):
"""
>>> s = School()
>>> s.food()
'awful'
"""
return 'awful'

def age(self):
return 300
```

Then in some _test_ module you need to add a `doctest_modules = [ ... ]` list
to the top-level of the test module. So lets revisit `test_foo.py` and add
that:

```python
# we could add this to the top or bottom of the existing file...

doctest_modules = ['proj.foo']
```

Then running `green -vv` might include this output:

```
DocTests via `doctest_modules = [...]`
. proj.foo.School.food
. proj.foo.answer
```

...or with one more level of verbosity (`green -vvv`)

```
DocTests via `doctest_modules = [...]`
. proj.foo.School.food -> /Users/cleancut/proj/green/example/proj/foo.py:10
. proj.foo.answer -> /Users/cleancut/proj/green/example/proj/foo.py:1
```

Notes:

1. There needs to be at least one `unittest.TestCase` subclass with a test
method present in the test module for `doctest_modules` to be examined.

### Running Green ###

To run the unittests, we would change to the parent directory of the project
Expand Down
9 changes: 9 additions & 0 deletions example/proj/foo.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
def answer():
"""
>>> answer()
42
"""
return 42

class School():

def food(self):
"""
>>> s = School()
>>> s.food()
'awful'
"""
return 'awful'

def age(self):
Expand Down
11 changes: 11 additions & 0 deletions example/proj/test/test_foo.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,14 @@ def test_food(self):
def test_age(self):
school = School()
self.assertEqual(school.age(), 300)

# If there are doctests you would like to run, add a `doctest_modules` list to
# the top level of any of your test modules. Items in the list are modules to
# discover doctests within. Each item in the list can be either the name of a
# module as a dotted string or the actual module that has been imported. In
# this case, we haven't actually imported proj.foo itself, so we use the string
# form of "proj.foo", but if we had done `import proj.foo` then we could have
# put the variable form proj.foo. The module form is preferred as it results
# in both better performance and eliminates the chance that the discovery will
# encounter an error searching for the module.
doctest_modules = ["proj.foo"]
2 changes: 1 addition & 1 deletion green/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.0
3.1.0
20 changes: 20 additions & 0 deletions green/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import sys
import unittest

doctest_modules = ['green.examples']


class TestStates(unittest.TestCase):

Expand Down Expand Up @@ -44,3 +46,21 @@ def test5UnexpectedPass(self):
This test will pass, but we expected it to fail!
"""
pass

def some_function():
"""
This will fail because some_function() does not, in fact, return 100.
>>> some_function()
100
"""
return 99

class MyClass:
def my_method(self):
"""
This will pass.
>>> s = MyClass()
>>> s.my_method()
'happy'
"""
return "happy"
44 changes: 33 additions & 11 deletions green/loader.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import unicode_literals
from collections import OrderedDict
from doctest import DocTestCase, DocTestSuite
from fnmatch import fnmatch
import functools
import glob
Expand Down Expand Up @@ -98,17 +99,17 @@ def testFailure(self):
def loadTestsFromModule(self, module, pattern=None):
tests = super(GreenTestLoader, self).loadTestsFromModule(
module, pattern=pattern)
return flattenTestSuite(tests)
return flattenTestSuite(tests, module)

else: # pragma: no cover

def loadTestsFromModule(self, module):
tests = super(GreenTestLoader, self).loadTestsFromModule(module)
return flattenTestSuite(tests)
return flattenTestSuite(tests, module)

def loadTestsFromName(self, name, module=None):
tests = super(GreenTestLoader, self).loadTestsFromName(name, module)
return flattenTestSuite(tests)
return flattenTestSuite(tests, module)

def discover(self, current_path, file_pattern='test*.py',
top_level_dir=None):
Expand Down Expand Up @@ -320,7 +321,7 @@ def toProtoTestList(suite, test_list=None, doing_completions=False):
exception_method = str(suite).split()[0]
getattr(suite, exception_method)()
# On to the real stuff
if issubclass(type(suite), unittest.TestCase):
if isinstance(suite, unittest.TestCase):
# Skip actual blank TestCase objects that twisted inserts
if str(type(suite)) != "<class 'twisted.trial.unittest.TestCase'>":
test_list.append(proto_test(suite))
Expand Down Expand Up @@ -356,7 +357,7 @@ def toParallelTargets(suite, targets):
found = False
for target in non_module_targets:
# target is a dotted name of either a test case or test method
# here test.dotted name is always a dotted name of a method
# here test.dotted_name is always a dotted name of a method
if (target in test.dotted_name):
if target not in parallel_targets:
# Explicitly specified targets get their own entry to
Expand Down Expand Up @@ -464,11 +465,32 @@ def isTestCaseDisabled(test_case_class, method_name):
return getattr(test_method, "__test__", 'not nose') is False


def flattenTestSuite(test_suite):
def flattenTestSuite(test_suite, module=None):
# Look for a `doctest_modules` list and attempt to add doctest tests to the
# suite of tests that we are about to flatten.
# todo: rename this function to something more appropriate.
suites = [test_suite]
doctest_modules = getattr(module, 'doctest_modules', ())
for doctest_module in doctest_modules:
suite = DocTestSuite(doctest_module)
suite.injected_module = module.__name__
suites.append(suite)

# Now extract all tests from the suite heirarchies and flatten them into a
# single suite with all tests.
tests = []
for test in test_suite:
if isinstance(test, unittest.BaseTestSuite):
tests.extend(flattenTestSuite(test))
else:
tests.append(test)
for suite in suites:
injected_module = None
if getattr(suite, 'injected_module', None):
injected_module = suite.injected_module
for test in suite:
if injected_module:
# For doctests, inject the test module name so we can later
# grab it and use it to group the doctest output along with the
# test module which specified it should be run.
test.__module__ = injected_module
if isinstance(test, unittest.BaseTestSuite):
tests.extend(flattenTestSuite(test))
else:
tests.append(test)
return GreenTestLoader.suiteClass(tests)
2 changes: 1 addition & 1 deletion green/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ def finalize_callback(test_result):
result.stopTest(test)
queue.put(result)
except:
raise_internal_failure('Green encoundered an error when running the test.')
raise_internal_failure('Green encountered an error when running the test.')
return
else:
# loadTargets() returned an object without a run() method, probably
Expand Down
35 changes: 32 additions & 3 deletions green/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
from __future__ import print_function

from collections import OrderedDict
from doctest import DocTestCase
from io import StringIO
from math import ceil
import sys
import time
import traceback
from unittest.result import failfast
from unittest import TestCase

from green.output import Colors, debug
from green.version import pretty_version
Expand Down Expand Up @@ -53,16 +55,35 @@ def __init__(self, test=None):
self.method_name = ''
self.docstr_part = ''
self.subtest_part = ''
self.is_subtest = False
# We need to know that this is a doctest, because doctests are very
# different than regular test cases in many ways, so they get special
# treatment inside and outside of this class.
self.is_doctest = False

# Is this a subtest?
if getattr(test, '_subDescription', None):
self.is_subtest = True
self.subtest_part = ' ' + test._subDescription()
test = test.test_case

# Is this a DocTest?
if isinstance(test, DocTestCase):
self.is_doctest = True
self.name = test._dt_test.name
# We had to inject this in green/loader.py -- this is the test
# module that specified that we should load doctests from some
# other module -- so that we'll group the doctests with the test
# module that specified that we should load them.
self.module = test.__module__
self.class_name = "DocTests via `doctest_modules = [...]`"
# I'm not sure this will be the correct way to get the method name
# in all cases.
self.method_name = self.name.split('.')[1]
self.filename = test._dt_test.filename
self.lineno = test._dt_test.lineno


# Is this a TestCase?
if test:
elif isinstance(test, TestCase):
self.module = test.__module__
self.class_name = test.__class__.__name__
self.method_name = str(test).split()[0]
Expand All @@ -78,6 +99,8 @@ def __init__(self, test=None):
doc_segments.append(line)
self.docstr_part = ' '.join(doc_segments)



def __eq__(self, other):
return self.__hash__() == other.__hash__()

Expand All @@ -89,12 +112,18 @@ def __str__(self):

@property
def dotted_name(self, ignored=None):
if self.is_doctest:
return self.name
return self.module + '.' + self.class_name + '.' + self.method_name + self.subtest_part

def getDescription(self, verbose):
if verbose == 2:
if self.is_doctest:
return self.name
return self.method_name + self.subtest_part
elif verbose > 2:
if self.is_doctest:
return self.name + " -> " + self.filename + ":" + str(self.lineno)
return (self.docstr_part + self.subtest_part) or (self.method_name + self.subtest_part)
else:
return ''
Expand Down

0 comments on commit 9a2f2a7

Please sign in to comment.