The stdlib has unittest. pytest is a third-party library that does the same job with vastly less ceremony. No TestCase base class, no self.assertEqual — just functions starting with test_ that use plain assert. The output on failure is rich and readable. Modern Python projects (including Pippa) almost always use pytest.
The basic shape
A file named test_foo.py with functions starting with test_. Inside each function, plain assert statements. Run pytest in the project directory and it discovers + runs them all. pytest path/to/file.py::test_name runs a specific test.
Fixtures — setup/teardown done elegantly
@pytest.fixture declares a setup function. Tests that take an argument with the fixture's name receive its return value. The fixture can yield instead of return for setup-then-teardown shape. Fixtures are reused across tests with smart caching based on scope (scope="function", "module", "session").
parametrize — same test, many inputs
@pytest.mark.parametrize("a,b,expected", [(1, 2, 3), (5, 5, 10)]) runs the test once per row. Each row becomes a separate test in the output (test_add[1-2-3]). Reduces "copy and paste this test 5 times with different inputs" to one decorator.
Async tests — pytest-asyncio
Async test functions need the pytest-asyncio plugin (or pytest-anyio). After installation, mark the test with @pytest.mark.asyncio (or configure auto mode). Pippa's tests use this — every async function in the codebase has tests that exercise it through real async paths.
import pytest
import tempfile
from pathlib import Path
@pytest.fixture
def temp_file():
# Setup
f = tempfile.NamedTemporaryFile(delete=False, suffix=".txt")
f.write(b"hello")
f.close()
path = Path(f.name)
yield path # provided to the test
# Teardown — runs after the test, even if it failed
path.unlink(missing_ok=True)
def test_read(temp_file):
content = temp_file.read_text()
assert content == "hello"
# Fixtures by name — pytest matches the parameter to the fixture
parametrize — many inputs, one test·python
import pytest
def divide(a, b):
return a / b
@pytest.mark.parametrize("a,b,expected", [
(10, 2, 5),
(9, 3, 3),
(1, 4, 0.25),
(-6, 2, -3),
])
def test_divide(a, b, expected):
assert divide(a, b) == expected
# Each row runs as a separate test:
# test_divide[10-2-5] PASSED
# test_divide[9-3-3] PASSED
# test_divide[1-4-0.25] PASSED
# test_divide[-6-2--3] PASSED
# Adding a 5th input is one line
Testing exceptions — pytest.raises·python
import pytest
def divide(a, b):
if b == 0:
raise ZeroDivisionError("can't")
return a / b
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError, match="can't"):
divide(10, 0)
# raises also works to inspect the exception
def test_with_inspection():
with pytest.raises(ValueError) as exc_info:
raise ValueError("specific message")
assert "specific" in str(exc_info.value)
Async tests — pytest-asyncio·python
# pip install pytest-asyncio
#
# In pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto" # auto-detect async tests
import pytest
import asyncio
async def fetch(name):
await asyncio.sleep(0.01)
return f"got {name}"
@pytest.mark.asyncio # or auto mode picks it up
async def test_fetch():
result = await fetch("foo")
assert result == "got foo"
@pytest.mark.asyncio
async def test_concurrent():
a, b = await asyncio.gather(fetch("a"), fetch("b"))
assert a == "got a"
assert b == "got b"
Write a small calculator module with add, subtract, multiply, divide (raises ZeroDivisionError on b=0). Then write test_calculator.py with: (a) a basic test for each, (b) a parametrized test that hits add/subtract/multiply with at least 5 input combinations each, (c) a pytest.raises test for the divide-by-zero, (d) a fixture that pre-builds a list of 100 random number pairs and a test that runs all four operations on them.
Progress
Progress is local-only — sign in to sync across devices.