-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
Refactor fixture finalization #4871
Comments
we should set up a full topology to manage setup/teardown |
I was reading the comments on #4055 where it seemed that no changes would be considered in the short term because of the risk of breaking tests in the wild. Does opening this issue mean that this is now ripe enough for design and eventual implementation? |
Yep, just yesterday @RonnyPfannschmidt and I were chatting about this. It will be a gradual change but will eventually make the entire fixture execution model much more explicit and easier to follow than what we have today. We plan to tackle this when he gets back from his hiatus. |
Hi All, I made a PR #4055 for this issue over a year ago. One of the primary concerns that was made about the PR was that it might break plugins. Most of the major external plugins already have their own test suites. Would integrating the test suites of external plugins into the pytest test suite alleviate the current impasse? It seems like this might be a worthy task anyway to give confidence in any change to core code. |
We have thought about this, but it is kind of tricky: external test suites are outside of our control, so they might break for unrelated reasons, which would then break pytest. We could of course devise a way for those breakages to not block PRs and such, though. |
#7511 touches on one of the problems highlighted here. |
Coming from #7511, but I believe in have a solution to this. Currently, there's a few issues with regards to autouse and parameterized fixtures. From 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 might expect Given these test classes: 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)
Unfortunately, because of parameterization, there's no way to guarantee an autouse fixture only happens once per its defined scope. However, I have an alternate proposal that I think might suit everyone's needs without breaking any existing test suites that properly define their dependencies.. In @bluetech's example from our discussions on #7511: 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:
And this would be the plan 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 possibly other things), and this can be used to ensure optimized, stack-like behavior. Keep in mind, that entries in the FRO aren't required to be in the stack in terms of explicit dependencies. But it would figure out a deterministic, linearized order that allows fixtures to optimally be treated like a stack in regards to setup and teardown. It can also look at the tests that would actually run (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. 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, it allows for a good number of optimizations in pytest's internals. What do you think? |
I think that's fine TBH, we just need to make that explicit in the docs.
That's a pretty neat idea! My initial thought about building a DAG of fixtures and use that for setup/teardown is similar, but you go a step further and remove the lazy approach we have now (by "lazy" I mean figuring out which fixtures are needed during test execution). This approach would give the full fixture resolution order right after collection is complete. I can even imagine a neat API like this (highlevel-code): plan = FixturePlan(items)
fixtures = plan.setup(items[0])
items[0].test_func(**fixtures)
plan.teardown(items[0]) I also think it would be possible to add this alongside the current implementation under a separate setting, so we can publish it as experimental and see how it fares in the wild. @RonnyPfannschmidt @bluetech thoughts? |
Yeah, FRO wouldn't even guarantee it. But if it's sorted the way I mentioned, then it can at least reduce the number of times it would happen. I remember someone even had an issue a while back where they wanted some mechanism that worked the opposite of autouse in that it would force fixtures as far "right" in the execution order as possible. They could probably exploit this for that purpose.
Awesome! I'm wondering if it could even provide opportunities for new hooks to give users even more control. Maybe something like 🤔 |
Some thoughts on the original issue (the excessive finalizer registrations). This is coming from @jakkdl's PR #11833 which cleaned up some related stuff. And no I don't expect anyone to read this through 😀 We are talking about the following code in pytest/src/_pytest/fixtures.py Lines 1037 to 1044 in 2607fe8
What this does is, when we execute a fixture e.g. F1 which requests some other fixtures e.g. F2, F3, it registers F1's My first question is Why is this code necessary anyway?It is not clear at all why this code is needed. Because, every fixture registers its own finalizer with the relevant node right after it's executed: pytest/src/_pytest/fixtures.py Lines 1064 to 1070 in 2607fe8
We want the teardown order to be reverse setup order (AKA "stack" order). If we assume that the node tree is torn down correctly in stack order, and each fixture pushes its finalizer onto the stack right after setup, then we get the desired stack order, no extra stuff necessary. So I just tried to comment out the "register finish with dependencies" code, run the pytest tests andd see what happens. And the failure that comes back looks like this: import pytest
@pytest.fixture(scope="class", params=["a", "b"])
def clsparam(request):
return request.param
class TestClass:
@pytest.fixture(scope="class")
def clsfix(self, clsparam):
print(f"setup-{clsparam}")
yield
print(f"teardown-{clsparam}")
def test_method(self, clsparam, clsfix):
print(f"method-{clsparam}") The expected output is:
but the actual (after commenting) is: setup-a
method-a
method-b
teardown-a The Why does it happen? The flow of execution is:
So the problem here is that Is this the only reason for the code?I can't think of another reason, but it possible I'm missing something. The two test failures after commenting the code is due to this, so if there is another reason it is not covered by the tests at least. How does the code fix it?With the finalizer registration code, But this solution sucks (this issue), so... Other possible ways to fix the problem?In the flow of execution above, there were 2 "failure points":
To me this conjures up two possible solutions: Possible solution - parametrized higher-scoped collector nodesThe collection tree of our example is: <Dir pytest>
<Module yy.py>
<Class TestClass>
<Function test_method[a]>
<Function test_method[b]> But this is a bit funny - the <Dir pytest>
<Module yy.py>
<Class TestClass[a]>
<Function test_method[a]>
<Class TestClass[b]>
<Function test_method[b]> With this the Can this work? Ignoring backward compat entirely, I think the major downside of this is that class fixtures which are not (transitively) dependent on a class-scoped param will no longer be shared between Possible solution - make the cache key not matchWhat if Can this work? I don't know. One thing that could have killed this idea is dynamic In the meantime, anything we can do?The bad example that @nicoddemus gave is of the function-scoped
for argname in self.argnames:
fixturedef = request._get_active_fixturedef(argname)
if not isinstance(fixturedef, PseudoFixtureDef):
- fixturedef.addfinalizer(finalizer)
+ if fixturedef.scope == request.scope:
+ fixturedef.addfinalizer(finalizer)
|
Currently for each fixture which depends on another fixture, a "finalizer" is added to the list of the dependent fixture.
For example:
tmpdir
, as we know, depends ontmpdir_path
to create the temporary directory. Eachtmpdir
invocation ends up adding its finalization to the list of finalizers oftmpdir_path
. This is the mechanism used to finalize fixtures in the correct order (as thought we still have bugs in this area, as #1895 shows for example), and ensures that everytmpdir
will be destroyed before the requestedtmpdir_path
fixture.This then means that every high-scoped fixture might contain dozens, hundreds or thousands of "finalizers" attached to them. Fixture finalizers can be called multiple times without problems, but this consumes memory: each finalizer keeps its
SubRequest
object alive, containing a number of small variables:pytest/src/_pytest/fixtures.py
Lines 341 to 359 in ed68fcf
This can easily be demonstrated by applying this patch:
(this prints the finalizers attached to the "Session" node, where the session fixtures attach their finalization to)
And running this test:
I believe we can think of ways to refactor the fixture teardown mechanism to avoid this accumulation of finalizers on the objects. Ideally we should build a proper DAG of fixture dependencies which should be destroyed in the proper order. This would also make things more explicit and easier to follow IMHO.
The text was updated successfully, but these errors were encountered: