how to pass command line argument from pytest to code - python-3.x

I am trying to pass arguments from a pytest testcase to a module being tested. For example, using the main.py from Python boilerplate, I can run it from the command line as:
$ python3 main.py
usage: main.py [-h] [-f] [-n NAME] [-v] [--version] arg
main.py: error: the following arguments are required: arg
$ python3 main.py xx
hello world
Namespace(arg='xx', flag=False, name=None, verbose=0)
Now I am trying to do the same with pytest, with the following test_sample.py
(NOTE: the main.py requires command line arguments. But these arguments need to be hardcoded in a specific test, they should not be command line arguments to pytest. The pytest testcase only needs to send these values as command line arguments to main.main().)
import main
def test_case01():
main.main()
# I dont know how to pass 'xx' to main.py,
# so for now I just have one test with no arguments
and running the test as:
pytest -vs test_sample.py
This fails with error messages. I tried to look at other answers for a solution but could not use them. For example, 42778124 suggests to create a separate file run.py which is not a desirable thing to do. And 48359957 and 40880259 seem to deal more with command line arguments for pytest, instead of passing command line arguments to the main code.
I dont need the pytest to take command line arguments, the arguments can be hardcoded inside a specific test. But these arguments need to be passed as arguments to the main code. Can you give me a test_sample.py, that calls main.main() with some arguments?

If you can't modify the signature of the main method, you can use the monkeypatching technique to temporarily replace the arguments with the test data. Example: imagine writing tests for the following program:
import argparse
def main():
parser = argparse.ArgumentParser(description='Greeter')
parser.add_argument('name')
args = parser.parse_args()
return f'hello {args.name}'
if __name__ == '__main__':
print(main())
When running it from the command line:
$ python greeter.py world
hello world
To test the main function with some custom data, monkeypatch sys.argv:
import sys
import greeter
def test_greeter(monkeypatch):
with monkeypatch.context() as m:
m.setattr(sys, 'argv', ['greeter', 'spam'])
assert greeter.main() == 'hello spam'
When combined with the parametrizing technique, this allows to easily test different arguments without modifying the test function:
import sys
import pytest
import greeter
#pytest.mark.parametrize('name', ['spam', 'eggs', 'bacon'])
def test_greeter(monkeypatch, name):
with monkeypatch.context() as m:
m.setattr(sys, 'argv', ['greeter', name])
assert greeter.main() == 'hello ' + name
Now you get three tests, one for each of the arguments:
$ pytest -v test_greeter.py
...
test_greeter.py::test_greeter[spam] PASSED
test_greeter.py::test_greeter[eggs] PASSED
test_greeter.py::test_greeter[bacon] PASSED

A good practice might to have this kind of code, instead of reading arguments from main method.
# main.py
def main(arg1):
return arg1
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='My awesome script')
parser.add_argument('word', help='a word')
args = parser.parse_args()
main(args.word)
This way, your main method can easily be tested in pytest
import main
def test_case01():
main.main(your_hardcoded_arg)
I am not sure you can call a python script to test except by using os module, which might be not a good practice

Related

Convert script accepting command line arguments to be importable as well

I've got a script (foo.py) which I originally developed to be called from the command line with named arguments. I'd now like to refactor it so it may called both from the command line and via import as well. Calling the script as is after import throws an unexpected keyword argument error, as written (since the main function itself has no arguments). I was under the impression that the __name__=="__main__" chunk would've provided the same experience in both calls, but I've obviously misunderstood. How could I refactor this so it can take the named arguments interactively and upon import by another script?
foo.py
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument('--token', help='API token')
parser.add_argument('--namedArgA', help='Switch parameter', action='store_true')
return parser.parse_args()
def main():
args = parse_args()
token = args.token
# Other stuff with the args
print(argDict)
if __name__=="__main__":
main()
bar.py
import foo
baz = foo.main(token='myToken',namedArgA='True')

Argparse Unit Test with IP Address Module

I'd like to write unit tests for the following function:
#!/usr/bin/env python3
"""IPv4 validation using `ipaddress module` and argparse."""
import argparse
from ipaddress import ip_address
def parse_cli_args():
"""
Command line parser for subnet of interest.
Args:
--ip 0.0.0.0
Returns:
String, e.g. 0.0.0.0
"""
parser = argparse.ArgumentParser(description="IPv4 address of interest.")
parser.add_argument("--ip", action="store", type=ip_address,\
required=True,\
help="IP address of interest, e.g. 0.0.0.0")
args = parser.parse_args()
return args
if __name__ == '__main__':
args = parse_cli_args()
print(args.ip)
which works as expected, e.g.:
python3 test.py --ip 192.168.1.1
192.168.1.1
python3 test.py --ip derp
usage: test.py [-h] --ip IP
test.py: error: argument --ip: invalid ip_address value: 'derp'
python3 test.py --ip
usage: test.py [-h] --ip IP
test.py: error: argument --ip: expected one argument
How can I mock these three conditions in unit tests?
I tried a few variations of this:
import unittest
from unittest.mock import patch
class ParseCLIArgs(unittest.TestCase):
"""Unit tests."""
#patch('builtins.input', return_value='192.168.1.1')
def test_parse_cli_args_01(self, input):
"""Valid return value."""
self.assertIsInstance(parse_cli_args(), ipaddress.IPv4Address)
if __name__ == '__main__':
unittest.main()
without success. What am I doing wrong, and how can I fix that?
EDIT I got a bit further with this:
class ParseCLIArgs(unittest.TestCase):
def setUp(self):
self.parser = parse_cli_args()
def test_parser_cli_args(self):
parsed = self.parser.parse_args(['--ip', '192.168.1.1'])
self.assertIs(parsed.ip, '192.168.1.1')
if __name__ == '__main__':
unittest.main()
Which fails with: TypeError: isinstance() arg 2 must be a type or tuple of types. I believe this is because the function actually transforms user input.
To test a parser you need to either modify sys.argv or provide your own substitute.
When called with
args = parser.parse_args(argv)
if argv is None (or not provided) it parses sys.argv[1:]. This is the list that the shell/interpreter gives it. Otherwise it will parse the equivalent list that you provide.
test_argparse.py uses both ways to test a parser - constructing a custom sys.argv and call parse_args with a custom argv.
Another thing to watch out for is error trapping. Most parsing errors display usage, error message and then exits. Capturing that exit and the stderr message takes some work. test_argparse makes a subclass of ArgumentParser with a custom error method.
In sum, doing unittest on code that depends on sys.argv and does a system exit, may require more work than it's worth. That said, I'm not an expert on unit testing and mocking tools; I've just studied the test_argparse file.
Hopefully this can serve as good sample for someone with a similar question. This test does most (but not all) of what I want it to do. I'm still working out a unit test to check for Type.
#!/usr/bin/env python3
"""IPv4 validation using `ipaddress module` and argparse."""
import argparse
from ipaddress import ip_address
import unittest
def parse_cli_args():
"""
Command line parser for subnet of interest.
Args:
--ip 0.0.0.0
Returns:
String, e.g. 0.0.0.0
"""
parser = argparse.ArgumentParser(description="IPv4 address of interest.")
parser.add_argument("--ip", action="store",\
required=True,\
help="IP address of interest, e.g. 0.0.0.0")
return parser
class ParseCLIArgs(unittest.TestCase):
def setUp(self):
self.parser = parse_cli_args()
def test_parser_cli_args(self):
parsed = self.parser.parse_args(['--ip', '192.168.1.1'])
self.assertEqual(parsed.ip, '192.168.1.1')
if __name__ == '__main__':
unittest.main()

pytest: passing list as arguments from command line is not working

I am facing below error when i run pytest with list as arguments from command line..
pytest -vs test_sample.py --html=results.html --A_list=[A1, A2, A3]
ERROR: file not found: A2,
below is my test_sample.py code
import pytest
def test_functionality(A_list):
print("element in list: {}".format(A_list))
Below is my conftest.py code
def pytest_addoption(parser):
parser.addoption("--A_list", action="store", default="default name")
def pytest_generate_tests(metafunc):
option_value = metafunc.config.option.A_list
if 'A_list' in metafunc.fixturenames and option_value is not None:
metafunc.parametrize("A_list", [option_value])
This is working fine if i pass only one element like below
pytest -vs test_sample.py --html=results.html --A_list=A1
But it is getting failed if i run pytest with list of elements for A_list like below
pytest -vs test_sample.py --html=results.html --A_list=[A1, A2, A3]
Can anyone let me know how to pass list as pytest arguments from command line ...
The problem is that you try to pass a Python list as a command line argument. This does not work - command line arguments are just strings that need to be parsed. Especially you cannot have spaces in the option if you don't surround it with apostrophes.
What you could do is to pass the list as a string, for example by comma separating the entries:
pytest -vs test_sample.py --html=results.html --A_list="A1,A2,A3"
Note that the apostrophes are strictly not needed here as you don't have spaces, but you can use them anyway. Then you can parse the string into a list:
def pytest_generate_tests(metafunc):
option_value = metafunc.config.option.A_list
if option_value:
params = option_value.split(",")
if 'A_list' in metafunc.fixturenames:
metafunc.parametrize("A_list", params)

argparse with multiple flags and arguments

I want my code to be able to call different functions according to the flag and then use the argument passed after the flag as the input to the function.
Example of the expected output:
$ python3 test.py -a 2
4
$ python3 test.py -a human123
<Error message>
$ python3 test.py -h human123
Hello, human123
Here's my sample code:
class Test:
def __init__(self):
pass
def add(self, a):
return a+a
def hello(self, name):
return f"Hello, {name}"
parser = argparse.ArgumentParser()
parser.add_argument('-a', '--add', dest='command', action='store_consts', const='add', nargs=1, help='add a to itself')
parser.add_argument('-h', '--hello', dest='command', action='store_consts', const='hello', nargs=1, help='hello!')
args = parser.parse_args()
t = Test()
if args.command=='add':
print(t.add(args.add))
elif args.command=='sub':
print(t.hello(args.hello))
This sample code currently is doing what I want to achieve. I tried many things to fix the issue from removing the 'consts', changing the action to 'store', changing the value of nargs to '?', etc., however, it keeps giving me different kinds of errors like TypeError, etc.
Simplifying your arguments:
import argparse
class Test:
def __init__(self):
pass
def add(self, a):
return a+a
def hello(self, name):
return f"Hello, {name}"
parser = argparse.ArgumentParser()
parser.add_argument('-a', '--add', help='add a to itself')
parser.add_argument('-b', '--hello', help='hello!') # -h is already taken
args = parser.parse_args()
print(args)
t = Test()
if args.add: # or 'args.add is not None:'
print(t.add(args.add))
elif args.hello:
print(t.hello(args.hello))
test runs:
1936:~/mypy$ python3 stack62702524.py -a testing
Namespace(add='testing', hello=None)
testingtesting
1937:~/mypy$ python3 stack62702524.py -b other
Namespace(add=None, hello='other')
Hello, other
1937:~/mypy$ python3 stack62702524.py
Namespace(add=None, hello=None)
===
Your errors, which you did not show :{ When you get errors, don't just throw up your hands and randomly try alternatives. Read the docs and try to understand the error.
parser.add_argument('-c', action='store_consts', const='hello')
ValueError: unknown action "store_consts"
parser.add_argument('-c', action='store_const', const='hello', nargs=1)
TypeError: __init__() got an unexpected keyword argument 'nargs'
'store_consts' is an name error; with 'store_const' nargs is fixed at 0; you can't change that.
If I add 3 arguments - two store_const and one positional:
parser.add_argument('-c', dest='command', action='store_const', const='add')
parser.add_argument('-d', dest='command', action='store_const', const='hello')
parser.add_argument('foo')
Note the two new command and foo attributes, which you could use in your function call:
1945:~/mypy$ python3 stack62702524.py -c bar
Namespace(add=None, command='add', foo='bar', hello=None)
1945:~/mypy$ python3 stack62702524.py -d bar
Namespace(add=None, command='hello', foo='bar', hello=None)
Typically I use the dest parameter of add_argument to specify the variable name.
For example:
parser.add_argument("-a", "--add", dest="add", required=False, type=str help="some help")
Could be accessed by:
args = parser.parse_args()
print(args.add == "something")
I believe that you need one unique dest per argument.
Also, -h is reserved for help. You may wish to change this to -e or something.
parser.add_argument('-h', '--hello', ...)

Passing argument to a defined function so they accessible to sys.argv in python3

I have a program (temp.py) in python3 that reads the temperature at sensors in a solar panel. I also have a program (stepper.py) that I use to control a stepper motor. The program stepper.py uses sys.argv to evaluate arguments and determine how far and how quickly the motor should turn. I now want to call stepper.py from temp.py to combine the functionality of the two programs. However, when I call stepper.py from temp.py the arguments are not passed in a way that sys.argv can use them. I have written a short script to illustrate my problem:
import sys
y=5
z=2
def called(a,b):
print(str(sys.argv))
print(len(sys.argv[1:]))
global p,q
p=a*b
q=a+b
called(y,z)
print(p,q)
This script returns the following:
['/home/pi/calling.py']
0
10 0
In other words, although the calculation of the arguments has been completed, sys.argv is saying that no arguments have been passed and merely shows argv[0] which is the program's name. I understand why this occurs but is there any way that I can get sys.argv to "see" the arguments?
I'm not aware of a way to manually fill argv, but that doesn't really seem like the best way of achieving your goal here to me. You're trying to call a python function in one module from another, so you should do that using Python's normal importing and function call mechanisms, not by repurposing argv, which is designed to handle command line arguments. Usig python's if __name__ == "__main__" construction, you can still pass command line arguments through to the function.
If you haven't already, this will require wrapping up the code you have in your modules into functions with well-defined arguments. Here's an example of the approach I'm describing:
# stepper.py
import sys
def turn_motor(rotations, speed=1):
"""Turns the stepper motor"""
if __name__ == "__main__":
# Code in this block will run when stepper.py is invoked from the command line
turn_motor(rotations=sys.argv[1], speed=sys.argv[2])
and in your other file
# temp.py
import sys
from stepper import turn_motor
def detect_temp():
"""Returns the current temperature"""
def turn_when_hot(threshold):
"""Turns stepper motor when temperature above given threshold"""
temperature = detect_temp()
if temperature > threshold:
# Calls the turn_motor function directly with whatever arguments
# you like, without having to mess with sys.argv
turn_motor(revolutions=3, speed=1.2)
if __name__ == "__main__":
turn_when_hot(sys.argv[1])
So you could call stepper.py from the command line with arguments for turns and speed, or you could call temp.py with an argument for a temperature threshold, and it would call stepper.turn_motor directly.
Here is the equivalent fixed code with notes below:
def called(a, b):
return a*b, a+b
if __name__ == '__main__':
import sys
y=5
z=2
print(str(sys.argv))
print(len(sys.argv[1:]))
p, q = called(y, z)
print(p, q)
As TroyHurts wrote, separate the core functionality to the functions, and the body to the if __name__ == '__main__': block.
Then you will be able to import stepper in your temp.py. If you need sys.argv in temp.py, import the sys in the temp.py.
The import sys in stepper.py can even be moved to the if block if it is not used in the function definitions.
The functions (in stepper.py) should get all inputs as function arguments. They should return everything through the return statement.
Never use global.
The called is not a nice identifier. Choose better one. ;)

Resources