pytest

Note

This a quick primer on using pytest for class.

See pytest’s docs for more details.

CLI Tips

  • Passing a filename like pytest tests/test_markov.py will run only the tests in that file.
  • -v will be more verbose, printing out more information about each test
  • -vv will print out even more information about each test, particularly useful when comparing expected output vs. actual
  • -s will include any output from print statements (normally suppressed by pytest).
  • -k <pattern> will only run tests whose names match the pattern. (e.g. pytest -k database to only run tests that contain the phrase database)
  • -x will stop running tests after the first failure, this can speed things up if a lot of tests are failing.
  • --pdb will open the debugger when you run into an issue, allowing more examination.

Writing Tests

When you run pytest, it will look for files named test_*.py in the current directory and its subdirectories. It will then run any functions in those files that start with test_.

Simple Example

# my_module.py
from collections import namedtuple

Point = namedtuple('Point', ['x', 'y'])

def circle_contains(radius: float, center: Point, point: Point):
    return (point.x - center.x) ** 2 + (point.y - center.y) ** 2 <= radius ** 2

def points_within(radius: float, center: Point, points: list[Point]):
    """ Find all points within a circle. """
    return [point for point in points if circle_contains(radius, center, point)]
# test_my_module.py

from my_module import circle_contains, points_within

origin = Point(0, 0)

def test_circle_contains():
    # centered at origin, radius 1
    assert circle_contains(1, origin, origin)
    assert circle_contains(1, origin, Point(.5, .5))

def test_circle_contains_edge():
    assert circle_contains(1, origin, Point(1, 0))  # on the circle

def test_circle_contains_outside():
    assert not circle_contains(1, origin, Point(1.1, 0))

Now running pytest would run the test function and report the results.

assert

The assert statement is used to check that a condition is true.

If the condition is True, nothing happens. If the condition is False, an AssertionError is raised.

You can also provide a message to be printed if the assertion fails:

assert 1 == 2, "1 is not equal to 2"

Note: assert is not a function. Using parentheses leads to confusing results because the parentheses are interpreted as a tuple.

assert(1 == 2, "1 is not equal to 2")

# This is equivalent to:
assert (1 == 2, "1 is not equal to 2")

Aside: Truthiness

In Python, every type has an implicit conversion to a boolean value. This is called “truthiness”.

The following values are considered “falsey”:

  • False
  • None
  • 0 # int
  • 0.0 # float
  • 0j # complex
  • “” # empty string
  • [] # empty list
  • () # empty tuple
  • {} # empty dict
  • set() # empty set

All other values are considered True.

values = [False, None, 0, 0.0, 0j, "", [], (), {}, set()]
values += [True, 42, 3.14, "hello", [1, 2, 3], {"a": 1}]
for value in values:
    # notice we're using the value as a boolean expression here
    if value:
        print(f"{value} is True")
    else:
        print(f"{value} is False")

pytest Features

pytest.fixture

Used to provide pre-configured data or resources like a database connection to multiple tests to reduce redundant code.

import pytest

@pytest.fixture
def user_list()
    return [
        {"name": "alice", "id": 1, "email": "alice@domain"},
        {"name": "carol", "id": 3, "email": "carol@domain"},
        {"name": "bob", "id": 2, "email": "bob@domain"},
        {"name": "diego", "id": 4, "email": "diego@otherdomain"},
    ]

def test_sort_users(user_list):
    sorted_list = sort_users(user_list)
    assert sorted_list == [
        {"name": "alice", "id": 1, "email": "alice@domain"},
        {"name": "bob", "id": 2, "email": "bob@domain"},
        {"name": "carol", "id": 3, "email": "carol@domain"},
        {"name": "diego", "id": 4, "email": "diego@otherdomain"},
    ]

def test_filter_users(user_list):
    filtered_list = filter_users(user_list, domain="domain")
    assert filtered_list == [
        {"name": "alice", "id": 1, "email": "alice@domain"},
        {"name": "bob", "id": 2, "email": "bob@domain"},
        {"name": "carol", "id": 3, "email": "carol@domain"},
    ]

pytest.raises

It’s often desirable to test that certain errors were raised. pytest.raises can be used to test that a function raises an exception.

def test_reject_invalid_domain():
    with pytest.raises(ValueError):
        validate_email("alice@invalid$")

Parameterized Tests

Sometimes the same test needs to be run with different inputs.

# could have written this as three separate tests with some copying and pasting
#  notice the application of the "none, one, some" rule
@pytest.mark.parametrize("str1,str2,expected", [
    ("abc", "abd", 1),
    ("abc", "abc", 0),
    ("abc", "xyz", 3),
])
def test_hamming_distance(str1, str2, expected):
    assert hamming_distance(str1, str2) == expected

This will run as 3 separate tests, allowing you to distinguish between failures.

mocking

The idea behind mocking is to isolate the behavior of the function you want to test from errors that might stem from other functions it depends upon.

It can also be used to provide test data to a difficult-to-test function.

unittest.mock provides, among other things, a Mock object which can be used to replace functions with dummy functions that return arbitrary data. You can also check how these functions were called if you want to discover if the mocked function was called with the correct parameters.

See https://docs.python.org/3/library/unittest.mock.html for more.

I recommend using it with pytest-mock installed, which adds a mocker fixture that makes this much easier.

Example

from unittest.mock import Mock

# a straightforward function that seems hard to test
# since you can't control what the API will return
def get_popular_bookmarks(n):
    response = httpx.get(BOOKMARKS_URL)
    bookmarks = response.json()["bookmarks"]
    # ... calculate most popular N bookmarks ... 
    return most_popular

# this parameter "mocker" comes from pytest-mock
# it is a fixture, like the one in the above example, so by
# adding the parameter pytest passes in a special object
# that aids in simplifying mocking
def test_get_popular_bookmarks_simple_one(mocker):
    mock_response = Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {"bookmarks": 
        [
            {"url": "https://example.com"},
      ],
    }
    # used here to patch httpx.get
    mocker.patch("httpx.get", return_value=mock_response)

    result = get_popular_bookmarks(1)
    assert result == ["https://example.com"]