Advanced Unit Testing #
You have already been introduced to basic testing with Pytest and some strategies you can use to write clear, purposeful unit tests.
Testing Functions that Print with capsys
#
By this point, you’ve probably written a few functions or methods that call
print
to produce some sort of output. How do you go about testing if it
prints the correct thing?
You can test these functions using Pytest’s capsys
. In essence, capsys
lets
you test functions which have the side effect of printing to the screen.
Here’s a basic demonstration of capsys
in action:
def test_print_example(capsys): # Test function takes capsys as a parameter
"""
Test that print prints text.
"""
# Run a function that prints as a side effect
print("Hello, World!")
# capsys has been collecting all things that have been printed. Assign
# the capsys to a local variable in the test function.
captured = capsys.readouterr()
# We can access the output via captured.out
assert captured.out == "Hello, World!\n" # Print appends a newline
Testing User Input with monkeypatch
#
monkeypatch
is a powerful and incredibly useful Pytest feature. It is also
very easily abused. It is used for what is often called “mocking” or
“monkeypatching”. It allows you to emulate certain features like standard
input, web servers, databases, etc.
Specifically, monkeypatch can be used to redefine existing functions with one of your own, changing its behavior. The function you define must take the same number of positional arguments as the one you are replacing. This exists only within the scope of a singular test case.
For example, let’s say we have the following function that reads input from a user and “translates” it into Pig Latin:
def pig_latin():
"""
Read user input and turn it into Pig Latin.
Uses the most basic definition of Pig Latin: move the first letter of the
word to the end of the word, then append "a".
"""
words = input("ivega ema aa entencesa: ").split()
pig_words = [word[1:] + word[0] + "a" for word in words]
return " ".join(pig_words)
We can test this by mocking the input
function from builtins
.
builtins
is always
imported, and contains every function you can call without additional imports.
If you want to know what module a function is from, evaluate help(myfunc)
in
Python. Using help(input)
as an example, you will see Help on built-in function input in module builtins:
.
input
returns a string representing a given input; we can monkeypatch it by
replacing it with a function that returns a predefined string.
def test_pig_latin(monkeypatch):
"""
Test that pig_latin correctly translates user input to Pig Latin.
Works by patching builtins.input
"""
def mock_input(_):
"""
Pretend to be a human behind a keyboard.
args:
_: A string representing the prompt positional argument given
to input.
"""
return "the quick brown fox jumps over the lazy dog\n"
# Replace one function with another in the context of this test.
monkeypatch.setattr("builtins.input", mock_input)
assert pig_latin() == "heta uicka rownba oxfa umpsja veroa heta azyla ogda"
This is a really simple example of using monkeypatch to mimic input. In cases like this, monkeypatch is probably the correct tool for writing tests.
Mocking Libraries #
However, when it comes to mimicking more complicated things (such as requests from a web server), you are better off using an external “mocking” library.
For example, let’s say you are using the Requests library to make GET requests from an API. You shouldn’t write your unit tests by directly requesting an API. Here are some reasons:
- It’s rude. Your unit tests (and unit tests get run a lot) pulling from the actual API imposes a cost on the server. Testing with the real API is considered bad internet etiquette.
- Because it imposes a cost on a server, there is a chance your API key will get blocked for repeatedly making the same request.
- Many APIs will limit how many requests you are allowed to make in a timeframe. Directly using the API in your tests contributes to your limit.
- Running your tests will depend on having an internet connection. In addition, if you have a poor internet connection, running your tests can take an extended period of time.
Instead, you are better off by using a library like Responses to mock Requests.
Here’s a sample test using Responses:
import pytest
import responses
import requests
from responses import matchers
@responses.activate
def test_responses_example():
responses.get(
"https://youtube.com/watch"
body="Totally the video you were looking for",
match=[matchers.query_param_matcher({"v": "dQw4w9WgXcQ"})]
)
resp = requests.get("https://youtube.com/watch",
params={"v": "dQw4w9WgXcQ"})
assert resp.text == "Totally the video you were looking for"
Let’s explain this bit by bit.
@responses.activate
is a decorator that modifies the behavior of any calls
from the Requests library inside the function definition.
The call to responses.get
pre-defines a response for a GET web request made
in the decorated function. The first argument is the URL you are defining a GET
response for. responses.get
also accept many optional arguments that you will
want to use. body
is a parameter that represents the content of
the response, in plain text. You can also use the json
parameter instead of
body
to specify a dictionary that will be converted to JSON data. match
allows you to specify different responses to the same URL depending on content
such as parameters. matchers.query_param_matcher
let’s us define a parameter
dictionary akin to making a request via Requests.
If you attempt to make a request in your test that is not predefined for
Responses, a Requests ConnectionError
will be raised.