Open shoyer opened 6 years ago
@shoyer
Type annotations and default values would be more compact, but the decorator removes all the magic.
But that doesn't require any changes to pytest, that's something you can implement on your own today.
Consider that mock.patch
and hypothesis.given
(just to cite a few examples) already work by decorating test functions without changing pytest at all.
@ms-lolo
Could someone help me understand a little more about what current limitations live in pytest that prevent me from wrapping a normal test function in a decorator? I'd love to help poke at things a bit more and learn more about the pytest internals in the process.
There's none AFAIK. Here's a quick example of injecting custom fixtures using a normal Python decorator:
def myfixtures(**input_fixtures):
def wrapped(fn):
def test_func():
# setup fixtures
fixture_gens = {name: fixture_func() for name, fixture_func in input_fixtures.items()}
kwargs = {name: next(fixture_gen) for name, fixture_gen in fixture_gens.items()}
try:
fn(**kwargs)
finally:
for name, fixture_gen in fixture_gens.items():
try:
next(fixture_gen)
except StopIteration:
pass
else:
raise RuntimeError(f"{name} can only yield once")
test_func.__name__ = fn.__name__
return test_func
return wrapped
def foo():
print()
print("foo setup")
yield 42
print("foo teardown")
@myfixtures(x=foo)
def test_foo(x):
print("test_foo started")
assert x == 42
print("test_foo done")
Output:
λ pytest .tmp\test_custom_fixtures.py -s --no-header
======================== test session starts ========================
collected 1 item
.tmp\test_custom_fixtures.py
foo setup
test_foo started
test_foo done
foo teardown
.
========================= 1 passed in 0.02s =========================
There's none AFAIK. Here's a quick example of injecting custom fixtures using a normal Python decorator:
Well that looks promising! Then maybe I was just struggling with parametrize
that has a similar string value being interpreted for special meaning 🤔 but your example is definitely helpful thank you!
Type annotations and default values would be more compact, but the decorator removes all the magic.
But that doesn't require any changes to pytest, that's something you can implement on your own today.
OK, awesome -- maybe we should just encourage that pattern!
There's at least a small amount of associated boilerplate (e.g., your myfixtures
function), but that could certainly be prototyped in a separate library. Given how popular this idea seems to be, it might make sense to eventually move that into pytest as an alternative to pytest's standard fixtures?
Here's my attempt to get this working with context managers:
from contextlib import ExitStack, contextmanager
def use_fixtures(*args, **kwargs):
def decorator(func):
def wrapper():
with ExitStack() as stack:
args2 = [stack.enter_context(a()) for a in args]
kwargs2 = {k: stack.enter_context(v()) for k, v in kwargs.items()}
out = func(*args2, **kwargs2)
return out
wrapper.__name__ = func.__name__
return wrapper
return decorator
@contextmanager
def foo():
print(f"foo setup")
yield 42
print("foo teardown")
@use_fixtures(x=foo)
def test_foo(x):
print("test_foo started")
assert x == 42
print("test_foo done")
@contextmanager
@use_fixtures(x=foo)
def nested(x):
print(f"nested setup")
yield x
print("nested teardown")
@use_fixtures(x=nested)
def test_nested(x):
print("test_foo started")
assert x == 42
print("test_foo done")
test_foo()
gives the right result:
>>> test_foo()
foo setup
test_foo started
test_foo done
foo teardown
But the nested context manager doesn't (foo teardown
should happen at the end):
>>> test_nested()
foo setup
foo teardown
nested setup
test_foo started
test_foo done
nested teardown
I'm probably missing something obvious here...
Given how popular this idea seems to be, it might make sense to eventually move that into pytest as an alternative to pytest's standard fixtures?
Perhaps, but here we are only getting our feet wet: fixtures support scopes, parametrization, autousing, etc. For that to be included into the core, there's a long road ahead both in implementation and designing how those new pieces fit together with the rest of pytest's features.
Having said that, this is an excellent candidate for a plugin: you are free to experiment different designs at your leisure.
Perhaps, but here we are only getting our feet wet: fixtures support scopes, parametrization, autousing, etc.
use_fixture
that takes a callable like our normal use_fixture
will, and that callable will return/yield one-or-more callables , which are then passed into each iteration of the test... Much like the current test collection machinery does, just more explicitly.tox
environment... Let tox
take care of ensuring I have an environment suitable for those tests not my test runner. @nicoddemus One thing to note about the default arg approach is that it breaks things like hypothesis, which doesn't permit default arguments. Not sure if that's an issue, but thought I'd raise it. That's one of the reasons why Ward supports both the "default argument" approach and the decorator approach.
e.g. you can do
@fixture
def my_fixture():
yield 1
@test("check that it is 1")
def _(f=my_fixture):
assert f == 1
OR, using the @using
decorator as has also been suggested here (and seems to be the preferred approach):
@fixture
def my_fixture():
yield 1
@test("check that it is 1")
@using(f=my_fixture)
def _(f):
assert f == 1
Ahh right, thanks @darrenburns!
What is the current status of this?
The current status is basically: not something that will be implemented in the core directly, but which someone can experiment using a plugin. Not sure if any progress has been done on the latter.
Btw is this useful to leave open?
I think it is. There are quite few people out there who would love such a feature (including prominent people such as @shoyer). This issue can serve as a meeting point to attempt pushing each other to move this a bit further ;-).
Magic name matching is a constant hurdle for new contributors not familiar with any given code-base: If a fixture is missing, there is absolutely no indication in the code where it is supposed to be loaded from - is it a local conftest.py
, a missing pytest plugin or on some other obscure path. IMHO it just feels against the principles of python (e.g. 12. "In the face of ambiguity, refuse the temptation to guess." or 19. "Namespaces are one honking great idea -- let's do more of those!").
I guess the problem is that one usually gets bitten by this while working with foreign packages rather than one's own.
Thanks @burnpanck.
Even if closed, the issue is still searchable, and people can further comment. My experience is that by leaving it open we are signalling that this is something we plan to integrate into the core eventually, and people then ask the status on it because (understandably) don't want to go through dozens of posts to understand the status.
But I don't think it is a big deal to leave it open too.
Personally, ever since typing and type annotation is part of the language I was hoping to experiment with this,
Unfortunately it's not clear if/when we can even create a base for this
Hi folks, I've been working on a dependency injection system (repo, docs) that has an API inspired by FastAPI and scoping/caching rules (somewhat) inspired by Pytest. @graingert thought it might be applicable to this issue, so I wrote up a quick prototype:
from typing import Annotated, AsyncGenerator, List
import pytest
from di.pytest import Depends, inject
class Log(List[str]):
pass
def dep(log: Log) -> int:
log.append("dep")
return 1
async def generator_dep(log: Log, inner_val: Annotated[int, Depends(dep)]) -> AsyncGenerator[int, None]:
log.append("generator_dep setup")
yield inner_val + 1
log.append("generator_dep teardown")
@pytest.mark.anyio
@inject
async def test_inject(log: Log, v: Annotated[int, Depends(generator_dep)]) -> None:
assert v == 2
assert log == ["dep", "generator_dep setup"] # teardown hasn't run yet
My WIP branch for this is here: https://github.com/adriangb/di/pull/43
Before I do any more work on it, I wanted to post here to see if there'd be any interest / conceptual input into what an integration like this should look like. I have some thoughts on rough edges but I'll hold off on them for now.
I've been trying to annotate all my tests that use a fixture with pytest.mark.usefixtures
(I don't write my own fixtures, because I dislike the pattern so much). Would it be possible to have a configuration option to disable magic name matching without an explicit pytest.mark.usefixtures
?
@grahamgower that particular one is out of scope for this issue and currently extremely unlikely as currently pytest has only name matching
@adriangb that approach looks interesting, but unless there is a way to use annotations instead of magic default values, this doest sit too well with me
with the current tech debt in pytest, im also under the impression, that unless we slowly externalize our own system first, we are unlikely to have success with integrating a new system in a way that wont break immense amounts of test-suites
in particular parameterization, dependencies and setup reduction reordering are going to be very challenging topics
i absolutely want pytest to see a better DI system, however we have to evolve the codebase into using one
if there was a api we could in parts start to use for better management of setupstate, value storage, interacting with fixture definition, i beleive that would be a much better starting point than the outer facade of a di system
as pytest has a already existing internal DI system thats by no means easy to replace/reshape
@adriangb that approach looks interesting, but unless there is a way to use annotations instead of magic default values, this doest sit too well with me
could you clarify what you mean? We also support v: Annotated[int, Depends(generator_dep)]
, and when you're injecting a class you don't even need that (log: Log
). This is covered here. And it's completely customizable, so I guess you could even implement matching on names if you wanted to 🤷
My idea was to interface with the existing DI system. You'd be able to opt in to the above proposed DI system by (installing the dependency) and then adding the @inject
decorator (or something like that). The test function itself would still be able to accept pytest fixtures, but sub-dependencies would not (so limited interoperability). And yeah you'd probably have to rewrite a lot of your code to really take advantage of it, but that would still be the case if pytest came out with a feature that allowed you to match on fixture objects/values instead of names. Maybe with a pytest plugin it could be more seamless (register pytest fixtures with the di container?), but I haven't looked into that.
@adriangb i like that annotation approach, for pytest i would probalby have a helper to make those annotations spell nicely
its pretty strange that mypy/pyrright "interpret" strange default values, but don't handle annotations nicely
i suspect there will be some extra need for plugins (just like attrs, pydantic & co)
i would probalby have a helper to make those annotations spell nicely
Do you mean like an Annotated
alias?
its pretty strange that mypy/pyrright "interpret" strange default values, but don't handle annotations nicely
to be fair to those tools, they only interpret those default values because we hack them to do so (same thing FastAPI does more or less)
i suspect there will be some extra need for plugins (just like attrs, pydantic & co)
a plugin for MyPy or for pytest?
@RonnyPfannschmidt
but don't handle annotations nicely
what do you mean by that?
@graingert thats related to my misunderstanding about having the Dependent
global function just ignore the return value for type checking
as it turns out, the "support" is just a tactical type ignore, no actual support is there
@nicoddemus
your myfixtures
decorator fixes the problem I have with pytest, making it more explicit.
Sadly it seems to not work anymore due to the deprecation and removal of directly calling fixtures
Fixture "my_fruit" called directly. Fixtures are not meant to be called directly, but are created automatically when test functions request them as parameters.
https://docs.pytest.org/en/7.1.x/deprecations.html?highlight=calling#calling-fixtures-directly ( Removed in version 4.0)
Are there any ways to make it work again with branch 7.2.*?
I just tried the example from https://github.com/pytest-dev/pytest/issues/3834#issuecomment-685690616 and it works for me.
That error only happens when someone uses the @fixture
decorator, which the example does not.
One workaround is to extract the fixture code you have into a normal function, and call that from the fixture and the myfixtures
code.
Yes I'm sorry if I confused you. I used the fruit basket example from the pytest documentation which has a fixture decorator.
My understanding is that the fixture decorator makes it into a "proper" fixture and all the third party pytest plugins use such fixtures. I was hoping for a workaround for pytests name based matching which I worry will confuse people on my team. Maybe I misunderstood what you were trying to say in that comment.
Is there a way to do make a decorator similar to what you wrote possibly with getfixturevalue or something else that works on fixture decorated functions or am I just totally misunderstanding how the fixtures work and that isn't currently possible without a lot of effort?
This seems to work as a useful workaround
def make_test_with_fixtures_from_defaults(fn):
def default_func_names(func):
# HT: https://stackoverflow.com/a/12627202/259130
signature = inspect.signature(func)
return [
v.default.__name__
for k, v in signature.parameters.items()
if v.default is not inspect.Parameter.empty
]
fixture_names = default_func_names(fn)
z = textwrap.dedent(f'''\
def test_func({', '.join(fixture_names)}):
return fn({', '.join(fixture_names)})
''')
scope = dict(fn=fn, fixture_names=fixture_names)
exec(z, scope)
test_func = scope['test_func']
test_func.__name__ = fn.__name__
return test_func
seems to work. Assuming you don't call the function by keyword argument. Does that every happen? Or are there any other corner cases or plugins this might have trouble with?
from _pytest.tmpdir import tmp_path
#...
@make_test_with_fixtures_from_defaults
def test_create_file(tmp=tmp_path):
d = tmp / "sub"
d.mkdir()
p = d / "hello.txt"
p.write_text('lol')
assert p.read_text() == 'lol'
assert len(list(tmp.iterdir())) == 1
works as expected and passes(so do other examples I tried)
I know a lot of people don't like exec but it works. I could probably do something to set the signature with https://smarie.github.io/python-makefun/ instead but I think makefun might also use exec. Maybe https://github.com/dfee/forge?
Interesting approach. If you turn that into a package, I'm sure others would be interested in trying it out.
I think this version of the workaround should support both default argument and decorator provided fixtures like ward does
def make_test_with_fixtures(**fixture_kwargs):
def inner(fn):
signature = inspect.signature(fn)
keyword_names_to_fixtures = {
k: fixture_kwargs.get(k, None) or v.default
for k, v in signature.parameters.items()
}
assert all(v is not inspect.Parameter.empty
for v in keyword_names_to_fixtures.values()), (
'every parameter should have a matching fixture function '
'provided in either the decorator or default function')
keyword_names_to_fixture_names = {k: f.__name__ for (k, f) in keyword_names_to_fixtures.items()}
fixture_names = keyword_names_to_fixture_names.values()
z = textwrap.dedent(f'''\
def test_func({', '.join(fixture_names)}):
return fn({', '.join(kname+'='+fname for (kname, fname) in keyword_names_to_fixture_names.items())})
''')
scope = dict(fn=fn)
exec(z, scope)
test_func = scope['test_func']
test_func.__name__ = fn.__name__
return test_func
return inner
I tested it out and both
@make_test_with_fixtures()
def test_bar1(f1=fix_w_yield1, f2=fix_w_yield2, tmp=tmp_path):
print("test_bar")
print(f'{tmp}')
assert tmp.exists()
@make_test_with_fixtures(f1=fix_w_yield1, f2=fix_w_yield2, tmp=tmp_path)
def test_bar2(f1, f2, tmp):
print("test_bar")
print(f'{tmp}')
assert tmp.exists()
test_bar1 and test_bar2 seem to work.
I'll try to put it in a package when I have time. Maybe this weekend
I want to see it it possible avoid exec first(possibly via the python-forge package).
I want to see it it possible avoid exec first(possibly via the python-forge package).
Note that is an implementation detail, which you can change at will later. 👍
It's pretty barebones but people might find it useful to reference fixtures by reference.
https://github.com/rtaycher/pytest-fixture-ref pip install git+https://github.com/rtaycher/pytest-fixture-ref.git people should feel free to make suggestions or PRs or suggest name changes I'll try to publish it on pypi before the weekend
pip install git+https://github.com/rtaycher/pytest-fixture-ref.git
from pytest_fixture_ref import make_test_with_fixtures
@make_test_with_fixtures()
def test_bar1(f1=fix_w_yield1, f2=fix_w_yield2, tmp=tmp_path):
print("test_bar")
print(f'{tmp}')
assert tmp.exists()
@make_test_with_fixtures(f1=fix_w_yield1, f2=fix_w_yield2, tmp=tmp_path)
def test_bar2(f1, f2, tmp):
print("test_bar")
print(f'{tmp}')
assert tmp.exists()
@nicoddemus is there a common way to import pytest plugins?
from _pytest.tmpdir import tmp_path
works for tmp_path but I couldn't find some of the others.
What about fixtures in various pytest plugins?
There is no standard for that, each plugin decides which API to expose; so in your case from pytest_fixture_ref import make_test_with_fixtures
is fine. :+1:
Btw, if you have Twitter, feel free to tweet about that project and I'll be glad to retweet (also we can ask the pytest-dev official twitter to retweet too).
@rtaycher can I suggest splitting up the two versions of fixtures into their own decorators? I'm opposed to the version that inspects the functions' default values and I think it would be useful to have that decision be clearer when we write our tests. A lot of development on large code bases happens by copying nearby code and having them as separate decorators makes it clearer that, in this code base, we chose style A over style B.
@rtaycher can I suggest splitting up the two versions of fixtures into their own decorators? I'm opposed to the version that inspects the functions' default values and I think it would be useful to have that decision be clearer when we write our tests. A lot of development on large code bases happens by copying nearby code and having them as separate decorators makes it clearer that, in this code base, we chose style A over style B.
@ms-lolo Could you open an issue with some suggested names? I tend to bikeshed maybe @fixtures_from_default_value and @fixtures_from_ref? Both sound not quite right to me
@nicoddemus https://twitter.com/rtaycher1987/status/1593199919892156418 @ms-lolo I split them into using_fixtures_from_defaults/using_fixtures_from_kwargs since I realized this would work not just for tests but for fixtures that take other fixtures
sorry it took so long. Figuring out the CI/publish setup for the boilerplate I used took a while(hit a number of odd library issues, plus the autochangelog is really annoying)
It's now on pypi so people can just use pip install pytest-fixture-ref
Nice, thanks @rtaycher!
I also recommend that suggestions/requests to be made in https://github.com/rtaycher/pytest-fixture-ref/issues instead of in this thread, as that is the appropriate place. :+1:
A couple of notes:
The workaround
@pytest.fixture(name='session_manager')
def get_session_manager():
return GameSessionsManager()
def test_create_session(session_manager: GameSessionsManager):
...
is fragile: since the GameSessionsManager
type is casted to the argument, once implementation of get_session_manager
is changed (say, returns GameSessionsManager | None
), things can get broken.
@adriangb your proposal sounded very promising Did the work continued ? Is there some branch of pytest that could be used?
I got bogged down in internal implementations of pytest and never got to finishing it. It mostly worked, and worked well, but there was bugs I just couldn't fix. I don't think I'll have time to do it myself.
can you provide a open draft pr for reeference - it may enable us to put focus on the bugs to enable the rest
I think this was my WIP branch: https://github.com/adriangb/di/tree/pytest. But given that it was last touched 3 years ago I don't know the state of it. To be clear I'm not saying there were bugs in pytest, more so that I just didn't understand how things were supposed to work internally, especially when it came to the async plugins.
I have thrown together a quick plugin that allows fixtures to be resolved using Annotated[...]
rather than their full name.
e.g.
from typing import Annotated
from pytest_annotated import Fixture
class SomeThing:
pass
@pytest.fixture
async def somefixture(someotherfixture) -> SomeThing:
return SomeThing()
async def test_thing(st: Annotated[SomeThing, Fixture(somefixture)]):
assert isinstance(st, SomeThing)
you can find it here: https://github.com/tristan/pytest-annotated and here: https://pypi.org/project/pytest-annotated/
Unfortunately, I feel it requires the use of too many _private
parts of pytest, so I'm a bit scared of the maintenance burden it will require to keep it up to date. Would be super happy to have someone more familiar with the internals of pytest to make suggestions for making it less brittle .
that one is a funky little hack, we ought too figure if we can enable lookup by name, type and annotated type in future
https://pypi.org/project/pytest-unmagic/ is a pytest plugin that implements fixtures with normal import semantics.
Fixtures are applied to tests or other fixtures with a @use
decorator. Alternately, a fixture can be called without arguments to get its value inside a test or other fixture function.
@millerdev at first glance pytest-unmagic is a complete deception - using magic globals unrelated to the actual fixtures instead of actual values passed - i strongly discourage anyone from using it - it does the opposite of its name
@RonnyPfannschmidt Fixtures are global objects. Pytest registers them in an opaque hierarchical namespace, and effectively does a global lookup each time a fixture is requested. The magic that this plugin eliminates is function-scoped local names magically invoking global fixtures. Granted, the active request reference is not the nicest feature. Hopefully we can work together to improve it?
The active request reference allows unmagic.fixture
to work with pytest features such as --setup-show
and --fixtures
. While implemented as a module-level singleton by default, it does provide a mechanism for thread-local or other custom scoping. In most cases this will be unnecessary.
I have a development branch where the global active request reference has been removed, but it has trade-offs. The implementation is more complicated, and it is impossible to use fixtures in the context of unittest.TestCase
. I didn't release it (yet) because the test suite that pytest-unmagic was originally intended for uses unittest-style tests extensively, and allowing fixtures to be used there is a big win.
For now—in the very spirit of pytest fixtures—practicality beats purity.
This explanation seems to discard important distinction between concepts like registries, discovery, references,actions at a distance and some more
As far as I'm concerned it's much worse than what we have right now
I'm happy to work towards more explicit control of the registries as well as sane lookup
But injection of dependencies at a distance instead of parameters is absolutely off the table, that's completely unacceptable
My 2 cents on the plugin -- I agree with @RonnyPfannschmidt overall about the design, however I'm also happy that @millerdev did an experiment to try things out (it is his project after all!).
But regardless I wanted to share my opinions on some points (I'm not speaking for all maintainers or for the pytest project at all, but only how I see this topic):
The existence of an external plugin that does "fixtures without name matching" does not automatically imply endorsement from the pytest project, much less that it will be incorporated at some point.
I think pytest should never stray from the current fixture mechanism, or even support both, even if there are people clamoring for that to be the case -- besides being an controversial topic[^1], there's backward compatibility, and having to teach/learn multiple ways of doing the same thing.
I do think, however, that we should improve pytest so plugins can change how fixtures are handled, allowing plugins to implement whatever mechanism one prefers over the current name matching in core pytest.
[^1]: I think the current fixture mechanism in pytest is absolutely fine, IMO lots of frameworks implement things that seem like "magic" at first glance, for example dataclasses "magically" injecting methods, SQL/Django columns "magically" talking to a DB based on how attributes are declared... if you squint hard enough many framework features can be shrugged off (or even bad mouthed) as magic.
@nicoddemus: Clarke's third law states "any sufficiently advanced technology is indistinguishable from magic". That is not at all a bad thing. It just means that as soon as a mechanism is not obvious how it works, it appears as if it were magic. Once you understand how things work, its not magic anymore, but just a great power to wield. And with great power, comes great responsibility.
IMHO, what is wrong with the current fixture mechanism is the single global namespace shared among all participants in a given test session. Thus, this particular great power is hard to guide to its intended target. Rather than a surgical laser beam, these fixtures behave like a virus; once you unleash them, they go everywhere, hitting both intended and unintended targets. It appears magic to the many users, because once that virus arrives at your site (intended or not), you don't see where it comes from. This applies both to humans and to tools (type checkers). More importantly, the design makes it impossible to reason about the area of effect within any subscope, because you always need to know the full testing session before you can start figuring out where fixtures may come from or go to.
This is very different from the magic methods in dataclasses or the various forms of automatic schema validation and column creation in ORMs. Its true that it can appear equally magic to someone who doesn't understand the mechanism, but once you do understand, a) the rules are simple (as in the current pytest fixtures) and b) the amount of inputs you need to consider to apply them are reasonably bounded (unlike in the current pytest fixtures).
Thanks @burnpanck for the detailed reasoning, you make good points.
Regardless, all my previous points still stand: backward compatibility, more than one way to do the same thing, etc, and those do not change regardless of the discussion about what constitutes a magical behavior or not.
But again, this is all from my POV, I don't know if other core maintainers feel otherwise and think it is OK to add an additional way to use fixtures to core pytest.
I do think, however, that we should improve pytest so plugins can change how fixtures are handled, allowing plugins to implement whatever mechanism one prefers over the current name matching in core pytest.
Yay! :tada: This would be wonderful.
what is wrong with the current fixture mechanism is the single global namespace shared among all participants
This is precisely why I think pytest-unmagic is an improvement on pytest magic fixture parameters. "Unmagic" fixtures are always explicitly referenced, and do not occupy a single shared global namespace. The question "where is the code for that?" can be answered without any knowledge of pytest fixture name resolution.
The part that @RonnyPfannschmidt reacted so strongly to (I think) is the active request reference, which is an implementation detail that allows the plugin to bind fixture setup and teardown to the appropriate scope and to retrieve the value from pytest's internal fixture cache. It is easy to reason about the active request within the context of any given test or fixture, so that single global reference does not seem as problematic as a shared global namespace for all fixtures.
Maybe "unmagic" is a bit of a misnomer. The plugin moves the "magic" part from fixture name resolution, which everyone using fixtures is confronted with, to an implementation detail where most people don't need to know much if anything about it. You might need to know if you're running tests in parallel within a single Python interpreter, but that is not a problem I tried to solve in v1.0.
I've been happily using pytest for several projects for the past few years.
There's one part about pytest that I still struggle to get behind: The way that fixtures magically match argument names to fixtures -- and apparently I'm not alone in this feeling. I would much rather declare dependencies explicitly in some way using code. I know this would be more verbose, but that's a tradeoff I'm happy to make.
Is it possible to do this in some way with pytest today? If not, would you be open to adding an optional feature for this?
I was thinking perhaps something like the following, using an example adapted from the docs: