pytest: 手动将测试添加到已发现的测试中

huangapple go评论72阅读模式
英文:

pytest: manually add test to discovered tests

问题

@pytest.mark.mymark
def custom_assert():
assert True

如何强制 pytest 发现此测试?

通常情况下,如何将任何测试动态添加到 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?

答案1

得分: 3

pytest是相当可定制的,但你需要查看其广泛的API。幸运的是,代码基于静态类型,因此你可以轻松地从函数和类导航到其他函数和类。

首先,理解*pytest是如何发现测试的很有帮助。回想一下可配置的发现命名约定

# pytest.ini的内容
# 示例1:使pytest查找"check"而不是"test"
[pytest]
python_files = check_*.py
python_classes = Check
python_functions = *_check

这意味着,例如,python_functions的值在某处用于筛选出被视为测试函数的函数。在pytest存储库上进行快速搜索,你可以看到这个

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

PyCollectorpytest Module对象的基类,而module_: pytest.Module具有obj属性,该属性是types.ModuleType对象本身。除了访问funcnamefilter::name参数,你可以创建一个pytest.Modulepytest.Packagepytest.Class的子类,来覆盖funcnamefilter以接受使用你自定义的@pytest.mark.mymark装饰器装饰的函数作为测试函数:

from __future__ import annotations

import types
import typing as t

import pytest

# 静态类型友好性
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:
            """测试函数回调方法"""

else:
    PyCollector: t.TypeAlias = object

def _isPytestMarkDecorated(obj: object) -> t.TypeGuard[_MarkDecorated]:
    """
    在函数上装饰`@pytest.mark.mymark`会得到这个结果

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

    这里的`Mark``pytest.Mark`。

    该函数提供了一个用于静态类型目的的类型保护
    """

    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

最后一件要做的事情是配置pytest,使其在收集测试模块时使用你的MyMarkModule,而不是pytest.Module。你可以使用每个目录的插件模块文件 conftest.py来实现这一点,在这个文件中,你需要覆盖钩子pytest.pycollect.makemodule(请参考pytest的实现来了解如何正确编写):

# 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]

现在你可以运行pytest <your test file>,你应该会看到所有带有@pytest.mark.mymark装饰器的函数都作为测试函数运行,无论它们是否根据pytest_functions配置设置命名。

这只是开始,关于如何使用pytest需要做和可以做的事情。如果你计划在其他地方使用@pytest.mark.mymark,你还需要对pytest.Classpytest.Package进行相同的操作。

英文:

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 &quot;check&quot; instead of &quot;test&quot;
[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) -&gt; bool:
        return self._matches_prefix_or_glob_option(&quot;python_functions&quot;, 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) -&gt; None:
            &quot;&quot;&quot;Test function callback method&quot;&quot;&quot;

else:
    PyCollector: t.TypeAlias = object


def _isPytestMarkDecorated(obj: object) -&gt; t.TypeGuard[_MarkDecorated]:

    &quot;&quot;&quot;
    Decorating `@pytest.mark.mymark` over a function results in this:

    &gt;&gt;&gt; @pytest.mark.mymark
    ... def f() -&gt; None:
    ...     pass
    ...
    &gt;&gt;&gt; f.pytestmark
    [Mark(name=&#39;mymark&#39;, args=(), kwargs={})]

    where `Mark` is `pytest.Mark`.

    This function provides a type guard for static typing purposes.
    &quot;&quot;&quot;

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

class _MyMarkMixin(PyCollector):
    def funcnamefilter(self, name: str) -&gt; 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 == &quot;mymark&quot; 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 &lt;...&gt; import MyMarkModule

if t.TYPE_CHECKING:
    import pathlib
    import pytest


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

Now you can run pytest &lt;your test file&gt; 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.

答案2

得分: 1

更改命名规范

Pytest > 更改命名规范

> 您可以通过在配置文件中设置 python_files、python_classes 和 python_functions 来配置不同的命名约定。以下是一个示例:
> ini &gt; # pytest.ini 文件的内容 &gt; # 示例 1:让 pytest 查找“check”而不是“test” &gt; [pytest] &gt; python_files = check_*.py &gt; python_classes = Check &gt; python_functions = *_check &gt;
> 这将使 pytest 在匹配 check_*.py 理模式的文件中查找测试,以及在类中匹配 Check 前缀以及匹配 *_check 的函数和方法。
> 您可以通过在模式之间添加空格来检查多个 glob 模式。

所以,在您的情况下,您需要在 pytest.ini 中使用以下配置:

# pytest.ini 文件的内容
[pytest]
python_functions = *_assert
英文:

Change naming convention

Pytest > Changing naming conventions

> You can configure different naming conventions by setting the python_files, python_classes and python_functions in your configuration file. Here is an example:
> ini
&gt; # content of pytest.ini
&gt; # Example 1: have pytest look for &quot;check&quot; instead of &quot;test&quot;
&gt; [pytest]
&gt; python_files = check_*.py
&gt; python_classes = Check
&gt; python_functions = *_check
&gt;

> This would make pytest look for tests in files that match the check_* .py glob-pattern, Check prefixes in classes, and functions and methods that match *_check.
> You can check for multiple glob patterns by adding a space between the patterns.

So, in your case, you need this configuration in pytest.ini:

# content of pytest.ini
[pytest]
python_functions = *_assert

huangapple
  • 本文由 发表于 2023年3月8日 16:56:52
  • 转载请务必保留本文链接:https://go.coder-hub.com/75671038.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定