-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
fixtures: register finalizers with all previous fixtures in the stack (take 3) #7511
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
Changes from all commits
2e48455
5cf32ec
b222988
2f70561
6943c4f
e59e383
009e4a3
06b2f59
cbcea9e
b095542
bb69a50
d331a57
0aa66f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
Fixtures now properly register their finalizers with autouse and | ||
parameterized fixtures that execute before them in the fixture stack so they are torn | ||
down at the right times, and in the right order. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1725,6 +1725,144 @@ def test_world(self): | |
reprec.assertoutcome(passed=3) | ||
|
||
|
||
class TestMultiLevelAutouseAndParametrization: | ||
def test_setup_and_teardown_order(self, testdir): | ||
"""Tests that parametrized fixtures affect subsequent fixtures. (#6436) | ||
|
||
If a fixture uses a parametrized fixture, or, for any other reason, is executed | ||
after the parametrized fixture in the fixture stack, then it should be affected | ||
by the parametrization, and as a result, should be torn down before the | ||
parametrized fixture, every time the parametrized fixture is torn down. This | ||
should be the case even if autouse is involved and/or the linear order of | ||
fixture execution isn't deterministic. In other words, before any fixture can be | ||
torn down, every fixture that was executed after it must also be torn down. | ||
""" | ||
testdir.makepyfile( | ||
test_auto=""" | ||
import pytest | ||
|
||
def f(param): | ||
return param | ||
|
||
@pytest.fixture(scope="session", autouse=True) | ||
def s_fix(request): | ||
yield | ||
|
||
@pytest.fixture(scope="package", params=["p1", "p2"], ids=f, autouse=True) | ||
def p_fix(request): | ||
yield | ||
|
||
@pytest.fixture(scope="module", params=["m1", "m2"], ids=f, autouse=True) | ||
def m_fix(request): | ||
yield | ||
|
||
@pytest.fixture(scope="class", autouse=True) | ||
def another_c_fix(m_fix): | ||
yield | ||
|
||
@pytest.fixture(scope="class") | ||
def c_fix(): | ||
yield | ||
|
||
@pytest.fixture(scope="function", params=["f1", "f2"], ids=f, autouse=True) | ||
def f_fix(request): | ||
yield | ||
|
||
class TestFixtures: | ||
def test_a(self, c_fix): | ||
pass | ||
|
||
def test_b(self, c_fix): | ||
pass | ||
""" | ||
) | ||
result = testdir.runpytest("--setup-plan") | ||
test_fixtures_used = ( | ||
"(fixtures used: another_c_fix, c_fix, f_fix, m_fix, p_fix, request, s_fix)" | ||
) | ||
expected = ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I recommend using |
||
"SETUP S s_fix\n" | ||
" SETUP P p_fix['p1']\n" | ||
" SETUP M m_fix['m1']\n" | ||
" SETUP C another_c_fix (fixtures used: m_fix)\n" | ||
" SETUP C c_fix\n" | ||
" SETUP F f_fix['f1']\n" | ||
" test_auto.py::TestFixtures::test_a[p1-m1-f1] {0}\n" | ||
" TEARDOWN F f_fix['f1']\n" | ||
" SETUP F f_fix['f2']\n" | ||
" test_auto.py::TestFixtures::test_a[p1-m1-f2] {0}\n" | ||
" TEARDOWN F f_fix['f2']\n" | ||
" SETUP F f_fix['f1']\n" | ||
" test_auto.py::TestFixtures::test_b[p1-m1-f1] {0}\n" | ||
" TEARDOWN F f_fix['f1']\n" | ||
" SETUP F f_fix['f2']\n" | ||
" test_auto.py::TestFixtures::test_b[p1-m1-f2] {0}\n" | ||
" TEARDOWN F f_fix['f2']\n" | ||
" TEARDOWN C c_fix\n" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't seem right, why is @pytest.fixture(scope="class")
def c_fix():
yield
class TestFixtures:
def test_a(self, c_fix): pass
def test_b(self, c_fix): pass It should be torn down at the end of the session only, regardless of the involvement of other autouse fixtures, or am I missing something? 🤔 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The fix basically makes it so that the fixture order is a stack, and a fixture cannot be torn down unless all the fixtures that were setup after it have also been torn down. In this case, I believe This way, there's no surprises related to side effects. Without this (if For example, if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment nailed the change for me. I agree with your argument, that the new behavior is the correct one. This is indeed a subtle but significant change. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks a lot for the explanation @SalmonMode, and sorry about the delay, it helped understand the actual behavior behind the change! Again, really appreciate the patience on this, given that this is taking months now to review, but is a very sensitive and complex part of the codebase. The remark about it being now a stack helps a lot.
I understand the picture you are painting here, the problem I see here is "unwittingly", which is a synonym for implicitly... if When I see this code: @pytest.fixture(scope="class")
def c_fix():
yield
class TestFixtures:
def test_a(self, c_fix): pass
def test_b(self, c_fix): pass In my understanding on how things should work, If The same argument can also be made that this is a bug: if A stripped down example: import pytest
@pytest.fixture(scope="session", params=[1, 2], autouse=True)
def s():
pass
@pytest.fixture(scope="package", autouse=True)
def p():
pass
def test():
pass Given that The change here is implicitly adding a dependency to lower scoped fixtures to all their upper scoped fixtures, which I'm not sure it is correct/desirable. What do you folks think? Also I need to mention the sheer quality of the PR is remarkable! The documentation improvements are an amazing addition by themselves. 😍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi folks, Gentle ping here in case this got lost through the cracks. 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That's a bit vague, but let me word that differently: autouse fixtures are automatically applied to functions, not to other fixtures. When I mentioned that I was talking specifically about autouse fixtures, which I belive is the main point we are discussing.
Sorry, I don't see any inconsistency. 😕 In that example, pytest needs to execute
I disagree... Thanks for the examples, but all the output shown after them (with setup/teardown order of the fixtures involved) makes sense to me, so it is hard for me to understand what is the argument behind them, sorry. I believe the main point of disagreement between us is that you believe that fixtures should explicitly depend on any autouse fixture acting on it, and I don't think they should. To exemplify: import pytest
@pytest.fixture(scope="session", params=[1, 2], autouse=True)
def s():
pass
@pytest.fixture(scope="module")
def m():
pass
def test(m):
pass What happens today, is that pytest executes as if the code was written like this: import pytest
@pytest.fixture(scope="session", params=[1, 2])
def s():
pass
@pytest.fixture(scope="module")
def m():
pass
def test(s, m): # <--- s added because s is autouse
pass What you argue is that pytest should be doing this: import pytest
@pytest.fixture(scope="session", params=[1, 2])
def s():
pass
@pytest.fixture(scope="module")
def m(s): # <--- s added because s is autouse
pass
def test(s, m): # <--- s added because s is autouse
pass Is my undertanding correct? Please correct me if I'm wrong. If my understanding is right, then I argue that the current behavior is correct, and it will definitely break existing suites that are working, and working not by accident, but by design (I say that from our codebase at work). It is common for an autouse fixture to execute something which is system-wide, but not necessarily affects other fixtures in the system. I will give an example derived from real code from work: @pytest.fixture(scope="session", params=["br", "en"], autouse=True)
def set_system_locale(request):
# sets current system locale to request.param
...
# in another module
@pytest.fixture(scope="package", autouse=True)
def initialize_petsc():
# initializes petsc C++ runtime
... In the current behavior,
I'm not familiar with the issue with pytest-django you mention... perhaps you can exemplify it to clarify the point better? Again sorry for all the discussion; I'm not trying to be difficult, I'm really making an effort to make sure I'm not missing the point, but if I'm understanding the proposal correctly, I don't think it is correct. Perhaps a new example of how things work now, and how you belive they should work, but with "real" examples, might help me understand what you believe the problem is (in case I'm still missing your point, as I tried to exemplify above)? It might be I'm missing something due to the fact that we are using abstract examples (like Again thanks for the patience! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To clarify, I'm not saying that the autouse fixture has to be dependent on the higher level fixture. I'm saying that if there's a need for (in the example),
The goal with my change is to get fixtures working like a stack so it's easier to manage/debug fixtures. I'm not saying there is always a dependent implied, but if the setup/teardown plan is jumping around, that suggests to me that things are being tested in contexts that have more going on than is necessary, or because scopes are being leveraged in a certain way to control ordering of behavior (and I believe that way could be made more effective/convenient/easy to read). In regards to your work example, things get tricky, because I don't know where that first fixture is defined. If it's in your top-level contest, for example, then I would expect that second fixture to run twice, and if that's not desired, then I wouldn't have that first fixture in the top level conftest. That said, I've been thinking about this for a while because a Regarding the pytest-django stuff, this is the PR I made to solve it in response to the merging if the original take of this PR: I'm on mobile, so hopefully the description there explains it well. All that said, I find myself losing the forest for the trees a bit, so I'm definitely gonna have to sleep on this, and come back with some fresh eyes and re-read everything haha There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, so I took a look back, and read through things a bit more carefully (although I still need a bit more caffeine haha). That said, I did spot an issue with how things currently work in regards to your work example, and this bit in the docs:
Given these fixtures: @pytest.fixture(scope='session', autouse=True, params=[1, 2])
def s():
pass
@pytest.fixture(scope='class', autouse=True)
def c():
pass One would expect Given these test classes, though: class TestX:
def test_it(self):
pass
class TestY:
def test_it_again(self):
pass Because pytest has to execute every test once for the first param set of Here's the setup plan (from pytest 6.1.2)
This means that your Unfortunately, because of parameterization, there's no way to guarantee an autouse fixture only happens once per its defined scope. NOW, I have an alternate proposal that I think might suit everyone's needs. In @bluetech's example: import pytest
@pytest.fixture(scope="module")
def m():
print('M SETUP')
yield
print('M TEARDOWN')
class TestIt:
@classmethod
@pytest.fixture(scope="class", autouse=True)
def c(cls):
print('C SETUP')
yield
print('C TEARDOWN')
def test_f1(self):
print('F1 CALL')
def test_f2(self, m):
print('F2 CALL') setup plan
the Ideally, I would like this to be the setup plan for that example:
But what if only
Since the To me, this is much easier to not just reason about, but also to orchestrate. This has pretty large implications, and could be incredibly challenging to figure out how to do this not just effectively, but in a way that folks can leverage in a predictable way for more advanced usage. But, I have an idea, and I think you'll get quite a kick out of it. Fixture Resolution Order (FRO) It works almost exactly like MRO (so it's a familiar concept), except the "child" we're trying to figure out the FRO of, is the master FRO. After the first pass, it can be sorted by scope, and this can be used to ensure stack-like behavior. Keep in mind, that entries in the FRO aren't required to be in the stack. It'd be more of a reference for knowing what fixtures should be executed after others, and how to teardown to appropriate common points. It can also look at the tests that would actually run (so not just the ones that were collected) and considered their actual dependencies to find out the actual scopes that running certain fixtures would be necessary for, even if the first test in those scopes isn't actually dependent on them, so they can be preemptively run for those scopes while also not running for scopes that don't need them. We can also apply some tweaks to it to make things operate more efficiently. For example, if autouse fixtures are shifted left in the FRO, then parameterized fixtures can be shifted as far right as can be (so as to affect as few other fixtures as possible), and if they're autouse parameterized fixtures, they can be shifted as right as possible among the autouse fixtures of their scope. For your use case, since you only want Nothing would ever get executed that didn't need to be, parameterized fixtures would have as reduced a footprint as they reasonably could, stack-like behavior would be achieved, reasoning about and planning setup plans would be easier (IMO), and I'm not sure, but I think this could also offer some opportunities for optimization in pytest's code. What do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
IIUC what you mean, then I agree and it is what I've been trying to convey: if a certain fixture requires the work from another fixture to happen before it, then it should explicitly declare a dependency on it. If I took a look at pytest-dev/pytest-django#807 (thanks for the link), and it seems what happened there was exactly that: the After reading back a few messages, I think an interpretation (which is also yours) is a bit incorrect and leads to some conclusions which are not true:
When in fact what it means in pytest (currently implemented) is:
"be requested" here also imply being requested by other fixtures, not only tests.
You are right; once parametrization plays in, the weak guarantee that a fixture at a high-level scope is executed once per scope breaks.
Indeed, it follows the rule I described above... but given
Yeah I think you are onto something, indeed a clear view or different approach on how pytest deals with fixtures is something we have been wanting to tackle in a while: #4871. Currently the fixture mechanism is implemented as:
There's definitely room for improvement there, however I do think we should keep 1) working as is. Perhaps we should move the discussion there and close this PR, or do you think we should analyse this some more? Also I didn't hear from others in while here (understandable, keeping up with the discussion here takes a lot of time!). Again, thanks for the rich discussion and patience here @SalmonMode! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Haha yes, I agree. Getting here using the GitHub mobile app is also pricing challenging (I wanted to give it a try, but it definitely needs some work). And I agree. Given the nuances of this, the needs of pytest itself, its needs going forward, and backwards compatibility, I don't believe this PR is a sufficient fix. I've also started trying to implement the FRO approach to see how it could be done, and I can tell there's a lot of things that will need to be discussed. I'll move my proposal over to #4871, and this can be closed. |
||
" TEARDOWN C another_c_fix\n" | ||
" TEARDOWN M m_fix['m1']\n" | ||
" SETUP M m_fix['m2']\n" | ||
" SETUP C another_c_fix (fixtures used: m_fix)\n" | ||
" SETUP C c_fix\n" | ||
" SETUP F f_fix['f1']\n" | ||
" test_auto.py::TestFixtures::test_a[p1-m2-f1] {0}\n" | ||
" TEARDOWN F f_fix['f1']\n" | ||
" SETUP F f_fix['f2']\n" | ||
" test_auto.py::TestFixtures::test_a[p1-m2-f2] {0}\n" | ||
" TEARDOWN F f_fix['f2']\n" | ||
" SETUP F f_fix['f1']\n" | ||
" test_auto.py::TestFixtures::test_b[p1-m2-f1] {0}\n" | ||
" TEARDOWN F f_fix['f1']\n" | ||
" SETUP F f_fix['f2']\n" | ||
" test_auto.py::TestFixtures::test_b[p1-m2-f2] {0}\n" | ||
" TEARDOWN F f_fix['f2']\n" | ||
" TEARDOWN C c_fix\n" | ||
" TEARDOWN C another_c_fix\n" | ||
" TEARDOWN M m_fix['m2']\n" | ||
" TEARDOWN P p_fix['p1']\n" | ||
" SETUP P p_fix['p2']\n" | ||
" SETUP M m_fix['m1']\n" | ||
" SETUP C another_c_fix (fixtures used: m_fix)\n" | ||
" SETUP C c_fix\n" | ||
" SETUP F f_fix['f1']\n" | ||
" test_auto.py::TestFixtures::test_a[p2-m1-f1] {0}\n" | ||
" TEARDOWN F f_fix['f1']\n" | ||
" SETUP F f_fix['f2']\n" | ||
" test_auto.py::TestFixtures::test_a[p2-m1-f2] {0}\n" | ||
" TEARDOWN F f_fix['f2']\n" | ||
" SETUP F f_fix['f1']\n" | ||
" test_auto.py::TestFixtures::test_b[p2-m1-f1] {0}\n" | ||
" TEARDOWN F f_fix['f1']\n" | ||
" SETUP F f_fix['f2']\n" | ||
" test_auto.py::TestFixtures::test_b[p2-m1-f2] {0}\n" | ||
" TEARDOWN F f_fix['f2']\n" | ||
" TEARDOWN C c_fix\n" | ||
" TEARDOWN C another_c_fix\n" | ||
" TEARDOWN M m_fix['m1']\n" | ||
" SETUP M m_fix['m2']\n" | ||
" SETUP C another_c_fix (fixtures used: m_fix)\n" | ||
" SETUP C c_fix\n" | ||
" SETUP F f_fix['f1']\n" | ||
" test_auto.py::TestFixtures::test_a[p2-m2-f1] {0}\n" | ||
" TEARDOWN F f_fix['f1']\n" | ||
" SETUP F f_fix['f2']\n" | ||
" test_auto.py::TestFixtures::test_a[p2-m2-f2] {0}\n" | ||
" TEARDOWN F f_fix['f2']\n" | ||
" SETUP F f_fix['f1']\n" | ||
" test_auto.py::TestFixtures::test_b[p2-m2-f1] {0}\n" | ||
" TEARDOWN F f_fix['f1']\n" | ||
" SETUP F f_fix['f2']\n" | ||
" test_auto.py::TestFixtures::test_b[p2-m2-f2] {0}\n" | ||
" TEARDOWN F f_fix['f2']\n" | ||
" TEARDOWN C c_fix\n" | ||
" TEARDOWN C another_c_fix\n" | ||
" TEARDOWN M m_fix['m2']\n" | ||
" TEARDOWN P p_fix['p2']\n" | ||
"TEARDOWN S s_fix".format(test_fixtures_used).split("\n") | ||
) | ||
result.stdout.fnmatch_lines(expected, consecutive=True) | ||
|
||
|
||
class TestAutouseManagement: | ||
def test_autouse_conftest_mid_directory(self, testdir): | ||
pkgdir = testdir.mkpydir("xyz123") | ||
|
@@ -4236,6 +4374,25 @@ def test_suffix(fix_combined): | |
assert result.ret == 0 | ||
|
||
|
||
class TestFinalizerOnlyAddedOnce: | ||
@pytest.fixture(scope="class", autouse=True) | ||
def a(self): | ||
pass | ||
|
||
@pytest.fixture(scope="class", autouse=True) | ||
def b(self, a): | ||
pass | ||
|
||
def test_a_will_finalize_b(self, request): | ||
a = request._get_active_fixturedef("a") | ||
b = request._get_active_fixturedef("b") | ||
assert b._will_be_finalized_by_fixture(a) | ||
|
||
def test_a_only_finishes_once(self, request): | ||
a = request._get_active_fixturedef("a") | ||
assert len(a._finalizers) | ||
|
||
|
||
def test_yield_fixture_with_no_value(testdir): | ||
testdir.makepyfile( | ||
""" | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Relying on the finalizers being
functools.partial
s here seems fragile to me. Would prefer if some other more direct mechanism for this was used, like using a proper type instead offunctools.partial
, or storing tuples in_finalizers
, etc.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that this isn't a super clean approach. But I think getting a proper type/mechanism in place could make this a much larger change. I'm not sure how you wanna proceed with it though.