Skip to content

Latest commit

 

History

History
255 lines (184 loc) · 7.16 KB

File metadata and controls

255 lines (184 loc) · 7.16 KB
layout default
title Errors, Logging, and Testing
parent Lessons
nav_order 9
permalink /lessons/errors-logging-testing/
course_lesson true
course_index 09
previous_page /lessons/files-serialization/
previous_title Files and Serialization
next_page /lessons/regular-expressions/
next_title Regular Expressions

09 - Errors, Logging, and Testing

Reliable software makes failures visible and checks important behavior automatically.

A simple picture

An exception is an emergency signal moving up through function calls until suitable code handles it. A log is the application's diary. A test is a repeatable experiment that checks whether behavior still matches its promise.

Exceptions and boundaries

Raise specific exceptions inside business logic and handle them at a boundary such as a CLI or API handler. Do not silently catch everything.

class InsufficientBalanceError(ValueError):
    """Raised when an account cannot complete a withdrawal."""


def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError("Amount must be positive")
    if amount > balance:
        raise InsufficientBalanceError("Not enough balance")
    return balance - amount

The domain function raises meaningful errors. A user-interface boundary translates them into messages:

try:
    updated_balance = withdraw(500, 700)
except InsufficientBalanceError as error:
    print(f"Withdrawal failed: {error}")

Catch the narrowest useful exception. except Exception: is appropriate only at carefully designed boundaries where the error is logged and the program has a recovery policy.

else, finally, and exception chaining

try:
    number = int(raw_value)
except ValueError as error:
    raise ValueError("quantity must be an integer") from error
else:
    print("Conversion succeeded")
finally:
    print("Conversion attempt finished")
  • else runs only when the try block succeeds;
  • finally runs whether success or failure occurs;
  • raise ... from error keeps the original cause visible.

Do not use exceptions for ordinary branching when a simple condition communicates the rule better.

Logging

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Application started")

Logs should explain what happened without exposing passwords, tokens, or personal data.

Logging levels

Level Typical meaning
DEBUG detailed diagnostic information
INFO normal important event
WARNING unexpected situation with continued operation
ERROR operation failed
CRITICAL application may be unable to continue

Use placeholder arguments instead of building a string before the logger knows whether it needs the message:

logger.info("Loaded %s student records", record_count)

Inside an exception handler, logger.exception(...) includes the traceback:

try:
    process_file(path)
except OSError:
    logger.exception("Could not process file %s", path)
    raise

Never log passwords, tokens, payment details, or unnecessary personal data.

Assertions and tests

An assert states a programmer assumption. Python can remove assertions when run with optimization, so do not use them to validate user input, authorization, or other required runtime rules.

Tests may use assertions because a failed test should report a mismatch.

def add(a, b):
    return a + b


def test_add():
    assert add(2, 3) == 5

A runnable standard-library test

import unittest


class WithdrawTests(unittest.TestCase):
    def test_withdraw_reduces_balance(self):
        result = withdraw(500, 200)
        self.assertEqual(result, 300)

    def test_withdraw_rejects_large_amount(self):
        with self.assertRaises(InsufficientBalanceError):
            withdraw(500, 700)


if __name__ == "__main__":
    unittest.main()

Run it with python test_account.py. Third-party frameworks such as pytest offer concise syntax and fixtures, but understanding test structure matters more than the framework.

Arrange, act, assert

A readable test has three stages:

  1. Arrange the inputs and dependencies.
  2. Act by calling one behavior.
  3. Assert the observable result.

Test normal values, boundaries, invalid input, and important failure paths. Avoid testing private implementation details.

Fakes and dependency injection

class FakeSender:
    def __init__(self):
        self.sent = []

    def send(self, message):
        self.sent.append(message)

A small fake is often easier to understand than a complex mock. Inject it into the service and assert what the service asked it to do.

Bug Hunter

Bug 1: every error is hidden

try:
    save_report()
except Exception:
    pass

Bug 2: user validation relies on assert

def transfer(amount):
    assert amount > 0

Bug 3: a test checks implementation instead of behavior

def test_cart():
    assert cart._items == ["pen"]
Show Bug Hunter fixes
  1. Catch only expected errors, log useful safe context, and either recover or re-raise.
  2. Raise ValueError or a domain exception because runtime validation must always execute.
  3. Check public behavior such as cart.total_items() or iteration unless _items itself is the promised interface.

Practice

  1. Define a custom exception.
  2. Catch an exception at a CLI boundary.
  3. Add useful logging levels.
  4. Remove a secret from a log message.
  5. Write a test for a pure function.
  6. Test an invalid input path.
  7. Use a fixture or setup helper.
  8. Mock an external dependency.
  9. Explain assertion versus exception handling.
  10. Add tests to an earlier project.
Show hints
  1. Subclass Exception.
  2. Keep the service focused on raising or returning domain errors.
  3. Use debug, info, warning, and error intentionally.
  4. Log identifiers, not credentials.
  5. Choose one input and expected output.
  6. Test the error or recovery behavior, not only the happy path.
  7. Share setup without hiding what the test needs.
  8. Replace the dependency with a predictable fake.
  9. Assertions catch programmer assumptions; exceptions handle runtime situations.
  10. Test business rules before integration details.
Show solution ideas
  1. class InvalidExpense(ValueError): pass.
  2. Catch it where you can show a user-friendly message.
  3. Configure a named logger and appropriate level.
  4. Replace the secret with a safe identifier or omit it.
  5. Assert the public behavior of the function.
  6. Use pytest.raises(...) or assertRaises.
  7. Use a fixture for repeated test data.
  8. Inject a fake client that returns known data.
  9. Use assertions during development; handle expected runtime errors explicitly.
  10. Add tests for normal, boundary, invalid, and failure cases.

Homework

Add structured logging and a test suite to the expense service. Include at least one test for each important failure mode.

Checkpoint

Explain where exceptions should be raised, where they should be handled, and what a useful log message contains.