IntermediatePython · Lesson 9 of 9

Testing with pytest

Write automated tests with plain assert statements, fixtures and parametrize.

pytest is the standard Python test tool. Tests are plain functions whose names start with test_, in files named test_*.py, using ordinary assert statements. Run them all with pytest.

pytest.raises checks that code raises the right exception. @pytest.mark.parametrize runs one test with many inputs — perfect for boundaries.

Fixtures provide ready-made test data or resources. pytest's built-in tmp_path fixture gives each test its own temporary folder.

grading.pyPython
def grade_for(score: float) -> str:
    if not 0 <= score <= 100:
        raise ValueError("score must be between 0 and 100")
    if score >= 75:
        return "A"
    if score >= 65:
        return "B"
    if score >= 45:
        return "C"
    if score >= 30:
        return "D"
    return "F"
test_grading.pyPython
import pytest

from grading import grade_for


@pytest.mark.parametrize(
    ("score", "expected"),
    [(100, "A"), (75, "A"), (74, "B"), (65, "B"), (45, "C"), (30, "D"), (29, "F"), (0, "F")],
)
def test_grade_boundaries(score: float, expected: str) -> None:
    assert grade_for(score) == expected


@pytest.mark.parametrize("bad", [-1, 101])
def test_rejects_out_of_range(bad: float) -> None:
    with pytest.raises(ValueError, match="between 0 and 100"):
        grade_for(bad)


def test_report_file(tmp_path) -> None:
    report = tmp_path / "report.txt"
    report.write_text(grade_for(88), encoding="utf-8")
    assert report.read_text(encoding="utf-8") == "A"
TerminalShell
pip install pytest
pytest -q

Key points

  • Plain assert + functions named test_* — no boilerplate.
  • Parametrize boundary values instead of copy-pasting tests.
  • Use tmp_path for file tests so nothing touches real data.

Exercise

Write pytest tests for your parse_line function from the Exceptions lesson: valid lines, each kind of invalid line (using pytest.raises), and extra whitespace around values.

Show solution

Try the exercise yourself first — then compare your approach with this one.

One parametrized test covers the valid lines (including extra spaces), and another uses pytest.raises(..., match=...) to check that each kind of invalid line fails with the right message.

parsing.pyPython
class InvalidLineError(Exception):
    pass


def parse_line(line: str) -> tuple[str, int]:
    if "," not in line:
        raise InvalidLineError("missing comma")
    name, _, score_text = line.partition(",")
    name = name.strip()
    if not name:
        raise InvalidLineError("empty name")
    try:
        score = int(score_text)
    except ValueError as err:
        raise InvalidLineError("score is not a whole number") from err
    if not 0 <= score <= 100:
        raise InvalidLineError("score out of range")
    return name, score
test_parsing.pyPython
import pytest

from parsing import InvalidLineError, parse_line


@pytest.mark.parametrize(
    ("line", "expected"),
    [
        ("Amina,88", ("Amina", 88)),
        ("  Juma ,  42 ", ("Juma", 42)),
        ("Neema,0", ("Neema", 0)),
        ("Ali,100", ("Ali", 100)),
    ],
)
def test_valid_lines(line: str, expected: tuple[str, int]) -> None:
    assert parse_line(line) == expected


@pytest.mark.parametrize(
    ("line", "message"),
    [
        ("Amina 88", "missing comma"),
        (" ,70", "empty name"),
        ("Neema,abc", "not a whole number"),
        ("Ali,101", "out of range"),
        ("Ali,-1", "out of range"),
    ],
)
def test_invalid_lines(line: str, message: str) -> None:
    with pytest.raises(InvalidLineError, match=message):
        parse_line(line)

Check your understanding

  1. How does pytest find your tests?

  2. What does @pytest.mark.parametrize do?

  3. Which checks that code raises a ValueError whose message contains "range"?

  4. What does the built-in tmp_path fixture provide?

Ask AI