Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

pytest: manually add test to discovered tests

Tags:

python

pytest

# tests/test_assert.py
@pytest.mark.mymark
def custom_assert():
    assert True 

How do I force pytest to discover this test?

In general, how do I dynamically add any test to pytest's list of discovered tests, even if they don't fit in the naming convention?

like image 637
Some Guy Avatar asked Sep 19 '25 08:09

Some Guy


1 Answers

pytest is fairly customisable, but you'll have to look at its extensive API. Luckily, the code base is statically typed, so you can navigate from functions and classes to other functions and classes fairly easily.

To start off, it pays to understand how pytest discovers tests. Recall the configurable discovery naming conventions:

# content of pytest.ini
# Example 1: have pytest look for "check" instead of "test"
[pytest]
python_files = check_*.py
python_classes = Check
python_functions = *_check

This implies that, for example, the value to python_functions is used somewhere to filter out functions that are not considered as test functions. Do a quick search on the pytest repository to see this:

class PyCollector(PyobjMixin, nodes.Collector):
    def funcnamefilter(self, name: str) -> bool:
        return self._matches_prefix_or_glob_option("python_functions", name)

PyCollector is a base class for pytest Module objects, and module_: pytest.Module has an obj property which is the types.ModuleType object itself. Along with access to the funcnamefilter::name parameter, you can make a subclass of pytest.Module, pytest.Package, and pytest.Class to override funcnamefilter to accept functions decorated your custom @pytest.mark.mymark decorator as test functions:

from __future__ import annotations

import types
import typing as t

import pytest


# Static-type-friendliness
if t.TYPE_CHECKING:
    from _pytest.python import PyCollector

    class _MarkDecorated(t.Protocol):
        pytestmark: list[pytest.Mark]

        def __call__(self, *args: object, **kwargs: object) -> None:
            """Test function callback method"""

else:
    PyCollector: t.TypeAlias = object


def _isPytestMarkDecorated(obj: object) -> t.TypeGuard[_MarkDecorated]:

    """
    Decorating `@pytest.mark.mymark` over a function results in this:

    >>> @pytest.mark.mymark
    ... def f() -> None:
    ...     pass
    ...
    >>> f.pytestmark
    [Mark(name='mymark', args=(), kwargs={})]

    where `Mark` is `pytest.Mark`.

    This function provides a type guard for static typing purposes.
    """

    if (
        callable(obj)
        and hasattr(obj, "pytestmark")
        and isinstance(obj.pytestmark, list)
    ):
        return True
    return False

class _MyMarkMixin(PyCollector):
    def funcnamefilter(self, name: str) -> bool:
        underlying_py_obj: object = self.obj
        assert isinstance(underlying_py_obj, (types.ModuleType, type))
        func: object = getattr(underlying_py_obj, name)
        if _isPytestMarkDecorated(func) and any(
            mark.name == "mymark" for mark in func.pytestmark
        ):
            return True
        return super().funcnamefilter(name)


class MyMarkModule(_MyMarkMixin, pytest.Module):
    pass

The last thing to do is to configure pytest to use your MyMarkModule rather than pytest.Module when collecting test modules. You can do this with the per-directory plugin module file conftest.py, where you would override the hook pytest.pycollect.makemodule (please see pytest's implementation on how to write this properly):

# conftest.py
import typing as t
from <...> import MyMarkModule

if t.TYPE_CHECKING:
    import pathlib
    import pytest


def pytest_pycollect_makemodule(
    module_path: pathlib.Path, parent: object
) -> pytest.Module | None:
    if module_path.name != "__init__.py":
        return MyMarkModule.from_parent(parent, path=module_path)  # type: ignore[no-any-return]

Now you can run pytest <your test file> and you should see all @pytest.mark.mymark functions run as test functions, regardless of whether they're named according to the pytest_functions configuration setting.


This is a start on what you need to do, and can do with pytest. You'll have to do this with pytest.Class and pytest.Package as well, if you're planning on using @pytest.mark.mymark elsewhere.

like image 175
dROOOze Avatar answered Sep 21 '25 20:09

dROOOze