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.