From 9e24b09a9f96908534e7bfb313df4a2c136b5094 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Mar 2018 20:59:10 -0300 Subject: [PATCH 1/2] Use re_match_lines in test_class_ordering "[1-a]" works fine using fnmatch_lines, but "[a-1]" breaks horribly inside `re`. --- testing/python/fixture.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/testing/python/fixture.py b/testing/python/fixture.py index 8638e361a6d..d1098799d91 100644 --- a/testing/python/fixture.py +++ b/testing/python/fixture.py @@ -2281,19 +2281,19 @@ def test_3(self): pass """) result = testdir.runpytest("-vs") - result.stdout.fnmatch_lines(""" - test_class_ordering.py::TestClass2::test_1[1-a] PASSED - test_class_ordering.py::TestClass2::test_1[2-a] PASSED - test_class_ordering.py::TestClass2::test_2[1-a] PASSED - test_class_ordering.py::TestClass2::test_2[2-a] PASSED - test_class_ordering.py::TestClass2::test_1[1-b] PASSED - test_class_ordering.py::TestClass2::test_1[2-b] PASSED - test_class_ordering.py::TestClass2::test_2[1-b] PASSED - test_class_ordering.py::TestClass2::test_2[2-b] PASSED - test_class_ordering.py::TestClass::test_3[1-a] PASSED - test_class_ordering.py::TestClass::test_3[2-a] PASSED - test_class_ordering.py::TestClass::test_3[1-b] PASSED - test_class_ordering.py::TestClass::test_3[2-b] PASSED + result.stdout.re_match_lines(r""" + test_class_ordering.py::TestClass2::test_1\[1-a\] PASSED + test_class_ordering.py::TestClass2::test_1\[2-a\] PASSED + test_class_ordering.py::TestClass2::test_2\[1-a\] PASSED + test_class_ordering.py::TestClass2::test_2\[2-a\] PASSED + test_class_ordering.py::TestClass2::test_1\[1-b\] PASSED + test_class_ordering.py::TestClass2::test_1\[2-b\] PASSED + test_class_ordering.py::TestClass2::test_2\[1-b\] PASSED + test_class_ordering.py::TestClass2::test_2\[2-b\] PASSED + test_class_ordering.py::TestClass::test_3\[1-a\] PASSED + test_class_ordering.py::TestClass::test_3\[2-a\] PASSED + test_class_ordering.py::TestClass::test_3\[1-b\] PASSED + test_class_ordering.py::TestClass::test_3\[2-b\] PASSED """) def test_parametrize_separated_order_higher_scope_first(self, testdir): From 59e7fd478eb7b3b1ab27f0eba1b8f5a8b8b08cd4 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Mar 2018 21:08:21 -0300 Subject: [PATCH 2/2] Sort fixtures by scope when determining fixture closure Fix #2405 --- _pytest/fixtures.py | 13 ++- changelog/2405.feature.rst | 1 + doc/en/fixture.rst | 44 ++++++++ testing/acceptance_test.py | 24 +++++ testing/python/fixture.py | 211 ++++++++++++++++++++++++++++++++++--- 5 files changed, 277 insertions(+), 16 deletions(-) create mode 100644 changelog/2405.feature.rst diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index e2ea84e3009..2ac340e6f4f 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -1021,9 +1021,6 @@ def _getautousenames(self, nodeid): if nextchar and nextchar not in ":/": continue autousenames.extend(basenames) - # make sure autousenames are sorted by scope, scopenum 0 is session - autousenames.sort( - key=lambda x: self._arg2fixturedefs[x][-1].scopenum) return autousenames def getfixtureclosure(self, fixturenames, parentnode): @@ -1054,6 +1051,16 @@ def merge(otherlist): if fixturedefs: arg2fixturedefs[argname] = fixturedefs merge(fixturedefs[-1].argnames) + + def sort_by_scope(arg_name): + try: + fixturedefs = arg2fixturedefs[arg_name] + except KeyError: + return scopes.index('function') + else: + return fixturedefs[-1].scopenum + + fixturenames_closure.sort(key=sort_by_scope) return fixturenames_closure, arg2fixturedefs def pytest_generate_tests(self, metafunc): diff --git a/changelog/2405.feature.rst b/changelog/2405.feature.rst new file mode 100644 index 00000000000..b041c132899 --- /dev/null +++ b/changelog/2405.feature.rst @@ -0,0 +1 @@ +Fixtures are now instantiated based on their scopes, with higher-scoped fixtures (such as ``session``) being instantiated first than lower-scoped fixtures (such as ``function``). The relative order of fixtures of the same scope is kept unchanged, based in their declaration order and their dependencies. diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index d2b2865ef31..2cd554f7fc0 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -256,6 +256,50 @@ instance, you can simply declare it: Finally, the ``class`` scope will invoke the fixture once per test *class*. + +Higher-scoped fixtures are instantiated first +--------------------------------------------- + +.. versionadded:: 3.5 + +Within a function request for features, fixture of higher-scopes (such as ``session``) are instantiated first than +lower-scoped fixtures (such as ``function`` or ``class``). The relative order of fixtures of same scope follows +the declared order in the test function and honours dependencies between fixtures. + +Consider the code below: + +.. code-block:: python + + @pytest.fixture(scope="session") + def s1(): + pass + + @pytest.fixture(scope="module") + def m1(): + pass + + @pytest.fixture + def f1(tmpdir): + pass + + @pytest.fixture + def f2(): + pass + + def test_foo(f1, m1, f2, s1): + ... + + +The fixtures requested by ``test_foo`` will be instantiated in the following order: + +1. ``s1``: is the highest-scoped fixture (``session``). +2. ``m1``: is the second highest-scoped fixture (``module``). +3. ``tempdir``: is a ``function``-scoped fixture, required by ``f1``: it needs to be instantiated at this point + because it is a dependency of ``f1``. +4. ``f1``: is the first ``function``-scoped fixture in ``test_foo`` parameter list. +5. ``f2``: is the last ``function``-scoped fixture in ``test_foo`` parameter list. + + .. _`finalization`: Fixture finalization / executing teardown code diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 36b9536f352..89a44911f27 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -964,3 +964,27 @@ def test2(): """) result = testdir.runpytest() result.stdout.fnmatch_lines(['* 2 passed *']) + + +def test_fixture_order_respects_scope(testdir): + """Ensure that fixtures are created according to scope order, regression test for #2405 + """ + testdir.makepyfile(''' + import pytest + + data = {} + + @pytest.fixture(scope='module') + def clean_data(): + data.clear() + + @pytest.fixture(autouse=True) + def add_data(): + data.update(value=True) + + @pytest.mark.usefixtures('clean_data') + def test_value(): + assert data.get('value') + ''') + result = testdir.runpytest() + assert result.ret == 0 diff --git a/testing/python/fixture.py b/testing/python/fixture.py index d1098799d91..59c5266cb7b 100644 --- a/testing/python/fixture.py +++ b/testing/python/fixture.py @@ -3,7 +3,7 @@ import _pytest._code import pytest from _pytest.pytester import get_public_names -from _pytest.fixtures import FixtureLookupError +from _pytest.fixtures import FixtureLookupError, FixtureRequest from _pytest import fixtures @@ -2282,18 +2282,18 @@ def test_3(self): """) result = testdir.runpytest("-vs") result.stdout.re_match_lines(r""" - test_class_ordering.py::TestClass2::test_1\[1-a\] PASSED - test_class_ordering.py::TestClass2::test_1\[2-a\] PASSED - test_class_ordering.py::TestClass2::test_2\[1-a\] PASSED - test_class_ordering.py::TestClass2::test_2\[2-a\] PASSED - test_class_ordering.py::TestClass2::test_1\[1-b\] PASSED - test_class_ordering.py::TestClass2::test_1\[2-b\] PASSED - test_class_ordering.py::TestClass2::test_2\[1-b\] PASSED - test_class_ordering.py::TestClass2::test_2\[2-b\] PASSED - test_class_ordering.py::TestClass::test_3\[1-a\] PASSED - test_class_ordering.py::TestClass::test_3\[2-a\] PASSED - test_class_ordering.py::TestClass::test_3\[1-b\] PASSED - test_class_ordering.py::TestClass::test_3\[2-b\] PASSED + test_class_ordering.py::TestClass2::test_1\[a-1\] PASSED + test_class_ordering.py::TestClass2::test_1\[a-2\] PASSED + test_class_ordering.py::TestClass2::test_2\[a-1\] PASSED + test_class_ordering.py::TestClass2::test_2\[a-2\] PASSED + test_class_ordering.py::TestClass2::test_1\[b-1\] PASSED + test_class_ordering.py::TestClass2::test_1\[b-2\] PASSED + test_class_ordering.py::TestClass2::test_2\[b-1\] PASSED + test_class_ordering.py::TestClass2::test_2\[b-2\] PASSED + test_class_ordering.py::TestClass::test_3\[a-1\] PASSED + test_class_ordering.py::TestClass::test_3\[a-2\] PASSED + test_class_ordering.py::TestClass::test_3\[b-1\] PASSED + test_class_ordering.py::TestClass::test_3\[b-2\] PASSED """) def test_parametrize_separated_order_higher_scope_first(self, testdir): @@ -3245,3 +3245,188 @@ def test_func(my_fixture): "*TESTS finalizer hook called for my_fixture from test_func*", "*ROOT finalizer hook called for my_fixture from test_func*", ]) + + +class TestScopeOrdering(object): + """Class of tests that ensure fixtures are ordered based on their scopes (#2405)""" + + @pytest.mark.parametrize('use_mark', [True, False]) + def test_func_closure_module_auto(self, testdir, use_mark): + """Semantically identical to the example posted in #2405 when ``use_mark=True``""" + testdir.makepyfile(""" + import pytest + + @pytest.fixture(scope='module', autouse={autouse}) + def m1(): pass + + if {use_mark}: + pytestmark = pytest.mark.usefixtures('m1') + + @pytest.fixture(scope='function', autouse=True) + def f1(): pass + + def test_func(m1): + pass + """.format(autouse=not use_mark, use_mark=use_mark)) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 'm1 f1'.split() + + def test_func_closure_with_native_fixtures(self, testdir, monkeypatch): + """Sanity check that verifies the order returned by the closures and the actual fixture execution order: + The execution order may differ because of fixture inter-dependencies. + """ + monkeypatch.setattr(pytest, 'FIXTURE_ORDER', [], raising=False) + testdir.makepyfile(""" + import pytest + + FIXTURE_ORDER = pytest.FIXTURE_ORDER + + @pytest.fixture(scope="session") + def s1(): + FIXTURE_ORDER.append('s1') + + @pytest.fixture(scope="module") + def m1(): + FIXTURE_ORDER.append('m1') + + @pytest.fixture(scope='session') + def my_tmpdir_factory(): + FIXTURE_ORDER.append('my_tmpdir_factory') + + @pytest.fixture + def my_tmpdir(my_tmpdir_factory): + FIXTURE_ORDER.append('my_tmpdir') + + @pytest.fixture + def f1(my_tmpdir): + FIXTURE_ORDER.append('f1') + + @pytest.fixture + def f2(): + FIXTURE_ORDER.append('f2') + + def test_foo(f1, m1, f2, s1): pass + """) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + # order of fixtures based on their scope and position in the parameter list + assert request.fixturenames == 's1 my_tmpdir_factory m1 f1 f2 my_tmpdir'.split() + testdir.runpytest() + # actual fixture execution differs: dependent fixtures must be created first ("my_tmpdir") + assert pytest.FIXTURE_ORDER == 's1 my_tmpdir_factory m1 my_tmpdir f1 f2'.split() + + def test_func_closure_module(self, testdir): + testdir.makepyfile(""" + import pytest + + @pytest.fixture(scope='module') + def m1(): pass + + @pytest.fixture(scope='function') + def f1(): pass + + def test_func(f1, m1): + pass + """) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 'm1 f1'.split() + + def test_func_closure_scopes_reordered(self, testdir): + """Test ensures that fixtures are ordered by scope regardless of the order of the parameters, although + fixtures of same scope keep the declared order + """ + testdir.makepyfile(""" + import pytest + + @pytest.fixture(scope='session') + def s1(): pass + + @pytest.fixture(scope='module') + def m1(): pass + + @pytest.fixture(scope='function') + def f1(): pass + + @pytest.fixture(scope='function') + def f2(): pass + + class Test: + + @pytest.fixture(scope='class') + def c1(cls): pass + + def test_func(self, f2, f1, c1, m1, s1): + pass + """) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 's1 m1 c1 f2 f1'.split() + + def test_func_closure_same_scope_closer_root_first(self, testdir): + """Auto-use fixtures of same scope are ordered by closer-to-root first""" + testdir.makeconftest(""" + import pytest + + @pytest.fixture(scope='module', autouse=True) + def m_conf(): pass + """) + testdir.makepyfile(**{ + 'sub/conftest.py': """ + import pytest + + @pytest.fixture(scope='module', autouse=True) + def m_sub(): pass + """, + 'sub/test_func.py': """ + import pytest + + @pytest.fixture(scope='module', autouse=True) + def m_test(): pass + + @pytest.fixture(scope='function') + def f1(): pass + + def test_func(m_test, f1): + pass + """}) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 'm_conf m_sub m_test f1'.split() + + def test_func_closure_all_scopes_complex(self, testdir): + """Complex test involving all scopes and mixing autouse with normal fixtures""" + testdir.makeconftest(""" + import pytest + + @pytest.fixture(scope='session') + def s1(): pass + """) + testdir.makepyfile(""" + import pytest + + @pytest.fixture(scope='module', autouse=True) + def m1(): pass + + @pytest.fixture(scope='module') + def m2(s1): pass + + @pytest.fixture(scope='function') + def f1(): pass + + @pytest.fixture(scope='function') + def f2(): pass + + class Test: + + @pytest.fixture(scope='class', autouse=True) + def c1(self): + pass + + def test_func(self, f2, f1, m2): + pass + """) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 's1 m1 m2 c1 f2 f1'.split()