diff --git a/changelog/1953.bugfix.rst b/changelog/1953.bugfix.rst new file mode 100644 index 000000000..9db33ab10 --- /dev/null +++ b/changelog/1953.bugfix.rst @@ -0,0 +1,20 @@ +Fix error when overwriting a parametrized fixture, while also reusing the super fixture value. + +.. code-block:: python + + # conftest.py + import pytest + + + @pytest.fixture(params=[1, 2]) + def foo(request): + return request.param + + + # test_foo.py + import pytest + + + @pytest.fixture + def foo(foo): + return foo * 2 diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index bf77d09f1..f0e02b8b9 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -47,6 +47,7 @@ from _pytest.config import _PluggyPlugin from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.deprecated import FILLFUNCARGS +from _pytest.mark import Mark from _pytest.mark import ParameterSet from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME @@ -1529,34 +1530,56 @@ class FixtureManager: return initialnames, fixturenames_closure, arg2fixturedefs def pytest_generate_tests(self, metafunc: "Metafunc") -> None: - for argname in metafunc.fixturenames: - faclist = metafunc._arg2fixturedefs.get(argname) - if faclist: - fixturedef = faclist[-1] - if fixturedef.params is not None: - markers = list(metafunc.definition.iter_markers("parametrize")) - for parametrize_mark in markers: - if "argnames" in parametrize_mark.kwargs: - argnames = parametrize_mark.kwargs["argnames"] - else: - argnames = parametrize_mark.args[0] + """Generate new tests based on parametrized fixtures used by the given metafunc""" - if not isinstance(argnames, (tuple, list)): - argnames = [ - x.strip() for x in argnames.split(",") if x.strip() - ] - if argname in argnames: - break - else: - metafunc.parametrize( - argname, - fixturedef.params, - indirect=True, - scope=fixturedef.scope, - ids=fixturedef.ids, - ) + def get_parametrize_mark_argnames(mark: Mark) -> Sequence[str]: + if "argnames" in mark.kwargs: + argnames = mark.kwargs[ + "argnames" + ] # type: Union[str, Tuple[str, ...], List[str]] else: - continue # Will raise FixtureLookupError at setup time. + argnames = mark.args[0] + if not isinstance(argnames, (tuple, list)): + argnames = [x.strip() for x in argnames.split(",") if x.strip()] + return argnames + + for argname in metafunc.fixturenames: + # Get the FixtureDefs for the argname. + fixture_defs = metafunc._arg2fixturedefs.get(argname) + if not fixture_defs: + # Will raise FixtureLookupError at setup time if not parametrized somewhere + # else (e.g @pytest.mark.parametrize) + continue + + # If the test itself parametrizes using this argname, give it + # precedence. + if any( + argname in get_parametrize_mark_argnames(mark) + for mark in metafunc.definition.iter_markers("parametrize") + ): + continue + + # In the common case we only look at the fixture def with the + # closest scope (last in the list). But if the fixture overrides + # another fixture, while requesting the super fixture, keep going + # in case the super fixture is parametrized (#1953). + for fixturedef in reversed(fixture_defs): + # Fixture is parametrized, apply it and stop. + if fixturedef.params is not None: + metafunc.parametrize( + argname, + fixturedef.params, + indirect=True, + scope=fixturedef.scope, + ids=fixturedef.ids, + ) + break + + # Not requesting the overridden super fixture, stop. + if argname not in fixturedef.argnames: + break + + # Try next super fixture, if any. def pytest_collection_modifyitems(self, items: "List[nodes.Item]") -> None: # Separate parametrized setups. diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index d54583858..9ae5a91db 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -396,6 +396,132 @@ class TestFillFixtures: result = testdir.runpytest(testfile) result.stdout.fnmatch_lines(["*3 passed*"]) + def test_override_fixture_reusing_super_fixture_parametrization(self, testdir): + """Override a fixture at a lower level, reusing the higher-level fixture that + is parametrized (#1953). + """ + testdir.makeconftest( + """ + import pytest + + @pytest.fixture(params=[1, 2]) + def foo(request): + return request.param + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.fixture + def foo(foo): + return foo * 2 + + def test_spam(foo): + assert foo in (2, 4) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*2 passed*"]) + + def test_override_parametrize_fixture_and_indirect(self, testdir): + """Override a fixture at a lower level, reusing the higher-level fixture that + is parametrized, while also using indirect parametrization. + """ + testdir.makeconftest( + """ + import pytest + + @pytest.fixture(params=[1, 2]) + def foo(request): + return request.param + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.fixture + def foo(foo): + return foo * 2 + + @pytest.fixture + def bar(request): + return request.param * 100 + + @pytest.mark.parametrize("bar", [42], indirect=True) + def test_spam(bar, foo): + assert bar == 4200 + assert foo in (2, 4) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*2 passed*"]) + + def test_override_top_level_fixture_reusing_super_fixture_parametrization( + self, testdir + ): + """Same as the above test, but with another level of overwriting.""" + testdir.makeconftest( + """ + import pytest + + @pytest.fixture(params=['unused', 'unused']) + def foo(request): + return request.param + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.fixture(params=[1, 2]) + def foo(request): + return request.param + + class Test: + + @pytest.fixture + def foo(self, foo): + return foo * 2 + + def test_spam(self, foo): + assert foo in (2, 4) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*2 passed*"]) + + def test_override_parametrized_fixture_with_new_parametrized_fixture(self, testdir): + """Overriding a parametrized fixture, while also parametrizing the new fixture and + simultaneously requesting the overwritten fixture as parameter, yields the same value + as ``request.param``. + """ + testdir.makeconftest( + """ + import pytest + + @pytest.fixture(params=['ignored', 'ignored']) + def foo(request): + return request.param + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.fixture(params=[10, 20]) + def foo(foo, request): + assert request.param == foo + return foo * 2 + + def test_spam(foo): + assert foo in (20, 40) + """ + ) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*2 passed*"]) + def test_autouse_fixture_plugin(self, testdir): # A fixture from a plugin has no baseid set, which screwed up # the autouse fixture handling.