Refactor pytest fixtures
Description
One of the most commonly used testing framework in Python is pytest. Among other things, it allows the use of fixtures.
Fixtures are defined as functions that can be required in test code, or in other fixtures, as an argument. This means that all functions arguments with a given name in a pytest context (test function or fixture) are essentially the same entity. However, not every editor's LSP is able to keep track of this, making refactoring challenging.
Using ast-grep, we can define some rules to match fixture definition and usage without catching similarly named entities in a non-test context.
First, we define utils to select pytest test/fixture functions.
utils:
is-fixture-function:
kind: function_definition
follows:
kind: decorator
has:
kind: identifier
regex: ^fixture$
stopBy: end
is-test-function:
kind: function_definition
has:
field: name
regex: ^test_Pytest fixtures are declared with a decorator @pytest.fixture. We match the function_definition node that directly follows a decorator node. That decorator node must have a fixture identifier somewhere. This accounts for different location of the fixture node depending on the type of imports and whether the decorator is used as is or called with parameters.
Pytest functions are fairly straightforward to detect, as they always start with test_ by convention.
The next utils builds onto those two to incrementally:
- Find if a node is inside a pytest context (test/fixture)
- Find if a node is an argument in such a context
utils:
is-pytest-context:
# Pytest context is a node inside a pytest
# test/fixture
inside:
stopBy: end
any:
- matches: is-fixture-function
- matches: is-test-function
is-fixture-arg:
# Fixture arguments are identifiers inside the
# parameters of a test/fixture function
all:
- kind: identifier
- inside:
kind: parameters
- matches: is-pytest-contextOnce those utils are declared, you can perform various refactoring on a specific fixture.
The following rule adds a type-hint to a fixture.
rule:
matches: is-fixture-arg
regex: ^foo$
fix: 'foo: int'This one renames a fixture and all its references.
rule:
kind: identifier
matches: is-fixture-context
regex: ^foo$
fix: 'five'Example
Renaming Fixtures
@pytest.fixture
def foo() -> int:
return 5
@pytest.fixture(scope="function")
def some_fixture(foo: int) -> str:
return str(foo)
def regular_function(foo) -> None:
...
def test_code(foo: int) -> None:
assert foo == 5Diff
@pytest.fixture
def foo() -> int:
def five() -> int:
return 5
@pytest.fixture(scope="function")
def some_fixture(foo: int) -> str:
def some_fixture(five: int) -> str:
return str(foo)
def regular_function(foo) -> None:
...
def test_code(foo: int) -> None:
def test_code(five: int) -> None:
assert foo == 5
assert five == 5Type Hinting Fixtures
@pytest.fixture
def foo() -> int:
return 5
@pytest.fixture(scope="function")
def some_fixture(foo) -> str:
return str(foo)
def regular_function(foo) -> None:
...
def test_code(foo) -> None:
assert foo == 5Diff
@pytest.fixture
def foo() -> int:
return 5
@pytest.fixture(scope="function")
def some_fixture(foo) -> str:
def some_fixture(foo: int) -> str:
return str(foo)
def regular_function(foo) -> None:
...
def test_code(foo) -> None:
def test_code(foo: int) -> None:
assert foo == 5