使用 pytest 测试 __init__.py 中可选依赖项的导入:Python 3.5 /3.6 的行为不同

Posted

技术标签:

【中文标题】使用 pytest 测试 __init__.py 中可选依赖项的导入:Python 3.5 /3.6 的行为不同【英文标题】:Test for import of optional dependencies in __init__.py with pytest: Python 3.5 /3.6 differs in behaviour 【发布时间】:2018-12-05 05:52:35 【问题描述】:

我有一个适用于 python 3.5 和 3.6 的包,它具有可选的依赖项,我想要在任一版本上运行的测试 (pytest)。

我在下面做了一个简化的示例,包含两个文件,一个简单的__init__.py,其中导入了可选包“requests”(只是一个示例),并设置了一个标志来指示请求的可用性。

mypackage/
├── mypackage
│   └── __init__.py
└── test_init.py

__init__.py文件内容:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

requests_available = True

try:
    import requests
except ImportError:
    requests_available = False

test_init.py文件内容:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import pytest, sys

def test_requests_missing(monkeypatch):
    import mypackage
    import copy
    fakesysmodules = copy.copy(sys.modules)
    fakesysmodules["requests"] = None
    monkeypatch.delitem(sys.modules,"requests")
    monkeypatch.setattr("sys.modules", fakesysmodules)
    from importlib import reload
    reload(mypackage)
    assert mypackage.requests_available == False


if __name__ == '__main__':
    pytest.main([__file__, "-vv", "-s"])

test_requests_missing 测试适用于 Python 3.6.5:

runfile('/home/bjorn/python_packages/mypackage/test_init.py', wdir='/home/bjorn/python_packages/mypackage')
============================= test session starts ==============================
platform linux -- Python 3.6.5, pytest-3.6.1, py-1.5.2, pluggy-0.6.0 -- /home/bjorn/anaconda3/envs/bjorn36/bin/python
cachedir: .pytest_cache
rootdir: /home/bjorn/python_packages/mypackage, inifile:
plugins: requests-mock-1.5.0, mock-1.10.0, cov-2.5.1, nbval-0.9.0, hypothesis-3.38.5
collecting ... collected 1 item

test_init.py::test_requests_missing PASSED

=========================== 1 passed in 0.02 seconds ===========================

但不是在 Python 3.5.4 上:

runfile('/home/bjorn/python_packages/mypackage/test_init.py', wdir='/home/bjorn/python_packages/mypackage')
========================================================= test session starts ==========================================================
platform linux -- Python 3.5.4, pytest-3.6.1, py-1.5.2, pluggy-0.6.0 -- /home/bjorn/anaconda3/envs/bjorn35/bin/python
cachedir: .pytest_cache
rootdir: /home/bjorn/python_packages/mypackage, inifile:
plugins: requests-mock-1.5.0, mock-1.10.0, cov-2.5.1, nbval-0.9.1, hypothesis-3.38.5
collecting ... collected 1 item

test_init.py::test_requests_missing FAILED

=============================================================== FAILURES ===============================================================
________________________________________________________ test_requests_missing _________________________________________________________

monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7f9a2953acc0>

    def test_requests_missing(monkeypatch):
        import mypackage
        import copy
        fakesysmodules = copy.copy(sys.modules)
        fakesysmodules["requests"] = None
        monkeypatch.delitem(sys.modules,"requests")
        monkeypatch.setattr("sys.modules", fakesysmodules)
        from importlib import reload
>       reload(mypackage)

test_init.py:13: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../anaconda3/envs/bjorn35/lib/python3.5/importlib/__init__.py:166: in reload
    _bootstrap._exec(spec, module)
<frozen importlib._bootstrap>:626: in _exec
    ???
<frozen importlib._bootstrap_external>:697: in exec_module
    ???
<frozen importlib._bootstrap>:222: in _call_with_frames_removed
    ???
mypackage/__init__.py:8: in <module>
    import requests
../../anaconda3/envs/bjorn35/lib/python3.5/site-packages/requests/__init__.py:97: in <module>
    from . import utils

.... VERY LONG OUTPUT ....

    from . import utils
../../anaconda3/envs/bjorn35/lib/python3.5/site-packages/requests/__init__.py:97: in <module>
    from . import utils
<frozen importlib._bootstrap>:968: in _find_and_load
    ???
<frozen importlib._bootstrap>:953: in _find_and_load_unlocked
    ???
<frozen importlib._bootstrap>:896: in _find_spec
    ???
<frozen importlib._bootstrap_external>:1171: in find_spec
    ???
<frozen importlib._bootstrap_external>:1145: in _get_spec
    ???
<frozen importlib._bootstrap_external>:1273: in find_spec
    ???
<frozen importlib._bootstrap_external>:1245: in _get_spec
    ???
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

name = 'requests', location = '/home/bjorn/anaconda3/envs/bjorn35/lib/python3.5/site-packages/requests/__init__.py'

>   ???
E   RecursionError: maximum recursion depth exceeded

<frozen importlib._bootstrap_external>:575: RecursionError
======================================================= 1 failed in 2.01 seconds =======================================================

我有两个问题:

    为什么我会看到这种差异?相关包在 3.5 和 3.6 上似乎是相同的版本。

    有没有更好的方法来做我想做的事?我现在的代码是从网上找到的示例拼接在一起的。我曾尝试修补导入机制以避免“重新加载”,但我没有成功。

【问题讨论】:

这是一个深思熟虑的问题。这里只是一些建议。首先,详细说明您要使用test_requests_missing 完成的工作,因为这似乎很老套。其次,您可以通过最小化函数(根据您的意图)以及使用pipenv来缩小问题范围:准备一个最小的Pipfile,为不同的Python版本创建一个虚拟环境(使用小步骤,即包括@ 987654334@、3.6.1 等),找出您的代码停止工作的 Python 版本,并仔细阅读该版本的发行说明。 这能回答你的问题吗? How to write unittests for an optional dependency in a python package? 【参考方案1】:
import sys
from unittest.mock import patch

def test_without_dependency(self):
    with patch.dict(sys.modules, 'optional_dependency': None):
        # do whatever you want

上面的代码所做的是,它模拟了包optional_dependency 没有安装,并在上下文管理器(with) 内的隔离环境中运行你的测试。

请记住,根据您的用例,您可能需要重新加载正在测试的 module

import sys
from unittest.mock import patch
from importlib import reload

def test_without_dependency(self):
    with patch.dict(sys.modules, 'optional_dependency': None):
        reload(sys.modules['my_module_under_test'])
        # do whatever you want

【讨论】:

这种方法效果很好,在***.com/a/65163627/8931942中也采用了类似的方式【参考方案2】:

我要么模拟__import__ function(在import modname 语句后面调用的那个),要么通过添加自定义元路径查找器来自定义导入机制。例子:

修改sys.meta_path

添加一个自定义的MetaPathFinder 实现,在尝试导入pkgnames 中的任何包时引发ImportError

class PackageDiscarder:
    def __init__(self):
        self.pkgnames = []
    def find_spec(self, fullname, path, target=None):
        if fullname in self.pkgnames:
            raise ImportError()


@pytest.fixture
def no_requests():
    sys.modules.pop('requests', None)
    d = PackageDiscarder()
    d.pkgnames.append('requests')
    sys.meta_path.insert(0, d)
    yield
    sys.meta_path.remove(d)


@pytest.fixture(autouse=True)
def cleanup_imports():
    yield
    sys.modules.pop('mypackage', None)


def test_requests_available():
    import mypackage
    assert mypackage.requests_available


@pytest.mark.usefixtures('no_requests2')
def test_requests_missing():
    import mypackage
    assert not mypackage.requests_available

夹具no_requests 将在调用时改变sys.meta_path,因此自定义元路径查找器从可以导入的包名称中过滤掉requests 包名称(我们不能在任何导入或pytest本身会破裂)。 cleanup_imports 只是为了确保 mypackage 在每次测试中都会被重新导入。

嘲讽__import__

import builtins
import sys
import pytest


@pytest.fixture
def no_requests(monkeypatch):
    import_orig = builtins.__import__
    def mocked_import(name, globals, locals, fromlist, level):
        if name == 'requests':
            raise ImportError()
        return import_orig(name, locals, fromlist, level)
    monkeypatch.setattr(builtins, '__import__', mocked_import)


@pytest.fixture(autouse=True)
def cleanup_imports():
    yield
    sys.modules.pop('mypackage', None)


def test_requests_available():
    import mypackage
    assert mypackage.requests_available


@pytest.mark.usefixtures('no_requests')
def test_requests_missing():
    import mypackage
    assert not mypackage.requests_available

这里,fixture no_requests 负责将__import__ 函数替换为在import requests 尝试时会引发的函数,在其余导入方面表现良好。

【讨论】:

这适用于 3.5 和 3.6,并且比我的旧代码更具体。【参考方案3】:

如果测试测试可选功能,如果缺少该功能,则应跳过而不是通过测试。

test.support.import_module() 是 Python 自动测试套件中使用的函数,用于在缺少模块时跳过测试或测试文件:

import test.support
import unittest

nonexistent = test.support.import_module("nonexistent")

class TestDummy(unittest.testCase):
    def test_dummy():
        self.assertTrue(nonexistent.vaporware())

然后,运行时:

> python -m py.test -rs t.py

<...>
collected 0 items / 1 skipped

=========================== short test summary info ===========================
SKIP [1] C:\Python27\lib\test\support\__init__.py:90: SkipTest: No module named
nonexistent
========================== 1 skipped in 0.05 seconds ==========================

【讨论】:

我不同意。我的意图不是在请求可用的情况下运行替代测试,而是在未安装此模块的情况下测试 mypackage 执行的操作。

以上是关于使用 pytest 测试 __init__.py 中可选依赖项的导入:Python 3.5 /3.6 的行为不同的主要内容,如果未能解决你的问题,请参考以下文章

将 pytest 与 src 层一起使用

Pytest权威教程24-Pytest导入机制及系统路径

pytest 框架与 unittest 框架的对比

pytest文档2-用例运行规则

pytest-25-conftest.py作用范围

pytest文档25-conftest.py作用范围