Merge pull request #1711 from nicoddemus/invocation-scoped-fixtures

Invocation scoped fixtures
This commit is contained in:
Bruno Oliveira 2016-07-21 19:48:52 -03:00 committed by GitHub
commit ae0798522f
11 changed files with 475 additions and 62 deletions

View File

@ -135,9 +135,17 @@ time or change existing behaviors in order to make them less surprising/more use
never fail because tuples are always truthy and are usually a mistake
(see `#1562`_). Thanks `@kvas-it`_, for the PR.
* Experimentally introduce new ``"invocation"`` fixture scope. At invocation scope a
fixture function is cached in the same way as the fixture or test function that requests it.
You can now use the builtin ``monkeypatch`` fixture from ``session``-scoped fixtures
where previously you would get an error that you can not use a ``function``-scoped fixture from a
``session``-scoped one.*
Thanks `@nicoddemus`_ for the PR.
* Allow passing a custom debugger class (e.g. ``--pdbcls=IPython.core.debugger:Pdb``).
Thanks to `@anntzer`_ for the PR.
*
*

View File

@ -161,7 +161,7 @@ def capsys(request):
captured output available via ``capsys.readouterr()`` method calls
which return a ``(out, err)`` tuple.
"""
if "capfd" in request._funcargs:
if "capfd" in request.fixturenames:
raise request.raiseerror(error_capsysfderror)
request.node._capfuncarg = c = CaptureFixture(SysCapture, request)
return c
@ -172,7 +172,7 @@ def capfd(request):
captured output available via ``capfd.readouterr()`` method calls
which return a ``(out, err)`` tuple.
"""
if "capsys" in request._funcargs:
if "capsys" in request.fixturenames:
request.raiseerror(error_capsysfderror)
if not hasattr(os, 'dup'):
pytest.skip("capfd funcarg needs os.dup")

View File

@ -260,8 +260,6 @@ class FuncFixtureInfo:
self.name2fixturedefs = name2fixturedefs
class FixtureRequest(FuncargnamesCompatAttr):
""" A request for a fixture from a test or fixture function.
@ -276,34 +274,51 @@ class FixtureRequest(FuncargnamesCompatAttr):
self.fixturename = None
#: Scope string, one of "function", "class", "module", "session"
self.scope = "function"
self._funcargs = {}
self._fixturedefs = {}
# rename both attributes below because their key has changed; better an attribute error
# than subtle key misses; also backward incompatibility
self._fixture_values = {} # (argname, scope) -> fixture value
self._fixture_defs = {} # (argname, scope) -> FixtureDef
fixtureinfo = pyfuncitem._fixtureinfo
self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy()
self._arg2index = {}
self.fixturenames = fixtureinfo.names_closure
self._fixturemanager = pyfuncitem.session._fixturemanager
@property
def fixturenames(self):
# backward incompatible note: now a readonly property
return list(self._pyfuncitem._fixtureinfo.names_closure)
@property
def node(self):
""" underlying collection node (depends on current request scope)"""
return self._getscopeitem(self.scope)
def _getnextfixturedef(self, argname):
def _getnextfixturedef(self, argname, scope):
def trygetfixturedefs(argname):
fixturedefs = self._arg2fixturedefs.get(argname, None)
if fixturedefs is None:
fixturedefs = self._arg2fixturedefs.get(argname + ':' + scope, None)
return fixturedefs
fixturedefs = trygetfixturedefs(argname)
if fixturedefs is None:
# we arrive here because of a a dynamic call to
# getfixturevalue(argname) usage which was naturally
# not known at parsing/collection time
fixturedefs = self._fixturemanager.getfixturedefs(
argname, self._pyfuncitem.parent.nodeid)
parentid = self._pyfuncitem.parent.nodeid
fixturedefs = self._fixturemanager.getfixturedefs(argname, parentid)
if fixturedefs:
self._arg2fixturedefs[argname] = fixturedefs
fixturedefs_by_argname = self._fixturemanager.getfixturedefs_multiple_scopes(argname, parentid)
if fixturedefs_by_argname:
self._arg2fixturedefs.update(fixturedefs_by_argname)
fixturedefs = trygetfixturedefs(argname)
# fixturedefs list is immutable so we maintain a decreasing index
index = self._arg2index.get(argname, 0) - 1
index = self._arg2index.get((argname, scope), 0) - 1
if fixturedefs is None or (-index > len(fixturedefs)):
raise FixtureLookupError(argname, self)
self._arg2index[argname] = index
self._arg2index[(argname, scope)] = index
return fixturedefs[index]
@property
@ -442,10 +457,10 @@ class FixtureRequest(FuncargnamesCompatAttr):
def _get_active_fixturedef(self, argname):
try:
return self._fixturedefs[argname]
return self._fixture_defs[(argname, self.scope)]
except KeyError:
try:
fixturedef = self._getnextfixturedef(argname)
fixturedef = self._getnextfixturedef(argname, self.scope)
except FixtureLookupError:
if argname == "request":
class PseudoFixtureDef:
@ -456,8 +471,8 @@ class FixtureRequest(FuncargnamesCompatAttr):
# remove indent to prevent the python3 exception
# from leaking into the call
result = self._getfixturevalue(fixturedef)
self._funcargs[argname] = result
self._fixturedefs[argname] = fixturedef
self._fixture_values[(argname, self.scope)] = result
self._fixture_defs[(argname, self.scope)] = fixturedef
return fixturedef
def _get_fixturestack(self):
@ -578,11 +593,10 @@ class SubRequest(FixtureRequest):
self._fixturedef = fixturedef
self.addfinalizer = fixturedef.addfinalizer
self._pyfuncitem = request._pyfuncitem
self._funcargs = request._funcargs
self._fixturedefs = request._fixturedefs
self._fixture_values = request._fixture_values
self._fixture_defs = request._fixture_defs
self._arg2fixturedefs = request._arg2fixturedefs
self._arg2index = request._arg2index
self.fixturenames = request.fixturenames
self._fixturemanager = request._fixturemanager
def __repr__(self):
@ -622,7 +636,7 @@ class FixtureLookupError(LookupError):
fspath, lineno = getfslineno(function)
try:
lines, _ = inspect.getsourcelines(get_real_func(function))
except (IOError, IndexError):
except (IOError, IndexError, TypeError):
error_msg = "file %s, line %s: source code not available"
addline(error_msg % (fspath, lineno+1))
else:
@ -636,9 +650,9 @@ class FixtureLookupError(LookupError):
if msg is None:
fm = self.request._fixturemanager
available = []
for name, fixturedef in fm._arg2fixturedefs.items():
parentid = self.request._pyfuncitem.parent.nodeid
faclist = list(fm._matchfactories(fixturedef, parentid))
for name, fixturedefs in fm._arg2fixturedefs.items():
faclist = list(fm._matchfactories(fixturedefs, parentid))
if faclist:
available.append(name)
msg = "fixture %r not found" % (self.argname,)
@ -749,7 +763,7 @@ class FixtureDef:
assert not hasattr(self, "cached_result")
ihook = self._fixturemanager.session.ihook
ihook.pytest_fixture_setup(fixturedef=self, request=request)
return ihook.pytest_fixture_setup(fixturedef=self, request=request)
def __repr__(self):
return ("<FixtureDef name=%r scope=%r baseid=%r >" %
@ -984,10 +998,12 @@ class FixtureManager:
parentid = parentnode.nodeid
fixturenames_closure = self._getautousenames(parentid)
def merge(otherlist):
for arg in otherlist:
if arg not in fixturenames_closure:
fixturenames_closure.append(arg)
merge(fixturenames)
arg2fixturedefs = {}
lastlen = -1
@ -1000,6 +1016,11 @@ class FixtureManager:
if fixturedefs:
arg2fixturedefs[argname] = fixturedefs
merge(fixturedefs[-1].argnames)
fixturedefs_by_argname = self.getfixturedefs_multiple_scopes(argname, parentid)
if fixturedefs_by_argname:
arg2fixturedefs.update(fixturedefs_by_argname)
for fixturedefs in fixturedefs_by_argname.values():
merge(fixturedefs[-1].argnames)
return fixturenames_closure, arg2fixturedefs
def pytest_generate_tests(self, metafunc):
@ -1057,25 +1078,43 @@ class FixtureManager:
msg = 'fixtures cannot have "pytest_funcarg__" prefix ' \
'and be decorated with @pytest.fixture:\n%s' % name
assert not name.startswith(self._argprefix), msg
fixturedef = FixtureDef(self, nodeid, name, obj,
marker.scope, marker.params,
def new_fixture_def(name, scope):
"""Create and registers a new FixtureDef with given name and scope."""
fixture_def = FixtureDef(self, nodeid, name, obj,
scope, marker.params,
unittest=unittest, ids=marker.ids)
faclist = self._arg2fixturedefs.setdefault(name, [])
if fixturedef.has_location:
faclist.append(fixturedef)
if fixture_def.has_location:
faclist.append(fixture_def)
else:
# fixturedefs with no location are at the front
# so this inserts the current fixturedef after the
# existing fixturedefs from external plugins but
# before the fixturedefs provided in conftests.
i = len([f for f in faclist if not f.has_location])
faclist.insert(i, fixturedef)
faclist.insert(i, fixture_def)
if marker.autouse:
autousenames.append(name)
if marker.scope == 'invocation':
for new_scope in scopes:
new_fixture_def(name + ':{0}'.format(new_scope), new_scope)
else:
new_fixture_def(name, marker.scope)
if autousenames:
self._nodeid_and_autousenames.append((nodeid or '', autousenames))
def getfixturedefs(self, argname, nodeid):
"""
Gets a list of fixtures which are applicable to the given node id.
:param str argname: name of the fixture to search for
:param str nodeid: full node id of the requesting test.
:return: list[FixtureDef]
"""
try:
fixturedefs = self._arg2fixturedefs[argname]
except KeyError:
@ -1087,3 +1126,24 @@ class FixtureManager:
for fixturedef in fixturedefs:
if nodeid.startswith(fixturedef.baseid):
yield fixturedef
def getfixturedefs_multiple_scopes(self, argname, nodeid):
"""
Gets multiple scoped fixtures which are applicable to the given nodeid. Multiple scoped
fixtures are created by "invocation" scoped fixtures and have argnames in
the form: "<argname>:<scope>" (for example "tmpdir:session").
:return: dict of "argname" -> [FixtureDef].
Arguments similar to ``getfixturedefs``.
"""
prefix = argname + ':'
fixturedefs_by_argname = dict((k, v) for k, v in self._arg2fixturedefs.items()
if k.startswith(prefix))
if fixturedefs_by_argname:
result = {}
for argname, fixturedefs in fixturedefs_by_argname.items():
result[argname] = tuple(self._matchfactories(fixturedefs, nodeid))
return result
else:
return None

View File

@ -10,7 +10,7 @@ import pytest
RE_IMPORT_ERROR_NAME = re.compile("^No module named (.*)$")
@pytest.fixture
@pytest.fixture(scope='invocation')
def monkeypatch(request):
"""The returned ``monkeypatch`` fixture provides these
helper methods to modify objects, dictionaries or os.environ::
@ -25,9 +25,11 @@ def monkeypatch(request):
monkeypatch.chdir(path)
All modifications will be undone after the requesting
test function has finished. The ``raising``
test function or fixture has finished. The ``raising``
parameter determines if a KeyError or AttributeError
will be raised if the set/deletion operation has no target.
This fixture is ``invocation``-scoped.
"""
mpatch = MonkeyPatch()
request.addfinalizer(mpatch.undo)
@ -97,7 +99,8 @@ notset = Notset()
class MonkeyPatch:
""" Object keeping a record of setattr/item/env/syspath changes. """
""" Object returned by the ``monkeypatch`` fixture keeping a record of setattr/item/env/syspath changes.
"""
def __init__(self):
self._setattr = []

View File

@ -1506,4 +1506,3 @@ class Function(FunctionMixin, pytest.Item, fixtures.FuncargnamesCompatAttr):
super(Function, self).setup()
fixtures.fillfixtures(self)

View File

@ -603,7 +603,7 @@ first execute with one instance and then finalizers are called
before the next fixture instance is created. Among other things,
this eases testing of applications which create and use global state.
The following example uses two parametrized funcargs, one of which is
The following example uses two parametrized fixture, one of which is
scoped on a per-module basis, and all the functions perform ``print`` calls
to show the setup/teardown flow::
@ -863,6 +863,14 @@ All test methods in this TestClass will use the transaction fixture while
other test classes or functions in the module will not use it unless
they also add a ``transact`` reference.
invocation-scoped fixtures
--------------------------
pytest 3.0 introduced a new advanced scope for fixtures: ``"invocation"``. Fixtures marked with
this scope can be requested from any other scope, providing a version of the fixture for that scope.
See more in :ref:`invocation_scoped_fixture`.
Shifting (visibility of) fixture functions
----------------------------------------------------

View File

@ -0,0 +1,63 @@
.. _invocation_scoped_fixture:
Invocation-scoped fixtures
==========================
.. versionadded:: 3.0
.. note::
This feature is experimental, so if decided that it brings too much problems
or considered too complicated it might be removed in pytest ``3.1``.
Fixtures can be defined with ``invocation`` scope, meaning that the fixture
can be requested by fixtures from any scope, but when they do they assume
the same scope as the fixture requesting it.
An ``invocation``-scoped fixture can be requested from different scopes
in the same test session, in which case each scope will have its own copy.
Example
-------
Consider a fixture which manages external process execution:
this fixture provides auxiliary methods for tests and fixtures to start external
processes while making sure the
processes terminate at the appropriate time. Because it makes sense
to start a webserver for the entire session and also to execute a numerical
simulation for a single test function, the ``process_manager``
fixture can be declared as ``invocation``, so each scope gets its own
value and can manage processes which will live for the duration of the scope.
.. code-block:: python
import pytest
@pytest.fixture(scope='invocation')
def process_manager():
"""
Return a ProcessManager instance which can be used to start
long-lived processes and ensures they are terminated at the
appropriate scope.
"""
m = ProcessManager()
yield m
m.shutdown_all()
@pytest.fixture(scope='session')
def server(process_manager):
process_manager.start(sys.executable, 'server.py')
@pytest.fixture(scope='function')
def start_simulation(process_manager):
import functools
return functools.partial(process_manager.start,
sys.executable, 'simulator.py')
In the above code, simulators started using ``start_simulation`` will be
terminated when the test function exits, while the server will be kept
active for the entire simulation run, being terminated when the test session
finishes.

View File

@ -6,7 +6,7 @@ Monkeypatching/mocking modules and environments
Sometimes tests need to invoke functionality which depends
on global settings or which invokes code which cannot be easily
tested such as network access. The ``monkeypatch`` function argument
tested such as network access. The ``monkeypatch`` fixture
helps you to safely set/delete an attribute, dictionary item or
environment variable or to modify ``sys.path`` for importing.
See the `monkeypatch blog post`_ for some introduction material
@ -14,6 +14,9 @@ and a discussion of its motivation.
.. _`monkeypatch blog post`: http://tetamap.wordpress.com/2009/03/03/monkeypatching-in-unit-tests-done-right/
As of pytest-3.0, the ``monkeypatch`` fixture is :ref:`invocation-scoped <invocation_scoped_fixture>`
meaning it can be requested from fixtures of any scope.
Simple example: monkeypatching functions
---------------------------------------------------
@ -53,27 +56,31 @@ This autouse fixture will be executed for each test function and it
will delete the method ``request.session.Session.request``
so that any attempts within tests to create http requests will fail.
example: setting an attribute on some class
------------------------------------------------------
example: setting an environment variable for the test session
-------------------------------------------------------------
If you need to patch out ``os.getcwd()`` to return an artificial
value::
If you would like for an environment variable to be
configured for the entire test session, you can add this to your
top-level ``conftest.py`` file:
def test_some_interaction(monkeypatch):
monkeypatch.setattr("os.getcwd", lambda: "/")
.. code-block:: python
which is equivalent to the long form::
# content of conftest.py
@pytest.fixture(scope='session', autouse=True)
def enable_debugging(monkeypatch):
monkeypatch.setenv("DEBUGGING_VERBOSITY", "4")
def test_some_interaction(monkeypatch):
import os
monkeypatch.setattr(os, "getcwd", lambda: "/")
This auto-use fixture will set the ``DEBUGGING_VERBOSITY`` environment variable for
the entire test session.
Note that the ability to use a ``monkeypatch`` fixture from a ``session``-scoped
fixture was added in pytest-3.0.
Method reference of the monkeypatch fixture
-------------------------------------------
Method reference of the monkeypatch function argument
-----------------------------------------------------
.. autoclass:: monkeypatch
.. autoclass:: MonkeyPatch
:members: setattr, replace, delattr, setitem, delitem, setenv, delenv, syspath_prepend, chdir, undo
``monkeypatch.setattr/delattr/delitem/delenv()`` all

View File

@ -0,0 +1,214 @@
def test_invocation_request(testdir):
"""
Simple test case with session and module scopes requesting an
invocation-scoped fixture.
"""
testdir.makeconftest("""
import pytest
@pytest.fixture(scope='invocation')
def my_name(request):
if request.scope == 'function':
return request.function.__name__
elif request.scope == 'module':
return request.module.__name__
elif request.scope == 'session':
return '<session>'
@pytest.fixture(scope='session')
def session_name(my_name):
return my_name
@pytest.fixture(scope='module')
def module_name(my_name):
return my_name
""")
testdir.makepyfile(test_module_foo="""
def test_foo(my_name, module_name, session_name):
assert my_name == 'test_foo'
assert module_name == 'test_module_foo'
assert session_name == '<session>'
""")
testdir.makepyfile(test_module_bar="""
def test_bar(my_name, module_name, session_name):
assert my_name == 'test_bar'
assert module_name == 'test_module_bar'
assert session_name == '<session>'
""")
result = testdir.runpytest()
result.stdout.fnmatch_lines(['*2 passed*'])
def test_override_invocation_scoped(testdir):
"""Test that it's possible to override invocation-scoped fixtures."""
testdir.makeconftest("""
import pytest
@pytest.fixture(scope='invocation')
def magic_value(request):
if request.scope == 'function':
return 1
elif request.scope == 'module':
return 100
@pytest.fixture(scope='module')
def module_magic_value(magic_value):
return magic_value * 2
""")
testdir.makepyfile(test_module_override="""
import pytest
@pytest.fixture(scope='module')
def magic_value():
return 42
def test_override(magic_value, module_magic_value):
assert magic_value == 42
assert module_magic_value == 42 * 2
""")
testdir.makepyfile(test_normal="""
def test_normal(magic_value, module_magic_value):
assert magic_value == 1
assert module_magic_value == 200
""")
result = testdir.runpytest()
result.stdout.fnmatch_lines(['*2 passed*'])
class TestAcceptance:
"""
Complete acceptance test for a invocation-scoped fixture.
"""
def test_acceptance(self, testdir):
"""
Tests a "stack" fixture which provides a separate list to each scope which uses it.
Some notes:
- For each scope, define 2 fixtures of the same scope which use the "stack" fixture,
to ensure they get the same "stack" instance for that scope.
- Creates multiple test files, which tests on each modifying and checking fixtures to
ensure things are working properly.
"""
testdir.makeconftest("""
import pytest
@pytest.fixture(scope='invocation')
def stack():
return []
@pytest.fixture(scope='session')
def session1_fix(stack):
stack.append('session1_fix')
return stack
@pytest.fixture(scope='session')
def session2_fix(stack):
stack.append('session2_fix')
return stack
@pytest.fixture(scope='module')
def module1_fix(stack):
stack.append('module1_fix')
return stack
@pytest.fixture(scope='module')
def module2_fix(stack):
stack.append('module2_fix')
return stack
@pytest.fixture(scope='class')
def class1_fix(stack):
stack.append('class1_fix')
return stack
@pytest.fixture(scope='class')
def class2_fix(stack):
stack.append('class2_fix')
return stack
""")
testdir.makepyfile(test_0="""
import pytest
@pytest.fixture
def func_stack(stack):
return stack
def test_scoped_instances(session1_fix, session2_fix, module1_fix, module2_fix,
class1_fix, class2_fix, stack, func_stack):
assert session1_fix is session2_fix
assert module1_fix is module2_fix
assert class1_fix is class2_fix
assert stack is func_stack
assert session1_fix is not module2_fix
assert module2_fix is not class1_fix
assert class1_fix is not stack
""")
testdir.makepyfile(test_1="""
def test_func_1(request, session1_fix, session2_fix, module1_fix, module2_fix, stack):
assert stack == []
assert session1_fix == ['session1_fix', 'session2_fix']
session1_fix.append('test_1::test_func_1')
assert module1_fix == ['module1_fix', 'module2_fix']
module1_fix.append('test_1::test_func_1')
class Test:
def test_1(self, request, session1_fix, module1_fix, class1_fix, class2_fix, stack):
assert stack == []
assert session1_fix == ['session1_fix', 'session2_fix', 'test_1::test_func_1']
session1_fix.append('test_1::Test::test_1')
assert module1_fix == ['module1_fix', 'module2_fix', 'test_1::test_func_1']
module1_fix.append('test_1::test_func_1')
assert class1_fix == ['class1_fix', 'class2_fix']
class1_fix.append('test_1::Test::test_1')
def test_2(self, request, class1_fix, class2_fix):
assert class1_fix == ['class1_fix', 'class2_fix', 'test_1::Test::test_1']
class1_fix.append('Test.test_2')
def test_func_2(request, session1_fix, session2_fix, module1_fix, class1_fix, class2_fix, stack):
assert stack == []
assert session1_fix == ['session1_fix', 'session2_fix', 'test_1::test_func_1',
'test_1::Test::test_1']
session1_fix.append('test_1::test_func_2')
assert module1_fix == ['module1_fix', 'module2_fix', 'test_1::test_func_1', 'test_1::test_func_1']
assert class1_fix == ['class1_fix', 'class2_fix']
""")
testdir.makepyfile(test_2="""
import pytest
@pytest.fixture(scope='session')
def another_session_stack(stack):
stack.append('other_session_stack')
return stack
def test_func_2(request, another_session_stack, module1_fix, stack):
assert stack == []
assert another_session_stack == [
'session1_fix',
'session2_fix',
'test_1::test_func_1',
'test_1::Test::test_1',
'test_1::test_func_2',
'other_session_stack',
]
assert module1_fix == ['module1_fix']
""")
result = testdir.runpytest()
assert result.ret == 0
result.stdout.fnmatch_lines('* 6 passed in *')

View File

@ -421,6 +421,25 @@ class TestCaptureFixture:
"*capsys*capfd*same*time*",
"*2 error*"])
def test_capturing_getfixturevalue(self, testdir):
"""Test that asking for "capfd" and "capsys" using request.getfixturevalue
in the same test is an error.
"""
testdir.makepyfile("""
def test_one(capsys, request):
request.getfixturevalue("capfd")
def test_two(capfd, request):
request.getfixturevalue("capsys")
""")
result = testdir.runpytest()
result.stdout.fnmatch_lines([
"*test_one*",
"*capsys*capfd*same*time*",
"*test_two*",
"*capsys*capfd*same*time*",
"*2 failed in*",
])
@pytest.mark.parametrize("method", ["sys", "fd"])
def test_capture_is_represented_on_failure_issue128(self, testdir, method):
p = testdir.makepyfile("""

View File

@ -329,3 +329,35 @@ def test_issue1338_name_resolving():
monkeypatch.delattr('requests.sessions.Session.request')
finally:
monkeypatch.undo()
def test_invocation_scoped_monkeypatch(testdir):
testdir.makeconftest("""
import pytest
import sys
@pytest.fixture(scope='module')
def stamp_sys(monkeypatch):
monkeypatch.setattr(sys, 'module_stamped', True, raising=False)
""")
testdir.makepyfile(test_inv_mokeypatch_1="""
import sys
def test_stamp_1(monkeypatch, stamp_sys):
assert sys.module_stamped
monkeypatch.setattr(sys, 'function_stamped', True, raising=False)
assert sys.function_stamped
def test_stamp_2(monkeypatch):
assert sys.module_stamped
assert not hasattr(sys, 'function_stamped')
""")
testdir.makepyfile(test_inv_mokeypatch_2="""
import sys
def test_no_stamps():
assert not hasattr(sys, 'module_stamped')
assert not hasattr(sys, 'function_stamped')
""")
result = testdir.runpytest()
result.stdout.fnmatch_lines(['*3 passed*'])