Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,41 @@ For the same reason, fixtures with a scope higher than `function` and
autouse fixtures should not use describe arguments, because they may be
set up before the values are injected.

## Using docstrings as describe block names

By default, describe-blocks are reported under the name of their function,
e.g. `describe_wallet`. If you want more descriptive test reports, you can
set the `describe_docstrings` configuration option, e.g. in your
`pyproject.toml`:

```toml
[tool.pytest.ini_options]
describe_docstrings = true
```

Describe-blocks are then named after the first line of the docstring of
their describe function, if it has one:

```python
def describe_wallet():
"""a wallet"""

def describe_when_empty():
"""when it is empty"""

def it_has_no_balance(wallet):
assert wallet.balance == 0
```

This will be reported as:

```text
test_wallet.py::a wallet::when it is empty::it_has_no_balance PASSED
```

Note that the docstring-based names become part of the test node IDs, which
are used when selecting tests with `-k` or by node ID on the command line.

## Why bother?

I've found that quite often my tests have one "dimension" more than my production
Expand Down Expand Up @@ -254,3 +289,20 @@ Fixtures defined in the block that includes the shared behavior take precedence
over fixtures defined in the shared behavior. This rule only applies to
fixtures, not to other functions (nested describe blocks and tests). Instead,
they are all collected as separate tests.

## Accessing describe functions from plugins

Reporting plugins sometimes need to know which describe-blocks enclose a
given test, e.g. to show their docstrings in the test report. For this
purpose, pytest-describe provides the function `get_describe_functions`
that returns the describe functions wrapping a collected test item,
starting with the outermost block:

```python
from pytest_describe import get_describe_functions

def pytest_collection_modifyitems(items):
for item in items:
for func in get_describe_functions(item):
print(item.nodeid, func.__name__, func.__doc__)
```
3 changes: 2 additions & 1 deletion src/pytest_describe/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import importlib.metadata as metadata

from .plugin import get_describe_functions
from .shared import behaves_like

__all__ = ["behaves_like"]
__all__ = ["behaves_like", "get_describe_functions"]

try:
__version__: str = metadata.version("pytest-describe")
Expand Down
26 changes: 26 additions & 0 deletions src/pytest_describe/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ def from_parent( # type: ignore[override]
) -> DescribeBlock:
"""Construct a new node for the describe block"""
name = getattr(obj, "_mangled_name", obj.__name__)
if parent.config.getini("describe_docstrings"):
doc = inspect.getdoc(obj)
if doc:
name = doc.splitlines()[0].strip()
nodeid = parent.nodeid + "::" + name
self: DescribeBlock = super().from_parent(
parent=parent, path=parent.path, nodeid=nodeid
Expand Down Expand Up @@ -253,6 +257,22 @@ def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.name!r}>"


def get_describe_functions(
node: pytest.Item | pytest.Collector,
) -> tuple[types.FunctionType, ...]:
"""Get the functions of the describe blocks enclosing the given node.

The functions are returned in the order of nesting, starting with the
outermost describe block. Reporting plugins can use this function to
access the original describe functions and e.g. their docstrings.
"""
return tuple(
block.funcobj # type: ignore[misc]
for block in node.listchain()
if isinstance(block, DescribeBlock)
)


def pytest_pycollect_makeitem(
collector: pytest.Collector, name: str, obj: object
) -> DescribeBlock | None:
Expand Down Expand Up @@ -376,3 +396,9 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=("describe",),
help="prefixes for Python describe function discovery",
)
parser.addini(
"describe_docstrings",
type="bool",
default=False,
help="use docstrings as names for describe blocks",
)
127 changes: 127 additions & 0 deletions test/test_docstrings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Test using docstrings as names for describe blocks"""


def test_docstrings_not_used_by_default(pytester):
pytester.makepyfile(
"""
def describe_wallet():
'''a wallet'''

def it_is_empty():
pass
"""
)

result = pytester.runpytest("-v")

result.assert_outcomes(passed=1)
result.stdout.fnmatch_lines(["*::describe_wallet::it_is_empty PASSED*"])


def test_docstrings_used_as_names_when_enabled(pytester):
pytester.makeini(
"""
[pytest]
describe_docstrings = true
"""
)
pytester.makepyfile(
"""
def describe_wallet():
'''a wallet'''

def describe_when_empty():
'''when it is empty

This second line should not appear in the name.
'''

def it_has_no_balance():
pass

def describe_without_docstring():

def it_keeps_the_function_name():
pass
"""
)

result = pytester.runpytest("-v")

result.assert_outcomes(passed=2)
result.stdout.fnmatch_lines(
[
"*::a wallet::when it is empty::it_has_no_balance PASSED*",
(
"*::a wallet::describe_without_docstring"
"::it_keeps_the_function_name PASSED*"
),
]
)


def test_docstrings_used_for_shared_behaviors(pytester):
pytester.makeini(
"""
[pytest]
describe_docstrings = true
"""
)
pytester.makepyfile(
"""
from pytest import fixture
from pytest_describe import behaves_like

def a_duck():

def describe_sound():
'''the sound it makes'''

def it_quacks(sound):
assert sound == 'quack'

@behaves_like(a_duck)
def describe_something_that_quacks():
'''something that quacks'''

@fixture
def sound():
return 'quack'
"""
)

result = pytester.runpytest("-v")

result.assert_outcomes(passed=1)
result.stdout.fnmatch_lines(
[
("*::something that quacks::the sound it makes::it_quacks PASSED*"),
]
)


def test_selection_by_docstring_name(pytester):
pytester.makeini(
"""
[pytest]
describe_docstrings = true
"""
)
pytester.makepyfile(
"""
def describe_wallet():
'''a wallet'''

def it_is_selected():
pass

def describe_purse():

def it_is_not_selected():
pass
"""
)

result = pytester.runpytest("-k", "wallet")

result.assert_outcomes(passed=1, deselected=1)
51 changes: 51 additions & 0 deletions test/test_reporting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Test the API for accessing describe functions of test items"""


def test_get_describe_functions(pytester):
pytester.makepyfile(
"""
from pytest_describe import get_describe_functions

def describe_outer():
'''the outer block'''

def describe_inner():

def it_knows_its_describe_functions(request):
funcs = get_describe_functions(request.node)
assert [func.__name__ for func in funcs] == [
'describe_outer', 'describe_inner']
assert funcs[0].__doc__ == 'the outer block'
assert funcs[1].__doc__ is None

def test_outside_of_describe_blocks(request):
assert get_describe_functions(request.node) == ()
"""
)

result = pytester.runpytest()

result.assert_outcomes(passed=2)


def test_get_describe_functions_with_shared_behavior(pytester):
pytester.makepyfile(
"""
from pytest_describe import behaves_like, get_describe_functions

def a_duck():

def it_quacks(request):
funcs = get_describe_functions(request.node)
assert [func.__name__ for func in funcs] == [
'describe_something_that_quacks']

@behaves_like(a_duck)
def describe_something_that_quacks():
pass
"""
)

result = pytester.runpytest()

result.assert_outcomes(passed=1)