Reputation: 3389
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:
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?
Upvotes: 10
Views: 4548
Reputation: 81
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()
Upvotes: 0
Reputation: 1
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)
Upvotes: 0
Reputation: 21
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 ...
Upvotes: 2
Reputation: 1901
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.
Upvotes: 2
Reputation: 874
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.
Upvotes: 3
Reputation: 2754
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.
Upvotes: 7
Reputation: 258228
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.).
Upvotes: 7