Doctesting functions that receive and display user input - Python (tearing my hair out)
Asked Answered
E

7

10

I am currently writing a small application with Python (3.1), and like a good little boy, I am doctesting as I go. However, I've come across a method that I can't seem to doctest. It contains an input(), an because of that, I'm not entirely sure what to place in the "expecting" portion of the doctest.

Example code to illustrate my problem follows:

"""
>>> getFiveNums()
Howdy. Please enter five numbers, hit <enter> after each one
Please type in a number:
Please type in a number:
Please type in a number:
Please type in a number:
Please type in a number:
"""

import doctest

numbers = list()

# stores 5 user-entered numbers (strings, for now) in a list
def getFiveNums():
    print("Howdy. Please enter five numbers, hit <enter> after each one")
    for i in range(5):
        newNum = input("Please type in a number:")
        numbers.append(newNum)
    print("Here are your numbers: ", numbers)

if __name__ == "__main__":
    doctest.testmod(verbose=True)

When running the doctests, the program stops executing immediately after printing the "Expecting" section, waits for me to enter five numbers one after another (without prompts), and then continues. As shown below:

doctest results

I don't know what, if anything, I can place in the Expecting section of my doctest to be able to test a method that receives and then displays user input. So my question (finally) is, is this function doctestable?

Eisteddfod answered 1/5, 2010 at 1:49 Comment(0)
A
7

I know you are asking for a doctest answer but may I suggest that this type of function may not be a good candidate for doctest. I use doctests for documentation more than testing and the doctest for this wouldn't make good documentation IMHO.

A unitest approach may look like:

import unittest

# stores 5 user-entered numbers (strings, for now) in a list
def getFiveNums():
    numbers = []
    print "Howdy. Please enter five numbers, hit <enter> after each one"
    for i in range(5):
        newNum = input("Please type in a number:")
        numbers.append(newNum)
    return numbers

def mock_input(dummy_prompt):
    return 1

class TestGetFiveNums(unittest.TestCase):
    def setUp(self):
        self.saved_input = __builtins__.input
        __builtins__.input = mock_input

    def tearDown(self):
        __builtins__.input = self.saved_input

    def testGetFiveNums(self):
        printed_lines = getFiveNums()
        self.assertEquals(printed_lines, [1, 1, 1, 1, 1])

if __name__ == "__main__":
    unittest.main()

It's maybe not exactally testing the function you put forward but you get the idea.

Alumina answered 1/5, 2010 at 3:42 Comment(0)
S
7

The simplest way to make this testable would be parameter injection:

def getFiveNums(input_func=input):
    print("Howdy. Please enter five numbers, hit <enter> after each one")
    for i in range(5):
        newNum = input_func("Please type in a number:")
        numbers.append(newNum)
    print("Here are your numbers: ", numbers)

You can't realistically be expected to unit test input/output like that -- you cannot be concerned that the call to input might somehow fail. Your best option is to pass in a stub method of some nature; something like

def fake_input(str):
    print(str)
    return 3

So that in your doctest, you actually test getFiveNums(fake_input).

Moreover, by breaking the direct dependency on input now, if you were to port this code to something else later that didn't use a command line, you could just drop in the new code to retrieve input (whether that would be a dialog box in a GUI application, or a Javascript popup in a web-based application, etc.).

Stirling answered 1/5, 2010 at 1:53 Comment(0)
A
7

I know you are asking for a doctest answer but may I suggest that this type of function may not be a good candidate for doctest. I use doctests for documentation more than testing and the doctest for this wouldn't make good documentation IMHO.

A unitest approach may look like:

import unittest

# stores 5 user-entered numbers (strings, for now) in a list
def getFiveNums():
    numbers = []
    print "Howdy. Please enter five numbers, hit <enter> after each one"
    for i in range(5):
        newNum = input("Please type in a number:")
        numbers.append(newNum)
    return numbers

def mock_input(dummy_prompt):
    return 1

class TestGetFiveNums(unittest.TestCase):
    def setUp(self):
        self.saved_input = __builtins__.input
        __builtins__.input = mock_input

    def tearDown(self):
        __builtins__.input = self.saved_input

    def testGetFiveNums(self):
        printed_lines = getFiveNums()
        self.assertEquals(printed_lines, [1, 1, 1, 1, 1])

if __name__ == "__main__":
    unittest.main()

It's maybe not exactally testing the function you put forward but you get the idea.

Alumina answered 1/5, 2010 at 3:42 Comment(0)
A
3

I found a different way.

"""
>>> get_five_nums(testing=True)
Howdy. Please enter five numbers, hit <enter> after each one.
Please type in a number: 1
Please type in a number: 1
Please type in a number: 1
Please type in a number: 1
Please type in a number: 1
Here is a list of the numbers you entered:  [1, 1, 1, 1, 1]
>>>
"""

import doctest

numbers = []

def get_five_nums(testing=False):
    """Stores 5 user-entered numbers (strings, for now) in a list."""

    print("Howdy. Please enter five numbers, hit <enter> after each one.")
    for i in range(5):
        new_num = int(input("Please type in a number: "))
        if testing:
            print(new_num)
        numbers.append(new_num)
    print("Here is a list of the numbers you entered: ", numbers)


if __name__ == "__main__":
    doctest.testmod(verbose=True)  

Save the above code in a file called foo.py. Now make a file called input.txt.

All it needs in it is.

1
1
1
1
1

Five ones. One on each line.

To test you program do the following, at terminal or command prompt (I'm using a mac):

$ python foo.py < input.txt

This is easily changeable for any kind of user input on any program. With this you can now copy the output of terminal session and use it as your doctest.

NOTE: the function call in terminal would be get_five_nums(). In you doctest it needs to be get_five_nums(testing=True).

Even though doctest doesn't appear to be intended to be used in this way it is still a handy hack.

Aurignacian answered 27/10, 2010 at 3:54 Comment(0)
E
2

Here's a work-around I came up with. It's a bit kludgy, but it works when only one line of input is needed:

def capitalize_name():
    """
    >>> import io, sys ; sys.stdin = io.StringIO("Bob")  # input
    >>> capitalize_name()
    What is your name?  Your name is BOB!
    """
    name = input('What is your name?  ')
    print('Your name is ' + name.upper() + '!')

Unfortunately, it complains when the input contains a newline (for example, "Bob\nAlice"). I suspect that this is due to the doctest parser being overwhelmed (but I can't say for sure).

You can get around the "\n" issue by using chr(10) instead, like this:

# stores 5 user-entered numbers (strings, for now) in a list
def getFiveNums():
    """
    >>> import io, sys ; sys.stdin = io.StringIO(chr(10).join(['1','2','3','4','5']))  # input
    >>> getFiveNums()
    Howdy. Please enter five numbers, hit <enter> after each one
    Please type in a number:Please type in a number:Please type in a number:Please type in a number:Please type in a number:Here are your numbers:  ['1', '2', '3', '4', '5']
    """
    print("Howdy. Please enter five numbers, hit <enter> after each one")
    numbers = []
    for _ in range(5):
        newNum = input("Please type in a number:")
        numbers.append(newNum)
    print("Here are your numbers: ", numbers)

This is even more kludgy, but it does work. You need to remember that all the prompting text (via the input() function) is displayed as output without the accompanying user input. (That's why "Please type in a number:" appears five times in a row with no spaces or newlines between its instances.)

And while this solution does work, keep in mind that it's harder to read and maintain than some of the other given solutions. That's something to consider when making your decision on which approach to use.

Elea answered 28/5, 2019 at 23:45 Comment(0)
C
2

I can agree with the kludginess, but to make it a little less so, why not add another little function that holds most of the kludginess for you (and add a test for it while you're at it :)

I do agree that doctest might not be the best solution for this type of testing, but I find myself using doctest for TDD where I like the simplicity of not having to leave the file, or even the function when writing the test, so I could just as well have ended up wanting to do such a test in the same way. That said, the approach to how you write the getFiveNums() should probably be changed into something more suitable for testing, such as the parameter injection previously mentioned.

def redirInput(*lines):
    """
    >>> import sys
    >>> redirInput('foo','bar')
    >>> sys.stdin.readline().strip()
    'foo'
    >>> sys.stdin.readline().strip()
    'bar'
    """
    import sys,io
    sys.stdin = io.StringIO(chr(10).join(lines))


def getFiveNums():
    """
    >>> redirInput('1','2','3','4','5')
    >>> getFineFums()
    ... rest as already written ...

Cassiani answered 23/2, 2020 at 0:59 Comment(0)
B
0

As with Mark Rushakoff's answer the following uses parameter injection, additionally it creates a generator to allow arbitrary inputs to be used in the tested function to fully answer the original posters question.

import doctest


def test_input_generator(*args):
    """Creates test input function that reads inputs from the passed arguments.
    This is used to override input to allow doctests to mimic user
    input

    Returns:
        function: this function is meant to moc out input and each time it is
                  called it will print the supplied prompt along with the "input"
                  supplied at the time the function was created.

    """
    input_iterator = (test_input for test_input in args)

    def test_input(prompt):
        """Gets input from outer input_iterator and prints the supplied
        prompt along with the test input.

        Args:
            prompt :  the prompt ot display to the user

        Returns:
            str:  the test input string
        """
        response = next(input_iterator)
        print(f"{prompt}{response}")
        return response

    return test_input


def getFiveNums(input=input):
    """stores 5 user-entered numbers (strings, for now) in a list

    Examples:
        >>> getFiveNums(input=test_input_generator(1, 2, 3, 4, 5))
        Howdy. Please enter five numbers, hit <enter> after each one
        Please type in a number:1
        Please type in a number:2
        Please type in a number:3
        Please type in a number:4
        Please type in a number:5
        Here are your numbers:  [1, 2, 3, 4, 5]
    """
    numbers = list()

    print("Howdy. Please enter five numbers, hit <enter> after each one")

    for i in range(5):
        newNum = input("Please type in a number:")
        numbers.append(newNum)

    print("Here are your numbers: ", numbers)


if __name__ == "__main__":
    doctest.testmod(verbose=True)
Bitty answered 22/3, 2022 at 17:25 Comment(0)
S
0

I came up with an improved way to "fake" user input. It works well with doctests. It's based on the final concepts at https://pyquestions.com/doctesting-functions-that-receive-and-display-user-input-python-tearing-my-hair-out wrapped in a context manager that "cleans up" after itself properly, so you can still use the input function after running doctests. Here's the code:

class FakeInput:
    """Context manager to allow input mocking.
    Developed for use with doctests, but works elsewhere.
    Original concept from https://pyquestions.com/doctesting-functions-that-receive-and-display-user-input-python-tearing-my-hair-out
    Wrapped in a context manager so sys.stdin gets reset automatically
    Converts all values passed to it to str, so FakeInput(2, 4, 6, 8) is OK
    You can either:
    - paste this class directly into your code, or
    - put this code into fakeinput.py in the same folder as your script,
      and add the following line to your script:
        from fakeinput import FakeInput
    >>> with FakeInput(""): input()
    ''
    >>> with FakeInput("Doc"): print("What's up, " + input("Your name? ") + "?")
    Your name? What's up, Doc?
    >>> with FakeInput(2, "bla", None): [input() for _ in range(3)]
    ['2', 'bla', 'None']
    """
    def __init__(self, *values):
        self.values = values
    def __enter__(self):
        import io, sys
        self.old_stdin = sys.stdin
        sys.stdin = io.StringIO("\n".join(map(str, self.values)) + "\n")
    def __exit__(self, *rest):
        import sys
        sys.stdin = self.old_stdin

if __name__ == "__main__":
    import doctest
    doctest.testmod()
Selfconfidence answered 9/9, 2022 at 15:33 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.