【问题标题】:Test discovery drops Python namespaces from relative imports?测试发现从相对导入中删除 Python 命名空间?
【发布时间】:2020-12-08 14:18:41
【问题描述】:

我在命名空间包中的单元测试中遇到了一个奇怪的问题。 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 中的代码运行了两次!在我的情况下,这导致我存根的单例被重新初始化为真实对象,随后导致测试失败。

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

【问题讨论】:

    标签: python namespaces python-unittest


    【解决方案1】:

    问题:sys.path 条目重叠

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

    在你的例子中:

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

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

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

    为什么?

    在这种情况下,重叠的 sys.path 条目发生是因为 unittest discover 试图提供帮助:它默认假设测试发现的起始目录也是您的导入相对的顶级目录,并且为方便起见,它会将该顶级目录插入到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)
    
            ...
    

    【讨论】:

    • 是的!感谢您链接到 loader.py。现在我也明白了为什么我第一次尝试使用带点的模块名称失败了。在我看来,loader.py 中的命名空间模块处理是不完整的,因为如果给定的模块是命名空间模块,_get_directory_containing_module 将无法处理它。但是,如果我对其进行猴子补丁,测试发现就可以完美运行!
    【解决方案2】:

    不知道为什么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)。

    现在,考虑到这一点,很多测试工具,如 unittest 和 pytest,会假设你的测试实际上没有包,所以他们会像包没有一样导入它们一点都不重要……

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

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

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

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

    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,现在在unittest 的上下文中,它扩展为testcase.sub.b,然后导致混乱。

    【讨论】:

    • 对于它的价值,pytest 发现的行为相同。正如对原始项目的 django-admin 测试一样,我发现了这种行为。
    • 我刚刚在pytest 中尝试过,但无法重现您所描述的情况(无论如何调用:只是pytest,或pytest src 或pytest src/namespace/ 等...)。您可以将此行添加到a.py,一旦发生错误导入,它将触发异常:assert __package__ == "namespace.testcase"。我无法在pytest 中触发该异常,但在unittest 中观察到它(也尝试使用pytest --capture-no,我确实看到了您的预期输出)
    • 糟糕,那是pytest --capture=no(上一条评论中的错字...)。
    • 另外,如果你只是这样做:python -m unittest discover src/,你也不应该遇到这个问题。我确实注意到您在src/namespace/ 中没有__init__.py 文件,我认为它只是在您的问题的tree 输出中被遗忘了。如果不是,您确实需要将 src/namespace/ 标记为 python 模块(即使它是一个真正的命名空间 - 在这种情况下,一个空的 __init__.py 不会这样做,但这是另一个问题)
    • 带有一般注释的已编辑答案。我必须承认我往往不会在主代码树中包含我的 tests/ 文件夹,所以这可能会暴露 unittests 中的特定边缘情况
    猜你喜欢
    • 2010-12-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-01-20
    • 1970-01-01
    • 2017-01-25
    • 2018-09-23
    • 2016-08-29
    相关资源
    最近更新 更多