One of the recommendations when writing well designed applications is to separate concerns. So one function to get input. Another to do the computation (business logic). A third function to generate the output.
Unfortunately in many applications people mix these things. Especially if they have not tried to test the application.
So when you start writing tests to such an application, one that might have functions with hundreds or thousands of lines of code, you need to be able untangle the code.
In this article we'll override the input and output of a CLI (Command Line Interface) based application.
For our purposes we'll use this "application". It has a single function that has input, output and some "business logic". (Yeah, I know, adding two numbers is probably not such a huge business these days.)
examples/python/mock-input/app.py
def main():
# ...
x = input("First: ")
y = input("Second: ")
# ...
z = int(x) + int(y)
# ...
print("The result is {}".format(z))
if __name__ == '__main__':
main()
We then have a test like this:
examples/python/mock-input/test_app.py
import app
def test_app():
input_values = [2, 3]
output = []
def mock_input(s):
output.append(s)
return input_values.pop(0)
app.input = mock_input
app.print = lambda s : output.append(s)
app.main()
assert output == [
'First: ',
'Second: ',
'The result is 5',
]
In this code we have two lines in which we replace the input
and print
functions of the app
object. The overriding of print
seems to be the simpler here.
Every time the code in app
calls print
our lambda
function will append the value to the list called output
we have declared earlier. It won't print anything to the screen. (In case you are not familiar with lambda
it creates an anonymous function on the fly.)
Overriding the input
function is similar, but that required two statements, so we could not do it in a lambda
. Hence we created a function called mock_input
(a totally arbitrary name) and then assigned that function to the app.input
. That means every time input
is called inside the app
object, Python will call our mock_input
function instead of the built-in input
function.
Normally the input
function of Python 3 does 2 things: prints the received string to the screen and then collects any text typed in on the keyboard. Our function will also do two things: Append the received string to the list called output
just as the lambda
function of print
does and then take the first value from the list called input_values
and return that.
This allows us to prepare a list of answers we would like to give to the application and this function will help us pretend we have actually typed those in when the application asked for it.
Once we prepared all this we call the main function of the application: app.main()
.
This will call the fake input
function and the fake print
function, but the rest of the code won't know about the change. So the rest, the business logic, we work as it should.
Then we can assert whether the values collected in the output
list are the same values as we expected them.
We can run the test by typing pytest test_app.py
in the directory where we have both of these files.
(In our case that is the examples/python/mock-input/
directory.
The above works, but we might have better solutions:
capsys
capsys
is one of the built-in fixtures Pytest provides.
It helps us capture everything that goes to the standard output and the standard error during the execution of a test.
In order to use it we need to include the parameter capsys
in the list of parameters our test function expects. (In our case this is the only parameter.) Seeing that the function expects the capsys
object Pytest will call our function passing in the capsys
object (this technique is called dependency injection.) Pytest will also set up everything necessary to capture both the output and error stream.
That means we don't need to (and we really should not) override the print
function and in the mock_input
function we don't save the parameter string in an output
list. Actually, because our mock_input
function only has one statement in it, we could have converted it to a lambda
. But I digress.
Once we have our setup we call app.main()
just as in the previous case. Then we can call the
readouterr
method of the capsys
object that will return two strings. Whatever was printed to the standard output and whatever was printed to the standard error since the beginning of the test function.
We can compare these two to the expected strings.
examples/python/mock-input/test_app_capsys.py
import app
def test_app(capsys):
input_values = [2, 3]
def mock_input(s):
return input_values.pop(0)
app.input = mock_input
app.main()
out, err = capsys.readouterr()
assert out == 'The result is 5\n'
assert err == ''
Using capsys
has the advantage that it is a built-in tool and that it can be used to check both the correct output and that nothing was printed to the error channel.
In other cases we might use it to make sure the expected error message was printed to the error channel.
We should also notice that in this solution we don't capture the output that should have been generated by the input
function as that part was swallowed by our mocking function. This might be clearer or more confusing to you depending on your expectations.
We also had to include the new-line \n
in our expected string.
Try: pytest test_app_capsys.py
capsys capturing the prompts as well
If you'd like to include the prompt strings in the captured output, it can also be easily done.
You just need to print them in the mock_input
function.
examples/python/mock-input/test_app_capsys_print.py
import app
def test_app(capsys):
input_values = [2, 3]
def mock_input(s):
print(s, end='')
return input_values.pop(0)
app.input = mock_input
app.main()
out, err = capsys.readouterr()
assert out == "".join([
'First: ',
'Second: ',
'The result is 5\n',
])
assert err == ''
Then we also need to include those strings in the expected output.
(We used end = ''
in the print
function inside the mock_input
function as that's how input
prints the prompt string.)
Try: pytest test_app_capsys_print.py
Warning
If you have all the 3 test files and the application in the same directory and you run pytest
it will run all 3 test files and then some of them will fail. This is because they run in the same process and we override parts of the app
in different ways in the different test files.
In a real application you would stick to one of the above solution for all of your test files and then this problem would not happen.