Skip to main content
Published / updated

Testing, Debugging and Quality

Before you start

You need: OOP (Article 08) and modules (Article 07).

Time: about 50 minutes, plus the practice.

Learning objective

Write tests that catch real defects, debug with a debugger rather than guesswork, and keep quality enforced by tools rather than discipline.

Topics

  • Why test
  • pytest basics
  • Fixtures
  • Parametrised tests
  • Testing exceptions
  • Test doubles
  • The debugger
  • Type checking
  • Linting and formatting
  • Pre-commit

Why test

A test is a program that proves another program works, and it keeps proving it after every change.

The value is not writing it once — it is the regression check. Six months later someone changes the grading rule, and a test failing on the absent-student case stops a wrong result sheet reaching a real student.

Test what would be expensive to get wrong: the fee balance, the pass/fail rule, the attendance percentage. Do not test getters, setters or the standard library.

pytest

pip install pytest
# tests/test_calculations.py
from school.calculations import calculate_percentage, get_grade


def test_calculate_percentage_returns_correct_value():
assert calculate_percentage(87, 100) == 87.0


def test_calculate_percentage_handles_partial_marks():
assert calculate_percentage(45, 60) == 75.0


def test_get_grade_returns_a_plus_for_ninety():
assert get_grade(90) == "A+"
pytest
pytest -v # one line per test
pytest tests/test_calculations.py
pytest -k "percentage" # tests whose name matches
pytest -x # stop at the first failure
pytest --lf # rerun only last-failed

Discovery is by convention: files named test_*.py, functions named test_*, classes named Test*. No decorators, no base class.

pytest uses the plain assert statement and rewrites it to produce a useful message:

E assert 86.0 == 87.0
E + where 86.0 = calculate_percentage(87, 101)

unittest is in the standard library and works; pytest is what most projects use, and its fixtures and parametrisation are the reason.

Naming

def test_absent_student_is_not_marked_failed():
...

def test_payment_exceeding_balance_is_rejected():
...

The test name is the specification. A failing test_absent_student_is_not_marked_failed tells you what broke without opening the file; test_grade_2 does not.

Arrange, Act, Assert

def test_recording_a_payment_reduces_the_outstanding_balance():
# Arrange
account = FeeAccount(total_fees=Decimal("50000"), discount=Decimal("5000"))

# Act
account.record_payment(Decimal("8000"))

# Assert
assert account.outstanding == Decimal("37000")

One behaviour per test. A test asserting six unrelated things fails on the first, hiding the other five.

Comparing floats needs a tolerance:

from pytest import approx

assert calculate_percentage(1, 3) == approx(33.333, rel=1e-3)

0.1 + 0.2 == 0.3 is False, so a direct float comparison in a test fails for a correct implementation.

Fixtures

import pytest


@pytest.fixture
def sample_students() -> list[Student]:
return [
Student(name="Ravi Kumar", roll_number="NCA-2024-0012", class_name="10th", marks=87),
Student(name="Priya Sharma", roll_number="NCA-2024-0018", class_name="10th", marks=91),
Student(name="Arjun Reddy", roll_number="NCA-2024-0031", class_name="9th", marks=65)
]


def test_filter_by_class_returns_only_matching_students(sample_students):
result = filter_by_class(sample_students, "10th")

assert len(result) == 2

A fixture is requested by naming it as a parameter. It runs fresh for each test, so one test cannot corrupt another's data.

@pytest.fixture
def temporary_csv(tmp_path) -> Path:
path = tmp_path / "students.csv"
path.write_text("RollNumber,Name\nNCA-2024-0012,Ravi Kumar\n", encoding="utf-8")
return path


def test_import_reads_every_row(temporary_csv):
result = import_students(temporary_csv)

assert len(result.students) == 1

tmp_path is built in: a fresh directory per test, cleaned up afterwards. Never write test files into the project directory — they leak between runs and end up committed.

@pytest.fixture
def database_connection():
connection = create_connection(":memory:")
create_schema(connection)

yield connection # the test runs here

connection.close() # teardown, even if the test failed

Code after yield is teardown. It runs on failure too, so a connection is always closed.

@pytest.fixture(scope="session")
def expensive_resource():
...

scope="session" creates it once for the whole run. Use it only for genuinely expensive read-only setup — a shared mutable fixture makes tests order-dependent, and an order-dependent suite is worse than no suite.

Fixtures in tests/conftest.py are available to every test file without importing.

Parametrised tests

@pytest.mark.parametrize(
"percentage,expected",
[
(95, "A+"),
(90, "A+"),
(89, "A"),
(80, "A"),
(79, "B"),
(34, "Fail"),
(0, "Fail")
]
)
def test_get_grade(percentage, expected):
assert get_grade(percentage) == expected

Seven test cases, one function. Each runs separately and reports separately, so a failure names the exact input:

FAILED test_get_grade[89-A]

Boundary values are where bugs live. 89 and 90 matter far more than 50 — the off-by-one at a grade boundary is the defect this catches.

@pytest.mark.parametrize(
"marks,is_absent,expected",
[
(87, False, "Pass"),
(20, False, "Fail"),
(None, True, "Absent"),
(0, False, "Fail") # a genuine zero is NOT absent
]
)
def test_result_label(marks, is_absent, expected):
result = ExamResult(marks_obtained=marks, is_absent=is_absent)

assert get_result_label(result, passing_marks=35) == expected

The last two rows are the whole point: a student who scored zero and a student who was absent must not produce the same label.

Testing exceptions

def test_zero_max_marks_raises():
with pytest.raises(ValueError):
calculate_percentage(87, 0)


def test_error_message_names_the_value():
with pytest.raises(ValueError, match="max_marks must be positive"):
calculate_percentage(87, 0)


def test_overpayment_carries_the_amounts():
account = FeeAccount(total_fees=Decimal("50000"))

with pytest.raises(PaymentExceedsBalanceError) as exception_info:
account.record_payment(Decimal("60000"))

assert exception_info.value.outstanding == Decimal("50000")

pytest.raises fails the test if the exception is not raised, which is what makes it a test rather than a try/except.

match is a regular expression against the message. Asserting on the structured attributes is better than on the message text — messages change.

@pytest.mark.parametrize("raw", ["", "abc", "-1", "101"])
def test_invalid_marks_are_rejected(raw):
with pytest.raises(ValueError):
parse_marks(raw, max_marks=100)

Test doubles

class FakeStudentRepository:
def __init__(self, students: list[Student] | None = None) -> None:
self.students = students or []
self.saved: list[Student] = []

def find_by_roll_number(self, roll_number: str) -> Student | None:
return next((s for s in self.students if s.roll_number == roll_number), None)

def save(self, student: Student) -> None:
self.saved.append(student)


def test_creating_a_duplicate_roll_number_is_rejected():
repository = FakeStudentRepository([
Student(name="Ravi Kumar", roll_number="NCA-2024-0012")
])
service = StudentService(repository)

with pytest.raises(DuplicateRollNumberError):
service.create(name="Someone Else", roll_number="NCA-2024-0012")

assert repository.saved == []

A hand-written fake is usually clearer than a mock library. It is real code, it type-checks, and asserting on repository.saved is more readable than asserting on call arguments.

from unittest.mock import Mock

def test_notification_is_sent_on_enrolment():
notifier = Mock()
service = EnrolmentService(repository=FakeStudentRepository(), notifier=notifier)

service.enrol(valid_request())

notifier.send.assert_called_once()

Mock suits verifying that something was called. It also happily accepts any attribute or method, so a typo in an assertion passes silently — which is why a fake is safer for anything with real behaviour.

def test_uses_the_configured_api_url(monkeypatch):
monkeypatch.setenv("API_URL", "https://test.example.com")

assert get_api_url() == "https://test.example.com"

monkeypatch replaces environment variables, attributes and dictionary entries, and undoes it after the test — unlike setting them by hand, which leaks into every later test.

Do not mock what you own and can construct. Mocking your own Student class tests the mock, not the code.

The debugger

def calculate_total(results):
total = 0

for result in results:
print(f"result: {result}, total: {total}") # the slow way
total += result.marks

return total

print debugging requires editing, running, reading and then removing the lines — and forgetting to remove them is how debug output reaches production.

breakpoint()

One line, and Python drops into pdb at that point.

CommandDoes
nNext line
sStep into
cContinue
rRun to the end of this function
p exprPrint an expression
pp exprPretty-print
lList the surrounding source
wWhere — the call stack
qQuit

p evaluates any expression, so you can inspect and test hypotheses without editing the file.

In VS Code, click the gutter to set a breakpoint and press F5. The Variables pane shows every local, Watch evaluates expressions, and the Call Stack shows how you arrived.

A conditional breakpoint is the tool for a loop over four hundred students: right-click the breakpoint and enter student.roll_number == "NCA-2024-0012" to stop on one.

pytest --pdb # drop into the debugger on failure

That is the fastest way to investigate a failing test — you land at the assertion with every local available.

Learn the debugger before print. Ten minutes with it replaces an hour of adding and removing print statements, and it shows every variable rather than the ones you thought to print.

Type checking

pip install mypy
mypy src/
def find_student(roll_number: str) -> Student | None:
...


def display(roll_number: str) -> str:
student = find_student(roll_number)
return student.name # error: Item "None" has no attribute "name"

mypy catches this before it runs. AttributeError: 'NoneType' object has no attribute is the most common Python runtime error, and a type checker removes most instances of it.

def display(roll_number: str) -> str:
student = find_student(roll_number)

if student is None:
return "Not found"

return student.name # narrowed to Student
[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_ignores = true

Enable strict on a new project from the first commit. Turning it on later produces hundreds of errors at once and the work gets abandoned.

For an existing codebase, enable it per module:

[[tool.mypy.overrides]]
module = "school.calculations"
strict = true
value = untyped_library_call() # type: ignore[no-any-return]

# type: ignore silences one line. Always give the specific error code — a bare ignore hides future errors on that line too.

Linting and formatting

pip install black ruff

black src/ tests/
ruff check src/ tests/
ruff check --fix src/

Black reformats to one canonical style. It is not configurable beyond line length, which is the point — formatting stops being a discussion and diffs stop containing whitespace changes.

Ruff replaces flake8, isort, pyupgrade and several others, and runs in milliseconds.

[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
select = [
"E", # pycodestyle
"F", # pyflakes — undefined names, unused imports
"I", # isort — import order
"UP", # pyupgrade — modern syntax
"B", # bugbear — likely bugs
"SIM", # simplify
"RUF" # ruff-specific
]

The B rules are the ones that catch real defects:

RuleCatches
B006Mutable default argument
B008Function call in a default argument
B902Wrong first argument on a method
B904raise inside except without from

B006 alone justifies enabling ruff — it catches Python's most famous gotcha automatically.

Pre-commit

pip install pre-commit
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.10.0
hooks:
- id: mypy
additional_dependencies: [types-requests]

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: detect-private-key
pre-commit install
pre-commit run --all-files

Hooks run automatically on git commit and block it on failure. Quality stops depending on anyone remembering.

detect-private-key is worth having on its own — it prevents the incident where a key reaches a repository and has to be rotated.

Coverage

pip install pytest-cov
pytest --cov=school --cov-report=term-missing
Name Stmts Miss Cover Missing
---------------------------------------------------------
school/calculations.py 24 2 92% 31-32
school/data/students.py 48 12 75% 67-71, 88-94

--cov-report=term-missing shows which lines are untested, which is the useful part. The percentage alone is not.

Coverage measures what ran, not what was verified. A test calling a function with no assertion counts as covered.

Chasing a coverage number produces bad tests. Use it to find untested branches — an error path with no test is worth knowing about; a getter without one is not.

What to test

TestDo not test
Fee balance, including cancelled paymentsGetters and setters
Pass/fail with absent handlingThe standard library
Attendance percentageThird-party libraries
Roll-number uniquenessFramework behaviour
Validation and boundariesTrivial one-line functions
Error pathsImplementation details
Anything that has broken before

A test reproducing a fixed bug is the highest-value test there is. It proves the fix and stops the regression.

def test_absent_student_is_not_counted_in_the_average():
"""Regression: issue #142 — absent students were counted as zero."""
results = [
ExamResult(marks_obtained=80, is_absent=False),
ExamResult(marks_obtained=None, is_absent=True),
ExamResult(marks_obtained=60, is_absent=False)
]

assert calculate_average(results) == 70.0 # not 46.67

The docstring naming the issue tells the next reader why the test exists — which stops someone deleting it as redundant.

Errors you will hit

MessageCauseFix
pytest collects nothingFiles or functions not named test_*Rename them
ModuleNotFoundError in tests onlyProject root not on the pathAdd __init__.py, or configure pytest
A test passes whatever the code doesAsserts the implementation, not the requirementRewrite from the requirement
Tests pass alone, fail togetherShared state between testsIsolate with fixtures
Coverage is high and bugs still shipCoverage measures lines run, not cases checkedTest the edge cases

Comment out the fix and confirm the test fails. A test that passes either way tests nothing, and coverage will not tell you.

Common mistakes

  • No tests on money or grading logic
  • Test names that describe nothing
  • Several unrelated assertions in one test
  • Tests depending on each other's state
  • Writing test files into the project directory instead of tmp_path
  • Comparing floats without approx
  • Mocking your own classes
  • Mock accepting a typo'd assertion silently
  • Chasing a coverage percentage
  • print debugging instead of a debugger
  • Debug prints left in committed code
  • Deferring mypy --strict until later
  • A bare # type: ignore with no error code
  • No pre-commit, so quality depends on memory

Practice

The course exercises are write a debugging report and create a regression checklist.

  1. Install pytest and write three tests for calculate_percentage.
  2. Name a test test_grade_2, then rename it to describe the behaviour. Break the code and compare the two failure reports.
  3. Parametrise get_grade across every boundary — 89, 90, 79, 80, 34, 35.
  4. Introduce an off-by-one at a boundary and confirm exactly which case fails.
  5. Parametrise the result label across marks 0 with is_absent=False and None with is_absent=True. Break the absent handling and confirm the failure.
  6. Write a fixture returning sample students. Mutate the list in one test and confirm the next test is unaffected.
  7. Use tmp_path for a CSV import test. Then write to a fixed path and run the suite twice.
  8. Write a yield fixture with teardown. Fail the test deliberately and confirm teardown still runs.
  9. Test three exceptions with pytest.raises, one using match and one asserting a structured attribute.
  10. Write a FakeStudentRepository and test a service with it. Assert on repository.saved.
  11. Use Mock and assert a method that does not exist. Confirm it passes.
  12. Use monkeypatch.setenv. Confirm the variable is restored afterwards.
  13. Add breakpoint() in a loop and inspect state with p. Then set a conditional breakpoint in VS Code for one student.
  14. Run pytest --pdb on a failing test.
  15. Run mypy --strict and fix every error. Add a function returning X | None and use it without a check.
  16. Enable ruff with the B rules. Add a mutable default argument and confirm B006 catches it.
  17. Set up pre-commit and try to commit unformatted code.
  18. Run coverage with term-missing and write a test for one uncovered error path.

Exercises 5 and 16 correspond to two real defects — a wrong result sheet and a shared mutable default.

You can now

  • Write tests that catch real defects
  • Name files and functions so pytest finds them
  • Use fixtures to isolate tests
  • Prove a test fails without its fix
  • Say why coverage is not correctness

Review questions

  1. Why is a parametrised test better than several near-identical ones?
  2. Why does tmp_path matter for file tests?
  3. Why is a hand-written fake often safer than a Mock?
  4. Why is a high coverage percentage not evidence of good tests?

Next: Databases and APIs