How to mock a function within a python script tested with pytest-console-scripts?

I need to test some legacy code, among which there are a number of Python scripts. By script I mean Python code not within a class or module, just within a unique file and executed with python script.py

Here is a example oldscript.py:

import socket


def testfunction():
    return socket.gethostname()


def unmockable():
    return "somedata"


if __name__ == '__main__':
    result = testfunction()
    result = unmockable()
    print(result)

I’m using pytest-console-scripts to test this as it’s “inprocess” launcher makes it possible to actually mock some things.

AFAIU, there’s no way to mock any call made within a Python script when it is ran with subprocess

pytest-console-scripts makes this possible, and indeed mocks to external functions work.

Here’s a test case for the above :

import socket
from pytest_console_scripts import ScriptRunner
from pytest_mock import MockerFixture


class TestOldScript:

    def test_success(self, script_runner: ScriptRunner, mocker: MockerFixture) -> None:
        mocker.patch('socket.gethostname', return_value="myhostname")
        mocker.patch('oldscript.unmockable', return_value="mocked!", autospec=True)
        ret = script_runner.run('oldscript.py', print_result=True, shell=True)
        socket.gethostname.assert_called_with()
        assert ret.success
        assert ret.stdout == 'mocked!'
        assert ret.stderr is None

This is failing as unmockable cannot be mocked this way.

The call to socket.gethostname() can be successfully mocked, but can the unmockable function be mocked? That is my issue.

Would there be another strategy to test such Python scripts and be able to mock internal functions?

Answer

The problem here is that when the script is executed, oldscript.py is not being imported into oldscript namespace, it’s instead in __main__ (that’s why the condition of the if at the bottom of the script is true). Your code successfully patchess oldscript.unmockable, but the script is calling __main__.unmockable and that one is indeed unmockable.

I see two ways to get around this:

You can split the code that you would like to mock into another module that’s imported by the main script. For example if you split oldscript.py into two files like this:

lib.py:

def unmockable():
    return "somedata"

oldscript.py:

import socket
import lib


def testfunction():
    return socket.gethostname()


if __name__ == '__main__':
    result = testfunction()
    print('testfunction:', result)
    result = lib.unmockable()
    print('unmockable:', result)

then you can mock lib.unmockable in the test and everything works as expected.

Another approach is to use a console_scripts entry point in setup.py (see here for more info on this). This is a more sophisticated approach that would be a good fit for python packages that have setup.py and are installed (e.g. via pip).

When you set up your script to be installed and called this way, it becomes available in the PATH as e.g. oldscript and then you can call it from tests with:

script_runner.run('oldscript')  # Without the .py

These installed console scripts are imported and executed using a wrapper that setup.py creates, so oldscript.py would be imported as oldscript and again mocking will work.

Leave a Reply

Your email address will not be published. Required fields are marked *