Open oakkitten opened 3 years ago
Hi @oakkitten,
Thanks for the well written post!
If I may suggest something, here's a proof of concept decorator that allows writing fixtures like this:
@fixture_taking_arguments def dog(request, /, name, age=69): return f"{name} the dog aged {age}"
At first glance looks good, one issue is that the /
is new in Python 3.8 only.
Fixture parametrization currently is driven either by tests (using indirect parametrization) or directly in the fixture using the params
argument to @pytest.fixture
. I agree both are not as friendly as they could be.
Here, to the left of / you have other fixtures, and to the right you have parameters that are supplied using:
@dog.arguments("Buddy", age=7) def test_with_dog(dog): assert dog == "Buddy the dog aged 7"
I like how it looks, however fixtures in pytest should not really be imported, which makes the @dog.arguments
impracticable, as test modules won't have the dog
fixture available.
However we can get the same benefits by using a new mark:
@pytest.mark.fixture_args("dog", "Buddy", age=7)
def test_with_dog(dog):
assert dog == "Buddy the dog aged 7"def
This however passes a single set of parameters to the dog
fixture. If we also want to parametrize this so a set of parameters are sent to dog
, resulting in multiple calls to test_with_dog
(like a normal parametrization with indirect=True
would), we need to think a bit more.
The other examples you propose have the same "problem" of needing access to the fixture function, as I mentioned is a bit problematic given the current good practices.
Another thought: the problem of specifying fixture parameters in tests and in fixtures can be tackled separately. I mean, currently one accesses fixtures parameters using request.param
; we can improve that aspect independently from changing how parameters are passed from test functions to fixtures, and vice versa.
Btw: I know it can be a bit frustrating when you come up with a new syntax which seems better overall, however pytest has backward compatibility and complexity concerns as well (more than one way to do the same thing), so we need to balance those out.
Again thanks for the detailed proposal!
I like how it looks, however fixtures in pytest should not really be imported
Why not?
Either way, the call to dog.arguments
or similar methods only really needs the name of the fixture. In the scenario when one can't import a fixture directly (not sure how that would be the case) they could do something like this perhaps:
dog = fixture.by_name("dog")
If we also want to parametrize this so a set of parameters are sent to dog
As I mention above, the following syntax could work; it will also work with the fixture produced by fixture.by_name()
@dog.argumentize("Champion", arguments("Buddy", age=7))
def test_with_dog(dog):
...
pytest has backward compatibility and complexity concerns as well
Unless I'm missing something this syntax would not introduce any breaking changes
Also, I hope that new syntax should be simple and straightforward enough to completely supercede the old one and become the “only” way to pass arguments around
By the way, I didn't think of fixure parametrization using params=...
argument, but why not use the same syntax? For instance, the documentation gives the following example:
@pytest.fixture(scope="module", params=["smtp.gmail.com", "mail.python.org"])
def smtp_connection(request):
smtp_connection = smtplib.SMTP(request.param, 587, timeout=5)
...
This could be written as:
@fixture(scope="module")
@parameters("server").argumentize(("smtp.gmail.com", "mail.python.org"))
def smtp_connection(request, /, server):
smtp_connection = smtplib.SMTP(server, 587, timeout=5)
...
In fact, in the case where one wants to directly parametrize argumentize a single parameter, a syntax similar to fixture.by_name()
could be used instead:
server = fixture.literal("server")
@fixture(scope="module")
@server.argumentize("smtp.gmail.com", "mail.python.org")
def smtp_connection(request, /, server):
smtp_connection = smtplib.SMTP(server, 587, timeout=5)
...
Here you can think of literal()
as of a special fixture that returns its parameters verbatim, and of @parameters(dog, owner, "poop")
as of a shorthand for @parameters(dog, owner, literal("poop"))
...
a agree wit the general intent but am really hesitant to currently introduce new and far-fetching apis,
my main driver for the hesitation is the amount of technical debt we have lingering in the fixture/fixture scoping/parameterize system that makes such changes extremely fragile right now
as far as i gather here the general desire is to have more and better ways to declarative configure pytests dependency injection/parameterize
there is a number of potential pitfalls/documentation-al pain points about how to configure things
so in order to give this effort a realistic chance of success (because i love the direction this is going, even if i don't necessarily agree about how this should look like yet, i want this to proceed to a success) we should do a number of things
a) set up a basic project for consolidating the histroic issues of the fixture system, migrating them to a more flexible setupstate/caching system b) iterate a a RFC style document on how to deal with referencing fixtures, passing parameters to fixtures and parameterize of fixtures and their parameters
its important to run trough a few iterations here as part of our internal issues is that the initial prototypes where driven by next features only and didn't have a design goal - with a growing api we would put ourselves into a bad mess if we didn't aim for a more consistent design while adding features one at a time
Just a note that I updated the proposal and also did a major update to my proof of concept to address comments by @nicoddemus and also to support fixture argumentization
Hey have you seen https://github.com/pytest-dev/pytest/issues/3960 (available in pytest_cases
)? It seems like a similar approach that could enhance your own thinking.
Is there anything in #3960 than this proposal/poc doesn't cover? With the exception of indirect parametrization of fixtures in fixture functions, which _pytestcases doesn't seem to support either, this poc allows argumentizing fixtures the in the exact same way that test functions are argumentized
_pytestcases also has fixture_union
which should work but might benefit from first-class support. I updated my poc to support a superset of this functionality:
@fixture
def raccoon(request, /):
return "Bob the raccoon"
@fixture
def eagle(request, /, name="Jake"):
return f"{name} the eagle"
@fixture.literal("name").argumentize("Matthew", "Bartholomew")
@fixture.literal("size").argumentize("big", "small")
@fixture
def sparrow(request, /, name, size):
return f"{name} the {size} sparrow"
animal = fixture.create_sync("animal").argumentize(
"Todd the toad",
raccoon,
eagle.arguments("William"),
eagle.argumentize("Luke", arguments("Simon")),
eagle,
sparrow,
)
def test_with_animal(animal):
assert animal in {
"Todd the toad",
"Bob the raccoon",
"William the eagle",
"Luke the eagle",
"Simon the eagle",
"Jake the eagle",
"Matthew the big sparrow",
"Matthew the small sparrow",
"Bartholomew the big sparrow",
"Bartholomew the small sparrow",
}
This works in by creating a new non-async fixture in fixture.create_sync
that calls getfixturevalue()
. I'm not sure if there's a way to portably call getfixturevalue()
in an async way? While this works, getfixturevalue()
is probably an awful solution.
It probably is possible to make this work as a test decorator, e.g.
@fixture.literal("animal").argumentize(raccoon, dog.arguments("Buddy", age=7))
def test_with_animal(animal):
...
But this apparently requires different machinery than the one used by fixture.create_sync
. Also, the implementation might complicated if one can argumentize fixtures with other fixtures, e.g. dog.arguments(cat.argumentize("Tom", "Mittens"))
one way I've been doing this:
@pytest.fixture
def fixture_1():
return 1
@pytest.fixture
def service(some_other_fixture):
def _fixture(service_name):
return f"Service launched with parameter {service_name!r} {fixture_1}"
return _fixture
def test_with_service(service):
service_message = service("parameter")
assert service_message == "Service launched with parameter 'parameter' 1"
@pytest.mark.parametrize("service_name", ["a", "b", "c"])
def test_with_varying_services(service_name, service):
service_message = service(service_name)
assert service_message == f"Service launched with parameter '{service_name}' 1"
Stumbeled over this again today... Is this proposal still considered? Any further actions have been taken since 2021?
Looks lovely ❤️ let's push it to main
I want to configure a fixture on a test-by-test basis. For most tests, I want the fixture configuration parameters to implicitly remain at their defaults. For some tests, I need to explicitly set one or more configuration parameters to nondefault values.
I just spent hours reading these links:
plus the discussion above, and wow... is it really this difficult to make a configurable fixture in Python? :(
Indirect fixture parameterization does not seem like a good fit. Its method of passing multiple parameters to a fixture is not intuitive, and the parameter values must be exhaustively enumerated instead of using defaults unless overridden.
The pattern of fixture-returns-a-function-that-makes-the-thing seems to be the closest match to what I need:
import pytest
from typing import Callable
# make a fixture that returns a function (with configurable parameters)
# that makes the thing (using those parameters)
@pytest.fixture
def make_thing() -> Callable:
# the configuration parameters have default values
def thing(
a: int = 1,
b: int = 1,
c: int = 1
) -> int:
return a*100 + b*10 + c
return thing
# test at various default and non-default parameter behaviors
def test_default(make_thing: Callable):
thing = make_thing()
assert thing == 111
def test_hundreds(make_thing: Callable):
thing = make_thing(a=3)
assert thing == 311
def test_tens(make_thing: Callable):
thing = make_thing(b=4)
assert thing == 141
def test_ones(make_thing: Callable):
thing = make_thing(c=5)
assert thing == 115
Is there a better way to do this, either recently introduced in Python or under discussion somewhere?
Here's an option:
def get_param_from_request(
request: pytest.FixtureRequest, name: str, default: Any = None
) -> Any:
marker = request.node.get_closest_marker("fixture_params")
if marker is None:
return default
return marker.kwargs.get(name, default)
@pytest.fixture
def fixt(request):
return get_param_from_request(request, "some_parameter", "thisisthedefault")
@pytest.mark.fixture_params(some_parameter="some_value")
def test_parameterized_fixture(fixt):
assert fixt == "some_value"
def test_unset_parameter(fixt):
assert fixt == "thisisthedefault"
note that there were major edits of this comment
pytest supports passing arguments to fixture functions using so-called indirect parametrization:
There are some problems with this:
test_with_service
is only run once. This test may only work with a service launched in this particular way. It may not make sense to run it against different kinds of service. In other words, this test doesn't require parametrization.indirect=...
bit, is rather confusingrequest.param
object. It somehow has to verify that it contains the things that it can use, choose default values if not, and throw exceptions in case of errors. some of this behavior looks very much like regular function calls and could benefit from better syntaxSo please add a more obvious way to pass parameters to fixture functions.
Additionally, we should note that there's a similar syntax to parametrize fixtures:
It is also problematic:
requst.param
), you can't easily create a “matrix” of arguments like you can do withpytest.mark.parametrize
. Consider this:This will run the test four times using all possible combinations of arguments. This is not possible with
pytest.fixture
pytest.fixture
. There's noindirect
keywordIt would be nice if the two syntaxes were unified
If I may suggest something, here's my take on a better syntax. Most of it is already possible with pytest; here's a runnable proof of concept — see below regarding what it can't do.
You can define fixture like this:
Here, to the left of
/
you have other fixtures, and to the right you have parameters that are supplied using:If for some reason you can't import
dog
directly, you can reference it by name:To run a test several times with different
dog
s, use:...it would be reasonable to not require
arguments()
in case of a single positional argument:To pass parameters to another fixture, stack
arguments()
decorators:To run a matrix of tests, stack
argumentize()
decorators:To argumentize several parameters as a group, use this:
To argumentize a parameter directly, without passing arguments to other fixtures, use a special “literal” fixture:
This fixture works exactly like regular fixtures. In
parameters()
, you can omit the call tofixture.literal()
:You can mix it all together. Also, you can use
pytest.param()
as usual:Pass a keyword argument
ids
toargumentize()
to have readable test case descriptions. I think you can't do this better than pytest is doing it at the time being.You can also parametrize fixtures using the same syntax, including parameter matrices, etc
Notes on my proof of concept:
_pytestfixturefunction
, it uses the regular pytest api. It doesn't require any breaking changes. It would require very few changes in existing pytest code if implemented properly.It is alrady usable, and also supports async fixtures. Also it produces a neat setup plan:
ids
, although it could. Matricizing fixture function arguments is a bit hacky and throwingids
into that would complicate things too much for a proof of concept@pytest.fixture()
Instead,
foo
will be== Arguments("bar")
. I'm not sure if one other other behavior should be preferred here