Prerequisite: Design Patterns · Object-Oriented Programming


Code is read far more often than it is written. A rough estimate in software teams is ten reads for every one write - across code review, debugging, onboarding, and future maintenance. Writing code that is easy to read, easy to change, and easy to verify is not a luxury; it is the primary job.

Naming

Good names are the cheapest form of documentation. A well-named function or variable tells you what it does without you having to read the body.

# Unclear
def calc(x, y, z):
    return x * y * (1 - z)

# Clear
def compute_discounted_price(unit_price: float, quantity: int, discount_rate: float) -> float:
    return unit_price * quantity * (1 - discount_rate)

Rules of thumb: use full words (not abbreviations), name booleans as assertions (is_valid, has_permission), name functions as verb phrases (calculate_tax, fetch_user), and name collections in the plural (users, orders).

Functions

A function should do one thing. If you find yourself writing “and” in a function’s name or docstring, that is a signal to split it.

# Doing too much: fetches AND parses AND writes
def process_report(url, filename):
    response = requests.get(url)
    data = parse_json(response.text)
    with open(filename, "w") as f:
        json.dump(data, f)

# Separated responsibilities
def fetch_report(url: str) -> dict:
    response = requests.get(url)
    return parse_json(response.text)

def save_report(data: dict, filename: str) -> None:
    with open(filename, "w") as f:
        json.dump(data, f)

Keep functions short enough to see on screen (roughly 20–30 lines as a soft guide). Prefer pure functions - those with no side effects and whose output depends only on their input - because they are trivial to test and reason about.

DRY vs Premature Abstraction

DRY (Don’t Repeat Yourself) says that each piece of knowledge should have a single, unambiguous representation. If you copy-paste a block of logic three times, extract it into a function.

But the tension is premature abstraction: over-engineering a generalisation before you understand the actual pattern. A common heuristic: wait until you have three concrete instances of a pattern before abstracting it. Two occurrences might be coincidental.

Comments: Explain Why, Not What

Code shows what is happening. Comments should explain why - the business rule, the historical reason, the non-obvious constraint.

# Bad: restates the code
i += 1  # increment i by 1

# Good: explains the non-obvious reason
# Skip the header row which is always index 0
for row in csv_rows[1:]:
    process(row)

# Good: documents a known limitation or workaround
# Using a list instead of a set here because insertion order matters
# for the downstream renderer (see issue #412)
ordered_items = []

If a comment explains what the code does, consider whether clearer code would eliminate the need for the comment at all.

Type Hints

Python’s optional type hints make function signatures self-documenting and enable static analysis tools like mypy to catch type errors before runtime.

def send_email(
    recipient: str,
    subject: str,
    body: str,
    cc: list[str] | None = None,
) -> bool:
    ...

You do not need to annotate every variable - focus on function signatures and complex data structures. Run mypy in CI to catch regressions:

pip install mypy
mypy my_module.py

Unit Testing with pytest

A unit test checks one small, isolated behaviour. The Arrange-Act-Assert pattern gives each test a clear structure:

# calculator.py
class Calculator:
    def add(self, a: float, b: float) -> float:
        return a + b

    def divide(self, a: float, b: float) -> float:
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b
# test_calculator.py
import pytest
from calculator import Calculator

class TestCalculator:
    def setup_method(self):
        self.calc = Calculator()  # Arrange (shared setup)

    def test_add_two_positives(self):
        result = self.calc.add(3, 4)   # Act
        assert result == 7              # Assert

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

    def test_divide_by_zero_raises(self):
        with pytest.raises(ValueError, match="divide by zero"):
            self.calc.divide(10, 0)

    def test_divide_normal(self):
        assert self.calc.divide(10, 2) == 5.0

Run tests with pytest from the command line. Each test function tests one behaviour. If a test fails, the name should tell you exactly what broke.

Mocking External Dependencies

Tests should be fast and deterministic. External calls (databases, APIs, file systems) are neither. Use unittest.mock.patch to replace them:

from unittest.mock import patch, MagicMock
from my_module import fetch_user_data

def test_fetch_user_data_success():
    mock_response = MagicMock()
    mock_response.json.return_value = {"id": 1, "name": "alice"}
    mock_response.status_code = 200

    with patch("my_module.requests.get", return_value=mock_response):
        result = fetch_user_data(user_id=1)

    assert result["name"] == "alice"

The real HTTP call never happens. The test is instant and works without a network connection.

Test Coverage

Coverage measures which lines of code were executed during your test suite. Aim for high coverage of critical paths - business logic, edge cases, error handling - rather than chasing 100%.

pip install pytest-cov
pytest --cov=my_module --cov-report=term-missing

Lines marked as missing are untested. Prioritise the ones that contain branching logic or error-prone transformations. A function that just returns a constant does not need a dedicated test.

SOLID Principles (Briefly)

These five principles guide object-oriented design toward maintainable code:

  • Single Responsibility: a class should have one reason to change. UserAuth handles authentication; UserEmailSender handles email - not one class doing both.
  • Open/Closed: open for extension, closed for modification. Add new behaviour by subclassing or composing, not by editing existing code.
  • Liskov Substitution: subclasses should be substitutable for their parent class without breaking the program.
  • Interface Segregation: prefer small, focused interfaces over large ones. Do not force a class to implement methods it does not need.
  • Dependency Inversion: depend on abstractions, not concrete implementations. Pass a storage object into a function rather than hard-coding open() calls inside it.

You do not need to apply all five perfectly in every project. They are heuristics, not rules. Learn to recognise when violating one is creating friction.

Examples

Refactor a Messy Function

# Before: hard to read, multiple responsibilities, magic numbers
def proc(d):
    r = []
    for x in d:
        if x["s"] == 1 and x["a"] >= 18:
            r.append({"n": x["n"], "e": x["e"]})
    return r

# After: clear names, single responsibility, explicit intent
def get_active_adult_users(users: list[dict]) -> list[dict]:
    """Return name and email for active users aged 18 or over."""
    ACTIVE_STATUS = 1
    ADULT_AGE = 18

    return [
        {"name": user["name"], "email": user["email"]}
        for user in users
        if user["status"] == ACTIVE_STATUS and user["age"] >= ADULT_AGE
    ]

Unit Tests for a Calculator Class

# Extended test cases showing edge cases and error conditions
class TestCalculatorEdgeCases:
    def setup_method(self):
        self.calc = Calculator()

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

    def test_divide_returns_float(self):
        result = self.calc.divide(7, 2)
        assert result == 3.5
        assert isinstance(result, float)

    def test_divide_by_zero_message(self):
        with pytest.raises(ValueError) as exc_info:
            self.calc.divide(5, 0)
        assert "zero" in str(exc_info.value).lower()

Writing clean, testable code is a practice that pays dividends over time. The habits - clear naming, small focused functions, tests that describe behaviour, logging instead of printing - compound as a codebase grows. Start with the most critical paths, add tests as you fix bugs, and refactor opportunistically when you understand a piece of code better than when it was first written.


Read Next: Debugging and Profiling · How Computers Execute Programs