Lesson 17: Testing, Style, and Code Quality
🎧 Listen to this lesson
Code that merely works is not finished. It must be tested (so future changes cannot silently break it), readable (the next reader is usually you, in three months, at midnight), and honest about its behaviour through names and structure. This lesson introduces pytest for automated tests, the test-first mindset, and the everyday disciplines of clean Python: meaningful names, docstrings, small functions, and consistent style following PEP 8, the community style guide.
Tests: executable proof and a safety net
A test is a small function that calls your code and asserts what must be true. If every assertion holds, the suite passes; change the code, re-run, and a red test tells you exactly which behaviour you just broke — before your users find out. The professional tool is pytest, installed into your virtual environment (Lesson 12) with pip install pytest. Suppose math_tools.py contains:
def add(a, b):
return a + b
def is_even(n):
return n % 2 == 0
def average(numbers):
if not numbers:
raise ValueError("average of empty list")
return sum(numbers) / len(numbers)
The tests live in test_math_tools.py — pytest discovers files and functions starting with test_ automatically:
from math_tools import add, is_even, average
import pytest
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
def test_is_even():
assert is_even(4)
assert not is_even(7)
def test_average():
assert average([10, 20, 30]) == 20
def test_average_empty():
with pytest.raises(ValueError):
average([])
Run pytest in that folder (session header trimmed) and a green run reports this output:
collected 4 items
test_math_tools.py .... [100%]
============================== 4 passed in 0.02s ===============================
Each dot is a passing test. Note test_average_empty: expected failures are tested too, with pytest.raises asserting the *kind* of error — error behaviour is behaviour. A test suite is also documentation that cannot go stale, because it executes: test_average teaches a new reader more about the function than three paragraphs of description, and it is guaranteed accurate today.
Worked example 1 — tests without pytest's magic
The mechanics need no framework: assertions plus a runner. This version completes the wallet example, with the rejection path tested using try/except instead of pytest's helper — the pattern matters more than the tool:
def add_funds(balance, amount):
"""Return the new balance after adding funds; reject non-positive amounts."""
if amount <= 0:
raise ValueError("amount must be positive")
return balance + amount
def test_add_funds_normal():
assert add_funds(50, 25) == 75
def test_add_funds_zero_rejected():
try:
add_funds(50, 0)
except ValueError:
return
raise AssertionError("expected ValueError for zero amount")
def run_tests():
tests = [test_add_funds_normal, test_add_funds_zero_rejected]
for test in tests:
test()
print("PASS", test.__name__)
print("All wallet tests passed.")
run_tests()
Output:
PASS test_add_funds_normal
PASS test_add_funds_zero_rejected
All wallet tests passed.
Note the structure of the rejection test: if add_funds *fails to raise*, execution falls past the except clause and raises AssertionError deliberately — a test that cannot fail is not a test. Under pytest, run_tests disappears (discovery replaces it) but the test functions themselves barely change. The professional habit to absorb now: when you fix a bug, add the test that would have caught it (Lesson 7's bug stories each deserve one). The suite then grows exactly where the code proved fragile.
Worked example 2 — a style makeover that changes nothing
Style is not decoration; it is comprehension speed. Both programs below compute identical results — run them and compare outputs:
def calc(a,b): x=a+b;y=a*b;return x,y
def ProcessData(d):
T=0
for i in d:T=T+i*2
return T
print(calc(3, 4))
print(ProcessData([5, 7, 5]))
def add_and_multiply(a, b):
"""Return (sum, product) of two numbers."""
return a + b, a * b
def doubled_total(numbers):
"""Return twice the sum of the numbers."""
return sum(n * 2 for n in numbers)
print(add_and_multiply(3, 4))
print(doubled_total([5, 7, 5]))
Both print (7, 12) and 34 — identical behaviour, verified by execution. The second version wins on every reading metric: names announce intent (doubled_total vs ProcessData — and PEP 8 wants snake_case for functions, PascalCase reserved for classes); one statement per line; docstrings state the contract; a generator expression replaces the manual accumulator from Lesson 5's pattern. The everyday PEP 8 essentials: 4-space indentation, spaces around operators, two blank lines between top-level functions (the tidy examples throughout this course follow these), lines kept short enough to read without scrolling, and no stray semicolons. Formatters like black and linters like ruff can enforce most of this automatically — professionals let tools carry the boring consistency and spend their attention on names and structure, which tools cannot judge.
Common mistakes
- One giant test that checks everything. When it fails you learn "something broke" — the suite's whole value is localisation. Many small tests, one behaviour each, with names that read like specifications (
test_average_empty). - Assertions without messages when the check is non-obvious.
assert result == expected, f"got {result}"turns a five-minute hunt into a five-second read — as the password tests below demonstrate. - Testing your mocks instead of your code, or never seeing a test fail. A suite you have never watched go red may be incapable of going red. Deliberately break the code once (flip a
+to-) and confirm a test catches it. - Leaving debug prints in finished code. Exploration prints belong to development; the deliverable uses logging (Lesson 14) at levels the user never sees by default.
- Names like
data2,temp,xfor lasting values. Short names are fine for loop counters and tiny scopes; anything that lives longer deserves its meaning in its name.
Real-World Challenge
The task. Build a password validator *test-first*: state the rules as tests, then implement until they pass. Rules: at least 8 characters, at least one digit, at least one uppercase letter. The function returns (ok, problems) so a signup form can tell the user exactly what to fix.
Worked solution.
def validate_password(pw):
"""Check a password against the house rules. Returns (ok, problems)."""
problems = []
if len(pw) < 8:
problems.append("at least 8 characters")
if not any(c.isdigit() for c in pw):
problems.append("at least one digit")
if not any(c.isupper() for c in pw):
problems.append("at least one uppercase letter")
return (not problems, problems)
def test_passwords():
cases = [
("sunshine", (False, ["at least one digit", "at least one uppercase letter"])),
("Sunshine1", (True, [])),
("Sun1", (False, ["at least 8 characters"])),
]
for pw, expected in cases:
result = validate_password(pw)
assert result == expected, f"{pw}: got {result}"
print("PASS", pw)
print("All password tests passed.")
test_passwords()
Output:
PASS sunshine
PASS Sunshine1
PASS Sun1
All password tests passed.
How it works. The test cases were chosen to probe the rules, not the happy path alone: "sunshine" is long enough but fails two rules — and the expected problems list exposes a real specification detail, namely the *order* messages accumulate (length, digit, uppercase), which a form displaying the first problem depends on. "Sun1" fails only on length while passing digit and uppercase, isolating one rule per failing case so a red test points at a single rule. The implementation collects *all* problems instead of failing at the first — a deliberate UX decision the tests make explicit — and any(c.isdigit() for c in pw) shows generator expressions earning their keep. Written under pytest, test_passwords would split into three one-case functions and drop the printing; the expectations would not change at all.
Extension. Add two rules — at least one lowercase letter and no spaces — with a failing test for each written *before* the implementation changes. Then check your test quality the Lesson 7 way: temporarily delete the digit check and confirm exactly the digit-expecting cases go red.
Going further — code review, and refactoring toward lookup tables
Tests prove behaviour; review improves the code carrying it. A practical self-review checklist, in order: Does it pass its tests, including the rejection paths? Do the names read like the domain (could a classmate narrate this function aloud)? Is any block doing two jobs that deserve two functions? Are the edge cases — empty, zero, missing, huge — handled where they enter? Is anything duplicated that will drift apart when edited? The last item has a favourite cure: when a function is a thicket of if/elif branches each returning a value for a known key, the branches are a table begging to be a dictionary:
def points_for(grade):
"""Grade points via lookup instead of a branch chain."""
table = {"A": 4.0, "B": 3.0, "C": 2.0, "D": 1.0, "F": 0.0}
return table.get(grade, 0.0)
print(points_for("B"), points_for("X"))
Output: 3.0 0.0 — a known grade resolves through the table, and an unknown one falls to the .get default instead of crashing, a policy decision (lenient versus strict) made explicitly in one place. Refactoring like this is safe *because* the tests exist: change the implementation, re-run the suite, and identical green results certify identical behaviour. That loop — test, refactor, re-test — is the daily rhythm of professional maintenance, and it is why this course taught tests before style: style without a safety net is just risk with better typography. Review your own capstone (Lesson 20) this way before calling it finished; the checklist above is its marking scheme.
Practice corner — names that answer questions
A reviewer's favourite micro-improvement costs nothing: name boolean functions and variables as questions — is_valid, has_paid, can_edit — so conditions read as English and negations stop producing double-negative puzzles:
def is_valid(age):
return age >= 18
print([is_valid(a) for a in (16, 18, 21)])
Output: [False, True, True] — and the call sites read if is_valid(age):, a sentence, not a puzzle. The same principle scales: collections get plural nouns (expenses, scores), single values singular ones, and anything measured carries its unit when confusion is possible (timeout_seconds, price_cents — the money lesson from the capstone). Rename aggressively while code is young; your tools make it safe when tests exist, and every future reader — starting with next-month you — collects the dividend.
🎬 Watch & Learn
Prefer video? These searches surface top tutorials on this lesson's topic:
- pytest in Python — write your first tests (Tech With Tim style) — pick a recent, well-reviewed video
- Corey Schafer: Python code style with PEP 8 — pick a recent, well-reviewed video
- freeCodeCamp: testing & code quality in Python — pick a recent, well-reviewed video
💪 Practice Zone
Try these yourself — hints are included, answers are not. Run your code in any Python environment.
- Three pytest tests — Write
is_even(n), then write three test functions: an even number, an odd number, and zero.Hint
Name the filetest_<something>.pyand each testtest_...; run withpytest -v. - Style cleanup — Rewrite this messy snippet with PEP 8 style (spacing, names, blank lines) — behavior must stay identical:
def f(a,b):
x=a+b
return x*2Hint
Two blank lines around top-level functions, spaces after commas, and a descriptive name likedouble_sum. - Docstring duty — Pick three functions you wrote this month and add proper docstrings (what it does, its parameters, what it returns).
Hint
Triple-quoted string right under thedefline — see the Applied Track example.
🌍 Real World
Tech companies run thousands of pytest-style tests automatically on every code change — a failing test blocks the release. Code review culture (style, docstrings, clear names) is what lets hundreds of programmers collaborate without chaos. These habits are what separate hobby code from professional code.