Merge pull request #5307 from akaihola/5273_doctest_conditional_skip

Feature: skip doctests conditionally
This commit is contained in:
Takeshi KOMIYA 2018-08-17 00:33:29 +09:00 committed by GitHub
commit 28131df93e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 238 additions and 2 deletions

View File

@ -198,6 +198,81 @@ The following is an example for the usage of the directives. The test via
This parrot wouldn't voom if you put 3000 volts through it! This parrot wouldn't voom if you put 3000 volts through it!
Skipping tests conditionally
----------------------------
``skipif``, a string option, can be used to skip directives conditionally. This
may be useful e.g. when a different set of tests should be run depending on the
environment (hardware, network/VPN, optional dependencies or different versions
of dependencies). The ``skipif`` option is supported by all of the doctest
directives. Below are typical use cases for ``skipif`` when used for different
directives:
- :rst:dir:`testsetup` and :rst:dir:`testcleanup`
- conditionally skip test setup and/or cleanup
- customize setup/cleanup code per environment
- :rst:dir:`doctest`
- conditionally skip both a test and its output verification
- :rst:dir:`testcode`
- conditionally skip a test
- customize test code per environment
- :rst:dir:`testoutput`
- conditionally skip output assertion for a skipped test
- expect different output depending on the environment
The value of the ``skipif`` option is evaluated as a Python expression. If the
result is a true value, the directive is omitted from the test run just as if
it wasn't present in the file at all.
Instead of repeating an expression, the :confval:`doctest_global_setup`
configuration option can be used to assign it to a variable which can then be
used instead.
Here's an example which skips some tests if Pandas is not installed:
.. code-block:: py
:caption: conf.py
extensions = ['sphinx.ext.doctest']
doctest_global_setup = '''
try:
import pandas as pd
except ImportError:
pd = None
'''
.. code-block:: rst
:caption: contents.rst
.. testsetup::
:skipif: pd is None
data = pd.Series([42])
.. doctest::
:skipif: pd is None
>>> data.iloc[0]
42
.. testcode::
:skipif: pd is None
print(data.iloc[-1])
.. testoutput::
:skipif: pd is None
42
Configuration Configuration
------------- -------------

View File

@ -90,6 +90,16 @@ class TestDirective(SphinxDirective):
def run(self): def run(self):
# type: () -> List[nodes.Node] # type: () -> List[nodes.Node]
if 'skipif' in self.options:
condition = self.options['skipif']
context = {} # type: Dict[str, Any]
if self.config.doctest_global_setup:
exec(self.config.doctest_global_setup, context)
should_skip = eval(condition, context)
if self.config.doctest_global_cleanup:
exec(self.config.doctest_global_cleanup, context)
if should_skip:
return []
# use ordinary docutils nodes for test code: they get special attributes # use ordinary docutils nodes for test code: they get special attributes
# so that our builder recognizes them, and the other builders are happy. # so that our builder recognizes them, and the other builders are happy.
code = '\n'.join(self.content) code = '\n'.join(self.content)
@ -155,11 +165,11 @@ class TestDirective(SphinxDirective):
class TestsetupDirective(TestDirective): class TestsetupDirective(TestDirective):
option_spec = {} # type: Dict option_spec = {'skipif': directives.unchanged_required} # type: Dict
class TestcleanupDirective(TestDirective): class TestcleanupDirective(TestDirective):
option_spec = {} # type: Dict option_spec = {'skipif': directives.unchanged_required} # type: Dict
class DoctestDirective(TestDirective): class DoctestDirective(TestDirective):
@ -167,6 +177,7 @@ class DoctestDirective(TestDirective):
'hide': directives.flag, 'hide': directives.flag,
'options': directives.unchanged, 'options': directives.unchanged,
'pyversion': directives.unchanged_required, 'pyversion': directives.unchanged_required,
'skipif': directives.unchanged_required,
} }
@ -174,6 +185,7 @@ class TestcodeDirective(TestDirective):
option_spec = { option_spec = {
'hide': directives.flag, 'hide': directives.flag,
'pyversion': directives.unchanged_required, 'pyversion': directives.unchanged_required,
'skipif': directives.unchanged_required,
} }
@ -182,6 +194,7 @@ class TestoutputDirective(TestDirective):
'hide': directives.flag, 'hide': directives.flag,
'options': directives.unchanged, 'options': directives.unchanged,
'pyversion': directives.unchanged_required, 'pyversion': directives.unchanged_required,
'skipif': directives.unchanged_required,
} }

View File

@ -0,0 +1,16 @@
extensions = ['sphinx.ext.doctest']
project = 'test project for the doctest :skipif: directive'
master_doc = 'skipif'
source_suffix = '.txt'
exclude_patterns = ['_build']
doctest_global_setup = '''
from test_ext_doctest import record
record('doctest_global_setup', 'body', True)
'''
doctest_global_cleanup = '''
record('doctest_global_cleanup', 'body', True)
'''

View File

@ -0,0 +1,81 @@
Testing the doctest extension's `:skipif:` option
=================================================
testsetup
---------
.. testsetup:: group-skipif
:skipif: record('testsetup', ':skipif:', True) != 'this will be True'
record('testsetup', 'body', True)
.. testsetup:: group-skipif
:skipif: record('testsetup', ':skipif:', False) == 'this will be False'
record('testsetup', 'body', False)
doctest
-------
.. doctest:: group-skipif
:skipif: record('doctest', ':skipif:', True) != 'this will be True'
>>> print(record('doctest', 'body', True))
The test is skipped, and this expected text is ignored
.. doctest::
:skipif: record('doctest', ':skipif:', False) == 'this will be False'
>>> print(record('doctest', 'body', False))
Recorded doctest body False
testcode and testoutput
-----------------------
testcode skipped
~~~~~~~~~~~~~~~~
.. testcode:: group-skipif
:skipif: record('testcode', ':skipif:', True) != 'this will be True'
print(record('testcode', 'body', True))
.. testoutput:: group-skipif
:skipif: record('testoutput-1', ':skipif:', True) != 'this will be True'
The previous testcode is skipped, and the :skipif: condition is True,
so this testoutput is ignored
testcode executed
~~~~~~~~~~~~~~~~~
.. testcode:: group-skipif
:skipif: record('testcode', ':skipif:', False) == 'this will be False'
print(record('testcode', 'body', False))
.. testoutput:: group-skipif
:skipif: record('testoutput-2', ':skipif:', False) == 'this will be False'
Recorded testcode body False
.. testoutput:: group-skipif
:skipif: record('testoutput-2', ':skipif:', True) != 'this will be True'
The :skipif: condition is False, so this testoutput is ignored
testcleanup
-----------
.. testcleanup:: group-skipif
:skipif: record('testcleanup', ':skipif:', True) != 'this will be True'
record('testcleanup', 'body', True)
.. testcleanup:: group-skipif
:skipif: record('testcleanup', ':skipif:', False) == 'this will be False'
record('testcleanup', 'body', False)

View File

@ -9,6 +9,7 @@
:license: BSD, see LICENSE for details. :license: BSD, see LICENSE for details.
""" """
import os import os
from collections import Counter
import pytest import pytest
from packaging.specifiers import InvalidSpecifier from packaging.specifiers import InvalidSpecifier
@ -61,6 +62,56 @@ def cleanup_call():
cleanup_called += 1 cleanup_called += 1
recorded_calls = Counter()
@pytest.mark.sphinx('doctest', testroot='ext-doctest-skipif')
def test_skipif(app, status, warning):
"""Tests for the :skipif: option
The tests are separated into a different test root directory since the
``app`` object only evaluates options once in its lifetime. If these tests
were combined with the other doctest tests, the ``:skipif:`` evaluations
would be recorded only on the first ``app.builder.build_all()`` run, i.e.
in ``test_build`` above, and the assertion below would fail.
"""
global recorded_calls
recorded_calls = Counter()
app.builder.build_all()
if app.statuscode != 0:
assert False, 'failures in doctests:' + status.getvalue()
# The `:skipif:` expressions are always run.
# Actual tests and setup/cleanup code is only run if the `:skipif:`
# expression evaluates to a False value.
# Global setup/cleanup are run before/after evaluating the `:skipif:`
# option in each directive - thus 11 additional invocations for each on top
# of the ones made for the whole test file.
assert recorded_calls == {('doctest_global_setup', 'body', True): 13,
('testsetup', ':skipif:', True): 1,
('testsetup', ':skipif:', False): 1,
('testsetup', 'body', False): 1,
('doctest', ':skipif:', True): 1,
('doctest', ':skipif:', False): 1,
('doctest', 'body', False): 1,
('testcode', ':skipif:', True): 1,
('testcode', ':skipif:', False): 1,
('testcode', 'body', False): 1,
('testoutput-1', ':skipif:', True): 1,
('testoutput-2', ':skipif:', True): 1,
('testoutput-2', ':skipif:', False): 1,
('testcleanup', ':skipif:', True): 1,
('testcleanup', ':skipif:', False): 1,
('testcleanup', 'body', False): 1,
('doctest_global_cleanup', 'body', True): 13}
def record(directive, part, should_skip):
global recorded_calls
recorded_calls[(directive, part, should_skip)] += 1
return 'Recorded {} {} {}'.format(directive, part, should_skip)
@pytest.mark.xfail( @pytest.mark.xfail(
PY2, reason='node.source points to document instead of filename', PY2, reason='node.source points to document instead of filename',
) )