9

I have a package for python 3.5 and 3.6 that has optional dependencies for which I want tests (pytest) that run on either version.

I made a reduced example below consisting of two files, a simple __init__.py where the optional package "requests" (just an example) is imported and a flag is set to indicate the availability of requests.

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

The __init__.py file content:

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

requests_available = True

try:
    import requests
except ImportError:
    requests_available = False

The test_init.py file content:

#!/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"])

The test_requests_missing test works on 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 ===========================

But not on 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 =======================================================

I have two questions:

  1. Why do I see this difference? Relevant packages seem to be of the same version on both 3.5 and 3.6.

  2. Is there a better way to do what I want? The code I have now is stitched together from examples found online. I have tried to patch the import mechanism in an attempt to avoid "reload", but I have not managed.

  • That's a thoughtful question. Here are just a couple of suggestions. First, elaborate on what you are trying to accomplish with `test_requests_missing`, as this seems to be quite hacky. Second, you can narrow down the problem by minimizing the function (according to your intent) and also by using `pipenv`: prepare a minimal `Pipfile`, create a virtual env for different Python versions (with small steps, i.e., including `3.6.0`, `3.6.1` and so on), find out the Python version where you code stops working, and peruse release notes for the version. – Kirill Bulygin Jun 26 '18 at 13:47
  • Does this answer your question? [How to write unittests for an optional dependency in a python package?](https://stackoverflow.com/questions/37916040/how-to-write-unittests-for-an-optional-dependency-in-a-python-package) – ricoms Mar 11 '21 at 05:18

3 Answers3

5

I would either mock the __import__ function (the one invoked behind the import modname statement), or customize the import mechanism by adding a custom meta path finder. Examples:

Altering sys.meta_path

Add a custom MetaPathFinder implementation that raises an ImportError on an attempt of importing any package in pkgnames:

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

The fixture no_requests will alter sys.meta_path when invoked, so the custom meta path finder filters out the requests package name from the ones that can be imported (we can't raise on any import or pytest itself will break). cleanup_imports is just to ensure that mypackage will be reimported in each test.

Mocking __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

Here, the fixture no_requests is responsible for replacing the __import__ function with one that will raise on import requests attempt, doing fine on the rest of imports.

hoefling
  • 59,418
  • 12
  • 147
  • 194
3
import sys
from unittest.mock import patch

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

What the above code does is, it mocks that the package optional_dependency is not installed and runs your test in that isolated environment inside the context-manager(with).

Keep in mind, you may have to reload the module under test depending upon your use case

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
Abhyudai
  • 826
  • 7
  • 16
2

If a test tests optional functionality, it should be skipped rather than passed if that functionality is missing.

test.support.import_module() is the function used in the Python autotest suite to skip a test or a test file if a module is missing:

import test.support
import unittest

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

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

Then, when running:

> 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 ==========================
ivan_pozdeev
  • 33,874
  • 19
  • 107
  • 152
  • 1
    I respectfully disagree. My intention is not to run alternative tests in case requests is avalable, but to test that mypackage do in case this module is not installed. – Björn Johansson Jun 26 '18 at 15:48