A pattern for unit testable Python argparse implementation

Python argparse is a standard library for a Python script to extract command line arguments. It’s pretty useful, but unfortunately most tutorials and even the documentation itself don’t assume unit testing of your argument parsing. I present here a pattern for argparse usage that enables and facilitates unit testing, along with a nice encapsulation of a “user options” concept for maintaining the user options specified from the command line.

In good Test Driven Development (TDD) practice, let’s define the tests first. I’m going to use unittest for the unit testing framework. It’s a standard library so it’s readily available, some others prefer pytest. You choose.

import unittest

class TestUserOptions(unittest.TestCase):

    def setUp(self):
        self.options_under_test = UserOptions()

        self.calculate_expected_sum = lambda x, y: x+y


    def test_defaults(self):
        """
        Test that default values of user options are assigned correctly.

        Mandatory arguments don't have a default, but they are mandatory so
        they have to be provided as part of the input to the test.
        """
        # The "mandatory" argument is mandatory
        EXPECTED_MANDATORY_VALUE = 10
        EXPECTED_OPTIONAL_VALUE = UserOptions.DEFAULT_OPTIONAL_VALUE
        mock_input = [
            '{0}'.format(EXPECTED_MANDATORY_VALUE),
        ]

        self.options_under_test.parse_arguments(mock_input)

        self.assertEqual(
          EXPECTED_MANDATORY_VALUE, \
          self.options_under_test.mandatory)
        self.assertEqual(
          EXPECTED_OPTIONAL_VALUE, \
          self.options_under_test.optional)
        self.assertEqual(
            self.calculate_expected_sum(
                EXPECTED_MANDATORY_VALUE, \
                EXPECTED_OPTIONAL_VALUE \
            ), \
            self.options_under_test.sum
        )


    def test_optional(self):
        """
        Test that the sum property is working.
        """
        EXPECTED_MANDATORY_VALUE = 10
        EXPECTED_OPTIONAL_VALUE = 3
        mock_input = [
            '{0}'.format(EXPECTED_MANDATORY_VALUE),
            '--optional',
            '{0}'.format(EXPECTED_OPTIONAL_VALUE),
        ]

        self.options_under_test.parse_arguments(mock_input)

        self.assertEqual(
          EXPECTED_MANDATORY_VALUE, \
          self.options_under_test.mandatory)
        self.assertEqual(
          EXPECTED_OPTIONAL_VALUE, \
          self.options_under_test.optional)
        self.assertEqual(
            self.calculate_expected_sum(
                EXPECTED_MANDATORY_VALUE, \
                EXPECTED_OPTIONAL_VALUE \
            ), \
            self.options_under_test.sum
        )

Remember, we haven’t done any implementation yet, so what do the above tests tell us about the expectations for the implementation?

  • The extracted command line argument values are presented as properties or members of the UserOptions class.
  • A UserOptions.sum property presents the sum of the other two argument values.
  • There is a UserOptions.DEFAULT_OPTIONAL_VALUE member defining the default value for the optional argument.
  • The UserOptions.parse_arguments method takes the command line arguments input for processing. After this method is called the UserOptions is expected to be in the correct state for using member variables.

Now, what implementation would satisfy the tests we’ve defined?

class UserOptions:
    DEFAULT_OPTIONAL_VALUE = 1

    def __init__(self):
        self.__parsed_arguments = None

        # declare the internal argparse parser
        self.__parser = argparse.ArgumentParser()

        # add your arguments
        self.__parser.add_arguments('mandatory',
                                    type=int)
        self.__parser.add_arguments('--optional',
                                    default=self.DEFAULT_OPTIONAL_VALUE,
                                    type=int)


    def __getattr__(self, item:str):
        # Some Python trickery to reflect parsed arguments as UserOptions attributes
        value = getattr(self.__parsed_arguments, item)

        return value


    def parse_arguments(command_line_argument:list):
        self.__parsed_arguments = self.__parser.parse_args(command_line_arguments)


    @property
    def sum(self):
        return self.mandatory + self.optional

The __getattr__ method is interesting because the parsed arguments from argparse get presented as attributes of the UserOptions class. Per Python definitions, a property or method of the UserOptions class will be called before the __getattr__ method and if __getattr__ fails then AttributeError exception will be raised.

So that’s it, a nice unit testable implementation of user defined options using the argparse standard library, with the side effect of a nicely decoupled user defined options class implementation for consumption elsewhere in your application.