Advanced Unit Testing

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.