测试发现从相对导入中删除 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
)。
现在,鉴于此,许多测试工具(如 unittest
和 pytest
)会假设您的测试实际上没有包,因此它们会将它们导入为-如果包裹根本不重要...
即:他们不一定会尝试导入 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
这意味着 src
和 src/namespace
都以 sys.path
结尾,因此:
- namespace.testcase.a 是相对于
src
导入的
- testcase.sub.b 和 testcase.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)
...
我在命名空间包中遇到单元测试的奇怪问题。 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
)。
现在,鉴于此,许多测试工具(如 unittest
和 pytest
)会假设您的测试实际上没有包,因此它们会将它们导入为-如果包裹根本不重要...
即:他们不一定会尝试导入 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
这意味着 src
和 src/namespace
都以 sys.path
结尾,因此:
- namespace.testcase.a 是相对于
src
导入的
- testcase.sub.b 和 testcase.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)
...