diff --git a/README.md b/README.md index 2344aad..655ab41 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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__) +``` diff --git a/src/pytest_describe/__init__.py b/src/pytest_describe/__init__.py index df3cc30..f5ff62a 100644 --- a/src/pytest_describe/__init__.py +++ b/src/pytest_describe/__init__.py @@ -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") diff --git a/src/pytest_describe/plugin.py b/src/pytest_describe/plugin.py index 9402300..1611e24 100644 --- a/src/pytest_describe/plugin.py +++ b/src/pytest_describe/plugin.py @@ -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 @@ -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: @@ -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", + ) diff --git a/test/test_docstrings.py b/test/test_docstrings.py new file mode 100644 index 0000000..e527aa1 --- /dev/null +++ b/test/test_docstrings.py @@ -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) diff --git a/test/test_reporting.py b/test/test_reporting.py new file mode 100644 index 0000000..77ec4c7 --- /dev/null +++ b/test/test_reporting.py @@ -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)