Reputation: 1201
Say I have a module with the following:
def main():
pass
if __name__ == "__main__":
main()
I want to write a unit test for the bottom half (I'd like to achieve 100% coverage). I discovered the runpy builtin module that performs the import/__name__
-setting mechanism, but I can't figure out how to mock or otherwise check that the main() function is called.
This is what I've tried so far:
import runpy
import mock
@mock.patch('foobar.main')
def test_main(self, main):
runpy.run_module('foobar', run_name='__main__')
main.assert_called_once_with()
Upvotes: 104
Views: 58725
Reputation: 1848
Your code seems mostly correct to me. As I didn't find an answer that fit my use-case, I am summarizing my findings here. I wanted something slightly different: use pytest to test a script that also takes arguments.
The code below should work with python 3.1+, it uses pytest
and runpy
, with unittest
for passing arguments. I didn't use mock
as I am not yet familiar with it.
totest.py
def main():
print("Hello")
if __name__ == '__main__':
main()
test_totest.py
import runpy
def test_main():
runpy.run_path('totest.py', run_name='__main__')
This is useful if you use argparse
or sys.argv
in your script:
import unittest
import runpy
def test_main():
with unittest.mock.patch('sys.argv', ["progname", "arg1", "arg2"]):
runpy.run_path('totest.py', run_name='__main__')
I also include this here to answer the question more directly.
totest/__main__.py
def main():
print("Hello")
if __name__ == '__main__':
main()
test_totest_module.py
This is almost the same script as before (run_module
instead of run_path
):
import runpy
def test_main():
runpy.run_module('totest.py', run_name='__main__')
main()
function was called.With the above examples, the main()
function is always called. If you doubt this, feel free to:
assert False
after the call to main()
in the if
: you will see "Hello" displayed on Pytest's stdout log (this is a bit specific to my example).assert False
in the main function: pytest will fail your test, proving that main
was executedrunpy
returns the a dictionary containing the module globals. Therefore, you can change some of these module globals in the main()
function and check that they were changed later. You can also use this mechanism to inspect the state of your module or module globals after executing main()
, which makes more sense to me than just checking if the function was executed.totest/__init__.py
class Testdata: # This is defined here so that the class is present in the module globals
main_was_executed = False
totest/__main__.py
from . import Testdata # Import the previously-defined class
def main():
print("Hello")
Testdata.main_was_executed = True # Change the state of the module global
if __name__ == '__main__':
main()
test_totest_module.py
import runpy
def test_main():
main = runpy.run_module('totest', run_name='__main__') # Retrieve module globals
assert main['Testdata'].main_was_executed # Access the member of the module global
Of course, you can also define your own function to assert this, export it in the module globals, and call it from the pytest script.
Upvotes: 1
Reputation: 15682
Anyone appreciate a bit of lateral thinking?
def test_if_file_name_main_main_function_is_called():
main_file_path = pathlib.Path.cwd().joinpath('__main__.py')
with main_file_path.open(mode='r', encoding='utf-8') as f:
content = f.read()
pattern = '.*\nif\s+__name__\s*==\s*[\'"]__main__[\'"]\s*:\s*\n\s+main\(\)\s*\n.*'
assert re.fullmatch(pattern, content, flags=re.DOTALL) != None
I hope that raises a laugh.
I'm personally not bothered much about coverage tools.
But I'm actually proposing to include this henceforth in my projects. It bugs me that my app could potentially be shipped with a pair of lines missing which are essential to its functioning, but pass all tests.
I readily accept that this ain't perfect (for example, the matching string could be found inside a multi-line comment), but it's better than nothing, IMHO.
PS you also get a basic bonus check on UTF-8 encoding thrown in for free...
Upvotes: 0
Reputation: 473
As imp is deprecated, here is a importlib
version, along with commandline arguments mock. After the exec_module
, foobar would have been fully initiallized, and foobar.main has already been exectued.
import sys
class MyTest(unittest.TestCase):
def test_main(self):
import importlib.util
from unittest.mock import patch
testargs = ['foobar.py']
with patch.object(sys, 'argv', testargs):
spec = importlib.util.spec_from_file_location('__main__', 'foobar.py')
runpy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(runpy)
# check result of foobar script in unittest from here
self.assertIsNotNone(runpy)
Upvotes: 0
Reputation: 120
To import your "main" code in pytest in order to test it you can import main module like other functions thanks to native importlib package :
def test_main():
import importlib
loader = importlib.machinery.SourceFileLoader("__main__", "src/glue_jobs/move_data_with_resource_partitionning.py")
runpy_main = loader.load_module()
assert runpy_main()
Upvotes: 0
Reputation: 1667
If it's just to get the 100% and there is nothing "real" to test there, it is easier to ignore that line.
If you are using the regular coverage lib, you can just add a simple comment, and the line will be ignored in the coverage report.
if __name__ == '__main__':
main() # pragma: no cover
https://coverage.readthedocs.io/en/coverage-4.3.3/excluding.html
Another comment by @ Taylor Edmiston also mentions it
Upvotes: 5
Reputation: 889
I did not want to exclude the lines in question, so based on this explanation of a solution, I implemented a simplified version of the alternate answer given here...
if __name__ == "__main__":
in a function to make it easily testable, and then called that function to retain logic:# myapp.module.py
def main():
pass
def init():
if __name__ == "__main__":
main()
init()
__name__
using unittest.mock
to get at the lines in question:from unittest.mock import patch, MagicMock
from myapp import module
def test_name_equals_main():
# Arrange
with patch.object(module, "main", MagicMock()) as mock_main:
with patch.object(module, "__name__", "__main__"):
# Act
module.init()
# Assert
mock_main.assert_called_once()
If you are sending arguments into the mocked function, like so,
if __name__ == "__main__":
main(main_args)
then you can use assert_called_once_with()
for an even better test:
expected_args = ["expected_arg_1", "expected_arg_2"]
mock_main.assert_called_once_with(expected_args)
If desired, you can also add a return_value
to the MagicMock()
like so:
with patch.object(module, "main", MagicMock(return_value='foo')) as mock_main:
Upvotes: 5
Reputation: 1878
Python 3 solution:
import os
from importlib.machinery import SourceFileLoader
from importlib.util import spec_from_loader, module_from_spec
from importlib import reload
from unittest import TestCase
from unittest.mock import MagicMock, patch
class TestIfNameEqMain(TestCase):
def test_name_eq_main(self):
loader = SourceFileLoader('__main__',
os.path.join(os.path.dirname(os.path.dirname(__file__)),
'__main__.py'))
with self.assertRaises(SystemExit) as e:
loader.exec_module(module_from_spec(spec_from_loader(loader.name, loader)))
Using the alternative solution of defining your own little function:
# module.py
def main():
if __name__ == '__main__':
return 'sweet'
return 'child of mine'
You can test with:
# Override the `__name__` value in your module to '__main__'
with patch('module_name.__name__', '__main__'):
import module_name
self.assertEqual(module_name.main(), 'sweet')
with patch('module_name.__name__', 'anything else'):
reload(module_name)
del module_name
import module_name
self.assertEqual(module_name.main(), 'child of mine')
Upvotes: 10
Reputation: 51
I found this solution helpful. Works well if you use a function to keep all your script code. The code will be handled as one code line. It doesn't matter if the entire line was executed for coverage counter (though this is not what you would actually actually expect by 100% coverage) The trick is also accepted pylint. ;-)
if __name__ == '__main__': \
main()
Upvotes: 3
Reputation: 70049
I will choose another alternative which is to exclude the if __name__ == '__main__'
from the coverage report , of course you can do that only if you already have a test case for your main() function in your tests.
As for why I choose to exclude rather than writing a new test case for the whole script is because if as I stated you already have a test case for your main()
function the fact that you add an other test case for the script (just for having a 100 % coverage) will be just a duplicated one.
For how to exclude the if __name__ == '__main__'
you can write a coverage configuration file and add in the section report:
[report]
exclude_lines =
if __name__ == .__main__.:
More info about the coverage configuration file can be found here.
Hope this can help.
Upvotes: 76
Reputation: 29
My solution is to use imp.load_source()
and force an exception to be raised early in main()
by not providing a required CLI argument, providing a malformed argument, setting paths in such a way that a required file is not found, etc.
import imp
import os
import sys
def mainCond(testObj, srcFilePath, expectedExcType=SystemExit, cliArgsStr=''):
sys.argv = [os.path.basename(srcFilePath)] + (
[] if len(cliArgsStr) == 0 else cliArgsStr.split(' '))
testObj.assertRaises(expectedExcType, imp.load_source, '__main__', srcFilePath)
Then in your test class you can use this function like this:
def testMain(self):
mainCond(self, 'path/to/main.py', cliArgsStr='-d FailingArg')
Upvotes: 0
Reputation: 2383
Whoa, I'm a little late to the party, but I recently ran into this issue and I think I came up with a better solution, so here it is...
I was working on a module that contained a dozen or so scripts all ending with this exact copypasta:
if __name__ == '__main__':
if '--help' in sys.argv or '-h' in sys.argv:
print(__doc__)
else:
sys.exit(main())
Not horrible, sure, but not testable either. My solution was to write a new function in one of my modules:
def run_script(name, doc, main):
"""Act like a script if we were invoked like a script."""
if name == '__main__':
if '--help' in sys.argv or '-h' in sys.argv:
sys.stdout.write(doc)
else:
sys.exit(main())
and then place this gem at the end of each script file:
run_script(__name__, __doc__, main)
Technically, this function will be run unconditionally whether your script was imported as a module or ran as a script. This is ok however because the function doesn't actually do anything unless the script is being ran as a script. So code coverage sees the function runs and says "yes, 100% code coverage!" Meanwhile, I wrote three tests to cover the function itself:
@patch('mymodule.utils.sys')
def test_run_script_as_import(self, sysMock):
"""The run_script() func is a NOP when name != __main__."""
mainMock = Mock()
sysMock.argv = []
run_script('some_module', 'docdocdoc', mainMock)
self.assertEqual(mainMock.mock_calls, [])
self.assertEqual(sysMock.exit.mock_calls, [])
self.assertEqual(sysMock.stdout.write.mock_calls, [])
@patch('mymodule.utils.sys')
def test_run_script_as_script(self, sysMock):
"""Invoke main() when run as a script."""
mainMock = Mock()
sysMock.argv = []
run_script('__main__', 'docdocdoc', mainMock)
mainMock.assert_called_once_with()
sysMock.exit.assert_called_once_with(mainMock())
self.assertEqual(sysMock.stdout.write.mock_calls, [])
@patch('mymodule.utils.sys')
def test_run_script_with_help(self, sysMock):
"""Print help when the user asks for help."""
mainMock = Mock()
for h in ('-h', '--help'):
sysMock.argv = [h]
run_script('__main__', h*5, mainMock)
self.assertEqual(mainMock.mock_calls, [])
self.assertEqual(sysMock.exit.mock_calls, [])
sysMock.stdout.write.assert_called_with(h*5)
Blam! Now you can write a testable main()
, invoke it as a script, have 100% test coverage, and not need to ignore any code in your coverage report.
Upvotes: 12
Reputation: 613222
You can do this using the imp
module rather than the import
statement. The problem with the import
statement is that the test for '__main__'
runs as part of the import statement before you get a chance to assign to runpy.__name__
.
For example, you could use imp.load_source()
like so:
import imp
runpy = imp.load_source('__main__', '/path/to/runpy.py')
The first parameter is assigned to __name__
of the imported module.
Upvotes: 15