如何使用PEP 420名称空间包对项目进行Pytest?

Cur*_*son 5 pytest

我正在尝试使用Pytest测试一个较大的项目(〜100k LOC,1k个文件),最终我还有其他几个类似的项目。这不是标准的Python包 ; 它是高度定制的系统的一部分,至少在短期内,我几乎无权更改。测试模块与代码集成在一起,而不是放在单独的目录中,这对我们很重要。配置与此问题相当相似,在那里的回答也可能提供有用的背景。

我遇到的问题是,这些项目几乎只使用PEP 420隐式名称空间包;也就是说,__init__.py任何软件包目录中几乎没有文件。我还没有看到必须将其作为名称空间软件包的情况,但是鉴于此项目与其他也具有Python代码的项目结合在一起,这种情况可能会发生(或者已经发生,而我只是没有注意到) 。

考虑一个如下所示的存储库。(对于它的可运行副本,包括下面描述的测试,请0cjs/pytest-impl-ns-pkg从GitHub进行克隆。)假定以下所有测试都在中project/thing/thing_test.py

repo/
    project/
        util/
            thing.py
            thing_test.py
Run Code Online (Sandbox Code Playgroud)

我对测试配置有足够的控制权,我可以确保 sys.path对输入的设置进行适当设置,以使被测代码能够正常工作。也就是说,以下测试将通过:

def test_good_import():
    import project.util.thing
Run Code Online (Sandbox Code Playgroud)

但是,Pytest正在使用其通常的系统从文件中确定包名称,给出的包名称不是用于我的配置的标准包名称,并将项目的子目录添加到中sys.path。因此,以下两个测试失败:

def test_modulename():
    assert 'project.util.thing_test' == __name__
    # Result: AssertionError: assert 'project.util.thing_test' == 'thing_test'

def test_bad_import():
    ''' While we have a `project.util.thing` deep in our hierarchy, we do
        not have a top-level `thing` module, so this import should fail.
    '''
    with raises(ImportError):
        import thing
    # Result: Failed: DID NOT RAISE <class 'ImportError'>
Run Code Online (Sandbox Code Playgroud)

正如你所看到的,同时thing.py可以随时导入为project.util.thingthing_test.pyproject.util.thing_testPytest之外,但在Pytest运行project/util被添加到sys.path,模块被命名thing_test

这带来了许多问题:

  1. 模块名称空间冲突(例如,之间project/util/thing_test.pyproject/otherstuff/thing_test.py)。
  2. 由于测试中的代码也正在使用这些非生产导入路径,因此未捕获到错误的导入语句。
  3. 相对导入可能无法在测试代码中使用,因为该模块已在层次结构中“移动”。
  4. 总的来说,我很担心sys.path在测试中添加大量额外的路径,这些路径在生产中将是不存在的,因为我看到了很多潜在的错误。但是,我们将其称为第一个(目前是默认值)选项。

我想我想做的就是告诉Pytest,它应该确定与我提供的特定文件系统路径相关的模块名称,而不是根据__init__.py文件的存在与否自行决定要使用的路径。但是,我认为Pytest无法做到这一点。(对我来说,将其添加到Pytest中并不是没有问题的,但是在不久的将来也不会发生这种情况,因为我认为在提出确切的方法之前,我希望对Pytest有更深入的了解。)

第三种选择(仅适应当前情况并按上述方式更改pytest之后)只是向项目添加数十个__init__.py文件。但是,尽管在常规Python世界中使用extend_path它们(我认为)可以处理名称空间与常规包的问题,​​但我认为这会破坏我们在多个项目中声明的包的不寻常发布系统。(也就是说,如果其他项目有一个project.util.other模块,并合并与我们的项目发布,之间的碰撞他们project/util/__init__.py和我们的project/util/__init__.py将是一个大问题。)修复,这将是因为我们不得不,除其他事项外的一个重大挑战,添加某种方式来声明某些包含的目录__init__.py实际上是名称空间包。

有什么方法可以改善上述选项?我还有其他选择吗?

hoe*_*ing 6

您面临的问题是您将测试放在命名空间包内的生产代码旁边。如前所述这里pytest承认你的设置作为独立的测试模块:

独立测试模块/conftest.py 文件

...

pytest 会发现foo/bar/tests/test_foo.py并意识到它不是包的一部分,因为__init__.py同一个文件夹中没有文件。然后它将添加root/foo/bar/testssys.pathtest_foo.py作为模块导入test_fooconftest.py通过添加root/footo将文件sys.path导入为conftest.

所以解决(至少部分)这个问题的正确方法是调整sys.path和分离生产代码的测试,例如将测试模块移动thing_test.py到一个单独的目录中project/util/tests。由于您不能这样做,您别无选择,只能弄乱pytest的内部结构(因为您将无法通过钩子覆盖模块导入行为)。这是一个建议:repo/conftest.py用打过补丁的LocalPath类创建一个:

# repo/conftest.py

import pathlib
import py._path.local


# the original pypkgpath method can't deal with namespace packages,
# considering only dirs with __init__.py as packages
pypkgpath_orig = py._path.local.LocalPath.pypkgpath

# we consider all dirs in repo/ to be namespace packages
rootdir = pathlib.Path(__file__).parent.resolve()
namespace_pkg_dirs = [str(d) for d in rootdir.iterdir() if d.is_dir()]

# patched method
def pypkgpath(self):
    # call original lookup
    pkgpath = pypkgpath_orig(self)
    if pkgpath is not None:
        return pkgpath
    # original lookup failed, check if we are subdir of a namespace package
    # if yes, return the namespace package we belong to
    for parent in self.parts(reverse=True):
        if str(parent) in namespace_pkg_dirs:
            return parent
    return None

# apply patch
py._path.local.LocalPath.pypkgpath = pypkgpath
Run Code Online (Sandbox Code Playgroud)

pytest>=6.0

版本 6 删除了 的用法,py.path因此应该将monkeypatching应用于_pytest.pathlib.resolve_package_path而不是LocalPath.pypkgpath,但其余部分基本相同:

# repo/conftest.py

import pathlib
import _pytest.pathlib


resolve_pkg_path_orig = _pytest.pathlib.resolve_package_path

# we consider all dirs in repo/ to be namespace packages
rootdir = pathlib.Path(__file__).parent.resolve()
namespace_pkg_dirs = [str(d) for d in rootdir.iterdir() if d.is_dir()]

# patched method
def resolve_package_path(path):
    # call original lookup
    result = resolve_pkg_path_orig(path)
    if result is not None:
        return result
    # original lookup failed, check if we are subdir of a namespace package
    # if yes, return the namespace package we belong to
    for parent in path.parents:
        if str(parent) in namespace_pkg_dirs:
            return parent
    return None

# apply patch
_pytest.pathlib.resolve_package_path = resolve_package_path
Run Code Online (Sandbox Code Playgroud)