Skip to main content

Unit testing CLI scripts

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:
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

Popular posts from this blog

Django on WHM

I spent pretty much all day today trying to peice together how to get Django running on a WHM server. Although I've done it before, I no longer have the configs form the old server and it was running EasyApache 3 which is no loner used. Since I've spent so much time trying to get to this point and wasn't able to find any clear documentation on how to set it up I figured I'd post it here. First we need to install python 3 since that's what Django requires, however as it's not available in the default repositories for CentOS, you'll need to install the EPEL Release repository by running: yum install epel-release Once the epel-release repository is installed, it's time to install python3, it's virtualenv installer and pip as follows: yum install python34 python34-pip python34-virtualenv After you have python 3 installed we need to compile mod_wsgi which is documented here . However we'll be changing 2 things and we need WHM's apache2...