I've been building tests for my scripts for some time now; however, I've avoiding testing the interaction with CLI scripts simply because I didn't know how to mock user input and test the script's output. This past week I decided overcome this hurdle and look further into mocking user input. I was surprised by how not difficult it actually was. Of course there is unittest.mock, but that didn't give me the control I wanted when running a script and testing it end to end.
After a bit of digging I found that the key to solving this problem was overloads, specifically overloading input, getpass(if you prompt for it), and print. I found an article called Mocking input and output for Python testing on code-maven covering this. I then expanded on it to make it work for my application. I guess it's best to demonstrate and then go back and explain the code. So here's an app, and it's test.py:
cli_app.py:
test.py:
So what's being done here is two lists are build, one for the input queue, and the other for the output. The mock_input uses the input queue to get pass inputs to the app while both the mock_input and mock_print functions populate the output according to what would have been printed on the screen.
In addition to mocking the input and collecting the output from a script the mock_input can accept a callable from the input queue. This allows the test suite to interrupt the flow of the program and make assertions at an input point(where a user would normally see the program stop anyway) and then return a static value, or a dynamic value that can be based on the values in the list of outputs. In my example above I simple return the first column of the first entry in a table that was printed immediately before the prompt that used a callable.
Note that the above code is written so that it will work and test correctly in python 2.7 and python 3.
After a bit of digging I found that the key to solving this problem was overloads, specifically overloading input, getpass(if you prompt for it), and print. I found an article called Mocking input and output for Python testing on code-maven covering this. I then expanded on it to make it work for my application. I guess it's best to demonstrate and then go back and explain the code. So here's an app, and it's test.py:
cli_app.py:
from __future__ import print_function
from getpass import getpass
try:
input = raw_input
except NameError:
pass
class MyObject(object):
@property
def somedata(self):
output = "header Columns here\n"
output += "------- -------- -----\n"
output += "First 1 foo \n"
output += "Second 2 bar \n"
output += "Third 3 baz \n"
return output
class App(object):
def __init__(self):
self.my_obj = MyObject()
def main(self):
input("Username: ")
getpass("Password: ")
print(self.my_obj.somedata)
input("Pick an object: ")
if __name__ == '__main__':
app = App()
try:
exit(app.main())
except KeyboardInterrupt:
print()
exit()
test.py:
from __future__ import print_function
import unittest
import cli_app
DEBUG = False
class TestApp(unittest.TestCase):
def mock_input(self, prompt, silent=False):
"""Mock user input by using an input_queue list."""
# Add the input's prompt to the list of outputs.
self.output.append(prompt)
if DEBUG:
print(prompt, end='')
# Pop the first item from the input_queue to be used as an unput.
input_value = self.input_queue.pop(0)
# If the popped item is callable, then the return value should be the value returned by the
# callable, otherwise simply return the value popped from the input_queue.
if callable(input_value):
return_value = input_value()
else:
return_value = input_value
# To double the purpose of this function use the silent attribute to prevent output
# generated by the input so that getpass can be mocked.
if not silent:
self.output.append('{}\n'.format(prompt))
if DEBUG:
print(return_value)
elif DEBUG:
print()
return return_value
def mock_getpass(self, prompt):
"""Mock input without the input being output."""
self.mock_input(prompt, silent=True)
def mock_print(self, *args, **kwargs):
"""Mock the print function in order to collect output from the CLI app."""
end = kwargs.get('end', '\n')
if len(args):
self.output.append('{}{}'.format(''.join(args), end))
else:
self.output.append(end)
if DEBUG:
print(*args, **kwargs)
def __init__(self, *args, **kwargs):
"""Initiaize the TestCase."""
super(TestApp, self).__init__(*args, **kwargs)
# Being that these only need to be overloaded once, overload them in the __init__ rather
# than the setup.
cli_app.input = self.mock_input
cli_app.getpass = self.mock_getpass
cli_app.print = self.mock_print
def setUp(self):
"""Set up for a new test."""
# Every test should have a fresh list for the input/output.
self.input_queue = []
self.output = []
def test_cli_app(self):
"""Run and test the CLI app."""
app = cli_app.App()
# Add items to the input queue to be entered into the app.
self.input_queue.append('admin')
self.input_queue.append('supersecretpassword')
def get_first_object():
"""Collect printed data and return an object from it."""
printed_table = self.output[-2]
# Assert that the item printed second to last(the input promt being the last) matches
# the property being printed by the app.
self.assertEqual(
printed_table,
'{}\n'.format(app.my_obj.somedata)
)
table_lines = printed_table.split('\n')
first_item = table_lines[2].split()[0]
# Assert that the item chosen is correct.
self.assertEqual(first_item, 'First')
# return the chosen item
return first_item
# Add the callable to the input queue to be processed 3rd.
self.input_queue.append(get_first_object)
# Start the app.
app.main()
So what's being done here is two lists are build, one for the input queue, and the other for the output. The mock_input uses the input queue to get pass inputs to the app while both the mock_input and mock_print functions populate the output according to what would have been printed on the screen.
In addition to mocking the input and collecting the output from a script the mock_input can accept a callable from the input queue. This allows the test suite to interrupt the flow of the program and make assertions at an input point(where a user would normally see the program stop anyway) and then return a static value, or a dynamic value that can be based on the values in the list of outputs. In my example above I simple return the first column of the first entry in a table that was printed immediately before the prompt that used a callable.
Note that the above code is written so that it will work and test correctly in python 2.7 and python 3.
Comments
Post a Comment