pytest
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.pywill run only the tests in that file. -vwill be more verbose, printing out more information about each test-vvwill print out even more information about each test, particularly useful when comparing expected output vs. actual-swill include any output fromprintstatements (normally suppressed bypytest).-k <pattern>will only run tests whose names match the pattern. (e.g.pytest -k databaseto only run tests that contain the phrase database)-xwill stop running tests after the first failure, this can speed things up if a lot of tests are failing.--pdbwill 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”:
FalseNone0# int0.0# float0j# 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) == expectedThis 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"]