C.W.K.
Stream
Lesson 01 of 05 · published

pytest — Tests Done Right

~22 min · pytest, testing, fixture, parametrize

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete

pytest beats unittest by miles

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.

Code

Bare-bones test file·python
# test_math.py
def add(a, b):
    return a + b

def test_add_basic():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, 1) == 0

def test_add_floats():
    result = add(0.1, 0.2)
    assert abs(result - 0.3) < 1e-10        # float comparison

# Run: pytest test_math.py
# Output:
# test_math.py::test_add_basic PASSED
# test_math.py::test_add_negative PASSED
# test_math.py::test_add_floats PASSED
Fixtures — setup with yield for teardown·python
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"

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.