35

I am writing a pytest plugin that should test software that's designed to work inside a set of specific environments.

The software I'm writing is run inside a bigger framework, which makes certain Python modules available only when running my Python software inside the framework.

In order to test my software, I'm required to "mock" or fake an entire module (actually, quite a few). I'll need to implement its functionality in some kind of similar-looking way, but my question is how should I make this fake Python module available to my software's code, using a py.test plugin?

For example, let's assume I have the following code in one of my source files:

import fwlib

def fw_sum(a, b):
    return fwlib.sum(a, b)

However, the fwlib module is only made available by the framework I run my software from, and I cannot test inside it.

How would I make sure, from within a pytest plugin, that a module named fwlib is already defined in sys.modules? Granted, I'll need to implement fwlib.sum myself. I'm looking for recommendations on how to do just that.

wim
  • 338,267
  • 99
  • 616
  • 750
NirIzr
  • 3,131
  • 2
  • 30
  • 49

2 Answers2

43

pytest provides a fixture for this use-case: monkeypatch.syspath_prepend.

You may prepend a path to sys.path list of import locations. Write a fake fwlib.py and include it in your tests, appending the directory as necessary. Like the other test modules, it needn't be included with the distribution.

After playing with this myself, I couldn't actually figure out how to get the fixture to mock module level imports correctly from the library code. By the time the tests run, the library code was already imported and then it is too late to patch.

However, I can offer a different solution that works: you may inject the name from within conftest.py, which gets imported first. The subsequent import statement within the code under test will just re-use the object already present in sys.modules.

Package structure:

$ tree .
.
├── conftest.py
├── lib
│   └── my_lib.py
└── tests
    └── test_my_lib.py

2 directories, 3 files

Contents of files:

# conftest.py
import sys

def fwlib_sum(a, b):
    return a + b

module = type(sys)('fwlib')
module.sum = fwlib_sum
sys.modules['fwlib'] = module

library file:

# lib/my_lib.py
import fwlib

def fw_sum(a, b):
    return fwlib.sum(a, b)

test file:

# lib/test_my_lib.py
import my_lib

def test_sum():
    assert my_lib.fw_sum(1, 2) == 3
wim
  • 338,267
  • 99
  • 616
  • 750
  • This is excellent, thank you! Could you please clarify the last statement, about needn't it be included with the distribution? – NirIzr Jan 16 '18 at 22:42
  • Presumably you have a `./tests/` subdirectory, that is not packaged up by your `setup.py` file when creating a distribution. You may create for example `./tests/mocks/fwlib.py` that can be imported during test runs, but it's not distributed with your library. – wim Jan 16 '18 at 22:44
  • Ah, I see. I'll wait a bit for other answers to come (however unlikely) and accept your soon. Thanks again! – NirIzr Jan 16 '18 at 22:49
  • 1
    Does anyone know how to do this when the import is like "from import "? – Neosapien Jan 22 '20 at 12:52
  • @Neosapien The same approach still works. Inject in `sys.modules` the name. – wim Aug 21 '20 at 22:15
  • And what about restoring the module after we change the module in conftest.py? – sergzach Jul 01 '21 at 11:19
  • @sergzach Not really needed. Once pytest exits, the Python process exits. – wim Jul 01 '21 at 13:41
  • @wim You may have many other tests which conflict with the action of changing module globally. – sergzach Jul 01 '21 at 13:44
  • @sergzach Hmm, maybe in that case you would need to make a fixture which injects to sys.modules and then undoes it at teardown. It will be tricky, though, because you may not be able to import the library code at the top of the test module (the important would need to be delayed until after the patch was applied). – wim Jul 01 '21 at 17:45
  • You could inject a wrapper module that'll redirect all calls, classes, etc at runtime based on a flag. This will still be a bit tricky for when specific classes are imported directly (as you'll need to create class wrappers too) – NirIzr Jul 12 '21 at 19:39
1

Just to provide a little more details to @wim's good answer, you can use it with submodules too, like so:

import sys
module = type(sys)("my_module_name")
module.submodule = type(sys)("my_submodule_name")
module.submodule.something = sommething
sys.modules["my_module_name"] = module
sys.modules["my_module_name.my_submodule_name"] = module.submodule
Itération 122442
  • 2,644
  • 2
  • 27
  • 73