测试发现从相对导入中删除 Python 个名称空间?

Test discovery drops Python namespaces from relative imports?

我在命名空间包中遇到单元测试的奇怪问题。 Here's an example I built on GitHub。这是基本结构:

$ tree -P '*.py' src 
src
└── namespace
    └── testcase
        ├── __init__.py
        ├── a.py
        ├── sub
        │   ├── __init__.py
        │   └── b.py
        └── tests
            ├── __init__.py
            └── test_imports.py

4 directories, 6 files

我希望命名空间包中的相对导入会维护命名空间。通常情况下,这似乎是真的:

$ cat src/namespace/testcase/a.py 
print(__name__)
$ cat src/namespace/testcase/sub/b.py 
print(__name__)

from ..a import *
$ python -c 'from namespace.testcase.sub import b'
namespace.testcase.sub.b
namespace.testcase.a

但如果我涉及测试,我会感到惊讶:

$ cat src/namespace/testcase/tests/test_imports.py 
from namespace.testcase import a
from ..sub import b
$ python -m unittest discover src/namespace/
namespace.testcase.a
testcase.sub.b
testcase.a

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

src/namespace/testcase/a.py 中的代码得到 运行 两次!在我的例子中,这导致我存根的单例被重新初始化为一个真实的对象,随后导致测试失败。

这是预期的行为吗?这里的正确用法是什么?我是否应该始终避免相对导入(如果我的公司决定重命名某些内容,则必须进行全局搜索和替换?)

不确定为什么 unittest 不会尊重您的 setup.py,但实际上通常不会(可能是一个错误,或者对实施者来说这样做有困难)。或者,也许 unittest 的设计非常“低级”,并且没有您期望的 pytest.

之类的任何花哨或口哨声

你需要做的是帮助 unittest 并告诉它你的包从哪里开始,为此使用 --top-level-directory 选项(或简称 -t)。

这应该会如您所愿:

python -m unittest discover -t src/ src/namespace/

问题是您的 setup.py:

中可能有这样的内容
    package_dir={"": "src"},

不幸的是 unittest 不够“聪明”,无法解决这个问题。

这是一个详细的例子,为什么我更喜欢 pytest 而不是 std-lib 的 unittest :)

pytest 将竭尽全力“做正确的事”,同时不会强迫您在测试 运行 调用中变得冗长(例如:默认情况下自动递归发现等)。

如果您想详细了解 unittest 如何导入内容,可以将此行添加到您的 a.py 文件中:

assert __package__ == "namespace.testcase"

然后,运行 你的测试没有 -t src/ 像你最初做的那样 -> 你会看到 unittest 崩溃的确切位置。如果您打开该代码,您会看到它所做的只是尝试简单地 __import__(name),其中 name 只是它刚刚发现的看起来像测试的东西。

测试通常不在包中,更严格的项目布局如下:

src/namespace/ # -> your project or lib
tests/         # -> your tests

以上内容“更严格”,因为它更难将您的测试与实际交付的代码混淆(即:实际代码中没有 oopsie import ..tests.foo)。

现在,鉴于此,许多测试工具(如 unittestpytest)会假设您的测试实际上没有包,因此它们会将它们导入为-如果包裹根本不重要...

即:他们不一定会尝试导入 test_foo.py,就好像它在您的主要顶级名称下一样。

所以,理论上你应该(根据我编写测试的经验):

  • 仅在您的实际代码中使用相对导入(即:任何非测试子模块)
  • 使用来自测试的完全绝对导入(这简化了测试工具的很多事情 + 它允许从测试中“不那么亲密地”对待你的代码 -> 有点强迫你从你的 import 东西namespace 像其他用户一样的项目)

希望对您有所帮助。我没有方便的文档链接(也许它值得一本好书)。但是考虑一下:如果你从你的测试中写这个:

from ..sub import b

您正在走图书馆用户无法做到的捷径。例如,任何 pip install namespace 的人都必须使用绝对导入来导入 b

from namespace.sub import b

我发现将测试与代码本身隔离开来很有帮助。我知道很多项目只是在他们的主代码树中添加一个 tests/ 子文件夹,但我确实觉得这很奇怪,因为它将测试与发布的包一起发送,并且可以在技术上像导入其他测试一样导入测试代码...例如:

from namespace.testcase.tests import test_imports

tests/ 在主代码树之外的一个例子是 requests 包。

遵循代码,因为这让我很好奇。

unittest discover 寻找测试用例,它发现 testcase/ 看起来像一个测试文件夹。 所以它只是做一个“独立的”(即:不管任何“顶级”上下文)import testcase.

然后您的测试将执行此操作(所有这些导入都按名称简单地缓存在 sys.modules 中):

  • from namespace.testcase import a,如预期的那样触发 a 作为 namespace.testcase 的子模块导入
  • 但随后它调用 from ..sub import b,现在在单元测试的上下文中,它扩展为 testcase.sub.b,然后导致混淆。

问题:重叠 sys.path 个条目

当您有重叠的 sys.path 条目时,会发生具有不同模块名称的重复导入:也就是说,当 sys.path 包含父目录和子目录作为单独的条目时。这种情况几乎总是一个错误:它会使 Python 将子目录视为一个单独的、不相关的导入根目录,这会导致令人惊讶的行为。

在你的例子中:

$ python -m unittest discover src/namespace/
namespace.testcase.a
testcase.sub.b
testcase.a

这意味着 srcsrc/namespace 都以 sys.path 结尾,因此:

  • namespace.testcase.a 是相对于 src
  • 导入的
  • testcase.sub.btestcase.a 是相对于 src/namespace
  • 导入的

为什么?

在这种情况下,重叠 sys.path 条目的发生是因为 unittest discover 试图提供帮助:它默认假设测试发现的起始目录也是您的顶级目录imports 是相对的,它会将顶级目录插入到 sys.path 中,如果它不存在的话,为方便起见。 (……事实证明不是那么方便。️)

解决方法:明确指定正确的顶级目录

您可以使用 -t (--top-level-directory) 显式指定正确的顶级目录:

python -m unittest discover -t src -s src/namespace/

这将像以前一样工作,但不会将 src/namespace 作为顶级目录插入到 sys.path

旁注: src/namespace/-s 选项前缀在前面的例子中是隐含的:上面只是让它显式。 (unittest discover 有奇怪的位置参数处理:它将前三个位置参数按顺序视为 -s-p-t 的值。)

详情

负责此的代码位于 unittest/loader.py:

class TestLoader(object):

    def discover(self, start_dir, pattern='test*.py', top_level_dir=None):

        ...

        if top_level_dir is None:
            set_implicit_top = True
            top_level_dir = start_dir

        top_level_dir = os.path.abspath(top_level_dir)

        if not top_level_dir in sys.path:
            # all test modules must be importable from the top level directory
            # should we *unconditionally* put the start directory in first
            # in sys.path to minimise likelihood of conflicts between installed
            # modules and development versions?
            sys.path.insert(0, top_level_dir)

        ...