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.
Self-reference: In CWK, a passing unit suite says the source-level contract survived. Shipping still requires the installed application, real service, and browser route to work. pytest is the fast regression net, not the production gate.
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.