Skip to content

Make test parametrization override indirect fixtures #3629

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 29, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ Ryan Wooden
Samuel Dion-Girardeau
Samuele Pedroni
Segev Finer
Serhii Mozghovyi
Simon Gomizelj
Skylar Downes
Srinivas Reddy Thatiparthy
Expand Down
4 changes: 4 additions & 0 deletions appveyor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ test_script:
cache:
- '%LOCALAPPDATA%\pip\cache'
- '%USERPROFILE%\.cache\pre-commit'

# We don't deploy anything on tags with AppVeyor, we use Travis instead, so we
# might as well save resources
skip_tags: true
3 changes: 3 additions & 0 deletions changelog/2220.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
In case a (direct) parameter of a test overrides some fixture upon which the
test depends indirectly, do the pruning of the fixture dependency tree. That
is, recompute the full set of fixtures the test function needs.
57 changes: 48 additions & 9 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,43 @@ def get_direct_param_fixture_func(request):
return request.param


@attr.s(slots=True)
class FuncFixtureInfo(object):
def __init__(self, argnames, names_closure, name2fixturedefs):
self.argnames = argnames
self.names_closure = names_closure
self.name2fixturedefs = name2fixturedefs
# original function argument names
argnames = attr.ib(type=tuple)
# argnames that function immediately requires. These include argnames +
# fixture names specified via usefixtures and via autouse=True in fixture
# definitions.
initialnames = attr.ib(type=tuple)
names_closure = attr.ib(type="List[str]")
name2fixturedefs = attr.ib(type="List[str, List[FixtureDef]]")

def prune_dependency_tree(self):
"""Recompute names_closure from initialnames and name2fixturedefs

Can only reduce names_closure, which means that the new closure will
always be a subset of the old one. The order is preserved.

This method is needed because direct parametrization may shadow some
of the fixtures that were included in the originally built dependency
tree. In this way the dependency tree can get pruned, and the closure
of argnames may get reduced.
"""
closure = set()
working_set = set(self.initialnames)
while working_set:
argname = working_set.pop()
# argname may be smth not included in the original names_closure,
# in which case we ignore it. This currently happens with pseudo
# FixtureDefs which wrap 'get_direct_param_fixture_func(request)'.
# So they introduce the new dependency 'request' which might have
# been missing in the original tree (closure).
if argname not in closure and argname in self.names_closure:
closure.add(argname)
if argname in self.name2fixturedefs:
working_set.update(self.name2fixturedefs[argname][-1].argnames)

self.names_closure[:] = sorted(closure, key=self.names_closure.index)


class FixtureRequest(FuncargnamesCompatAttr):
Expand Down Expand Up @@ -1033,11 +1065,12 @@ def getfixtureinfo(self, node, func, cls, funcargs=True):
usefixtures = flatten(
mark.args for mark in node.iter_markers(name="usefixtures")
)
initialnames = argnames
initialnames = tuple(usefixtures) + initialnames
initialnames = tuple(usefixtures) + argnames
fm = node.session._fixturemanager
names_closure, arg2fixturedefs = fm.getfixtureclosure(initialnames, node)
return FuncFixtureInfo(argnames, names_closure, arg2fixturedefs)
initialnames, names_closure, arg2fixturedefs = fm.getfixtureclosure(
initialnames, node
)
return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs)

def pytest_plugin_registered(self, plugin):
nodeid = None
Expand Down Expand Up @@ -1085,6 +1118,12 @@ def merge(otherlist):
fixturenames_closure.append(arg)

merge(fixturenames)

# at this point, fixturenames_closure contains what we call "initialnames",
# which is a set of fixturenames the function immediately requests. We
# need to return it as well, so save this.
initialnames = tuple(fixturenames_closure)

arg2fixturedefs = {}
lastlen = -1
while lastlen != len(fixturenames_closure):
Expand All @@ -1106,7 +1145,7 @@ def sort_by_scope(arg_name):
return fixturedefs[-1].scopenum

fixturenames_closure.sort(key=sort_by_scope)
return fixturenames_closure, arg2fixturedefs
return initialnames, fixturenames_closure, arg2fixturedefs

def pytest_generate_tests(self, metafunc):
for argname in metafunc.fixturenames:
Expand Down
5 changes: 5 additions & 0 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,11 @@ def _genfunctions(self, name, funcobj):
# add funcargs() as fixturedefs to fixtureinfo.arg2fixturedefs
fixtures.add_funcarg_pseudo_fixture_def(self, metafunc, fm)

# add_funcarg_pseudo_fixture_def may have shadowed some fixtures
# with direct parametrization, so make sure we update what the
# function really needs.
fixtureinfo.prune_dependency_tree()

for callspec in metafunc._calls:
subname = "%s[%s]" % (name, callspec.id)
yield Function(
Expand Down
31 changes: 31 additions & 0 deletions testing/python/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,37 @@ def test_overridden_via_param(value):
rec = testdir.inline_run()
rec.assertoutcome(passed=1)

def test_parametrize_overrides_indirect_dependency_fixture(self, testdir):
"""Test parametrization when parameter overrides a fixture that a test indirectly depends on"""
testdir.makepyfile(
"""
import pytest

fix3_instantiated = False

@pytest.fixture
def fix1(fix2):
return fix2 + '1'

@pytest.fixture
def fix2(fix3):
return fix3 + '2'

@pytest.fixture
def fix3():
global fix3_instantiated
fix3_instantiated = True
return '3'

@pytest.mark.parametrize('fix2', ['2'])
def test_it(fix1):
assert fix1 == '21'
assert not fix3_instantiated
"""
)
rec = testdir.inline_run()
rec.assertoutcome(passed=1)

@ignore_parametrized_marks
def test_parametrize_with_mark(self, testdir):
items = testdir.getitems(
Expand Down