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.
| Command | Does |
|---|---|
n | Next line |
s | Step into |
c | Continue |
r | Run to the end of this function |
p expr | Print an expression |
pp expr | Pretty-print |
l | List the surrounding source |
w | Where — the call stack |
q | Quit |
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:
| Rule | Catches |
|---|---|
B006 | Mutable default argument |
B008 | Function call in a default argument |
B902 | Wrong first argument on a method |
B904 | raise 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
| Test | Do not test |
|---|---|
| Fee balance, including cancelled payments | Getters and setters |
| Pass/fail with absent handling | The standard library |
| Attendance percentage | Third-party libraries |
| Roll-number uniqueness | Framework behaviour |
| Validation and boundaries | Trivial one-line functions |
| Error paths | Implementation 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
| Message | Cause | Fix |
|---|---|---|
pytest collects nothing | Files or functions not named test_* | Rename them |
ModuleNotFoundError in tests only | Project root not on the path | Add __init__.py, or configure pytest |
| A test passes whatever the code does | Asserts the implementation, not the requirement | Rewrite from the requirement |
| Tests pass alone, fail together | Shared state between tests | Isolate with fixtures |
| Coverage is high and bugs still ship | Coverage measures lines run, not cases checked | Test 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
Mockaccepting a typo'd assertion silently- Chasing a coverage percentage
printdebugging instead of a debugger- Debug prints left in committed code
- Deferring
mypy --strictuntil later - A bare
# type: ignorewith 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.
- Install pytest and write three tests for
calculate_percentage. - Name a test
test_grade_2, then rename it to describe the behaviour. Break the code and compare the two failure reports. - Parametrise
get_gradeacross every boundary — 89, 90, 79, 80, 34, 35. - Introduce an off-by-one at a boundary and confirm exactly which case fails.
- Parametrise the result label across marks 0 with
is_absent=FalseandNonewithis_absent=True. Break the absent handling and confirm the failure. - Write a fixture returning sample students. Mutate the list in one test and confirm the next test is unaffected.
- Use
tmp_pathfor a CSV import test. Then write to a fixed path and run the suite twice. - Write a
yieldfixture with teardown. Fail the test deliberately and confirm teardown still runs. - Test three exceptions with
pytest.raises, one usingmatchand one asserting a structured attribute. - Write a
FakeStudentRepositoryand test a service with it. Assert onrepository.saved. - Use
Mockand assert a method that does not exist. Confirm it passes. - Use
monkeypatch.setenv. Confirm the variable is restored afterwards. - Add
breakpoint()in a loop and inspect state withp. Then set a conditional breakpoint in VS Code for one student. - Run
pytest --pdbon a failing test. - Run
mypy --strictand fix every error. Add a function returningX | Noneand use it without a check. - Enable ruff with the
Brules. Add a mutable default argument and confirmB006catches it. - Set up pre-commit and try to commit unformatted code.
- Run coverage with
term-missingand 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
pytestfinds them - Use fixtures to isolate tests
- Prove a test fails without its fix
- Say why coverage is not correctness
Review questions
- Why is a parametrised test better than several near-identical ones?
- Why does
tmp_pathmatter for file tests? - Why is a hand-written fake often safer than a
Mock? - Why is a high coverage percentage not evidence of good tests?
Next: Databases and APIs