Adrian Dane

Python 30‑by‑30 Course

Module 5: Object-Oriented Programming, Testing, and Debugging

This week, we're learning the skills of a true software craftsperson. We'll explore a powerful way of thinking called Object-Oriented Programming (OOP), and then learn the essential practices of testing and debugging to make sure our code is robust, reliable, and easy to maintain.

Contents

Quick Recap of Module 4

In Module 4, we learned how to make our programs interact with the outside world:

  • File I/O: We used the with open(...) statement to safely read from and write to text files.
  • CSV Files: We used Python's built-in csv module to read and write spreadsheet data, using DictReader to access data by column name.
  • JSON and APIs: We learned how to parse JSON, the language of the web, and understood how to fetch live data from web APIs.
  • Command-Line Tools: We leveled up our scripts by using the argparse module to accept arguments directly from the command line, making them more powerful and automatable.
  • Report Generation: We combined all these skills to turn raw data into a clean, formatted, human-readable report.

Day 21: Blueprints for Your Code (Classes)

Objectives

So far, we've worked with data (like strings and numbers) and actions (functions) separately. Object-Oriented Programming (OOP) is a powerful way to bundle them together. We do this by creating a class, which acts as a blueprint for creating "objects." Think of a BankAccount class: it would hold data like the owner's name and the balance, but it would also have actions like deposit() and withdraw().

The __init__ method is a special function that acts as the constructor or setup instructions. It runs automatically whenever you create a new object from the class. Inside the class, you'll see the self keyword everywhere. This is simply how an object refers to its own attributes and methods. Once you've defined your blueprint, you can create as many instances (individual objects) as you want, each with its own separate data.

This approach helps organize complex programs by modeling real-world things. Instead of a dozen loose variables, you have a single player object that knows its own score, position, and inventory.

🤯 Classes, Objects, Instances... What's the difference?

A class is a cookie cutter. 🍪

  • It's the blueprint. It defines the shape of the cookie and what it's made of (its data, or attributes).
  • An object (or instance) is an actual cookie you make with the cutter.
  • You can use one cutter (class) to make many cookies (instances).
  • Each cookie is separate. You can add sprinkles to one (acct1.balance = 500) without changing another.

OOP is just a way to design your code around these "cookie" objects, each of which manages its own data and can perform its own actions.

Practice ✍️

Design a Dog class. The __init__ method should take a name and breed. The class should have a bark() method that prints "Woof!". Create two different Dog instances and call their bark() methods.

Click to reveal a sample class
# dog_class.py
class Dog:
    def __init__(self, name, breed):
        """Initializes a new Dog instance."""
        self.name = name
        self.breed = breed
    
    def bark(self):
        """Makes the dog bark."""
        print(f"{self.name} says: Woof!")

# Create two instances (objects) of the Dog class
dog1 = Dog("Fido", "Golden Retriever")
dog2 = Dog("Lucy", "Poodle")

# Call methods on each instance
dog1.bark()
dog2.bark()
        

Day 22: Building on Your Blueprints (Inheritance & Composition)

Objectives

Once you have a class, you'll often want to create a new one that is a more specialized version of it. Inheritance lets you do this. You can create a subclass that inherits all the attributes and methods of a parent class. This models an "is-a" relationship. For example, a GuideDog *is a* special kind of Dog that has all the normal dog behaviors plus a new guide() method.

Another way to build complex objects is composition. This is where an object is built from other, smaller objects. This models a "has-a" relationship. For example, a Car object isn't a type of engine, but it *has an* Engine object inside of it. The Car class can then delegate tasks, like car.start() might call self.engine.ignite().

Which one should you use? A good rule of thumb is to "favor composition over inheritance." Composition is often more flexible. You can easily swap out a Car's engine without changing the Car class, but you can't easily change a GuideDog's "parent" class. Both are powerful tools for building well-structured code.

🤯 Inheritance vs. Composition? Let's use two analogies.

Inheritance is like family traits. 👨‍👩‍👧

  • A Cat *is an* Animal. It inherits the basic animal features (like eat() and sleep()) but can add its own special behaviors (purr()) or change existing ones (its speak() method returns "Meow"). It's for creating specialized versions of a base type.

Composition is like building with Lego. 🧱

  • A Car is not a special type of wheel. A Car *has* Wheel objects, an Engine object, and Seat objects. You build a big, complex thing by putting together smaller, independent parts. This is usually more flexible—it's easier to swap the Lego engine than to change a cat's species!

Practice ✍️

Create a base class Character with name and health attributes. Then, create two subclasses, Hero and Villain, that inherit from Character. Give the Hero class an inspire() method and the Villain class a cackle() method.

Click to see sample classes
# character_inheritance.py
class Character:
    def __init__(self, name, health=100):
        self.name = name
        self.health = health
    
    def introduce(self):
        print(f"I am {self.name}.")

class Hero(Character):
    def inspire(self):
        print(f"{self.name} shouts: For glory!")

class Villain(Character):
    def cackle(self):
        print(f"{self.name} cackles: Mwahahaha!")

# Create instances
hero = Hero("Aragorn")
villain = Villain("Sauron", 500)

hero.introduce()
hero.inspire()

villain.introduce()
villain.cackle()
        

Day 23: Your Code's Safety Net (Unit Testing)

Objectives

How do you know if your code actually works? You could run it and check the output manually, but that's slow and error-prone. A much better way is to write unit tests—small, automated scripts whose only job is to check that one tiny piece (a "unit") of your code works as expected. This creates a safety net, allowing you to change and improve your code with confidence, knowing your tests will catch you if you break something.

Python's built-in unittest module is a great tool for this. You create a test class and write methods that start with test_. Inside these methods, you call your code and then make assertions. An assertion is a statement that declares something must be true. For example, self.assertEqual(add(2, 2), 4) asserts that the result of add(2, 2) should be equal to 4. If it's not, the test fails, and you're immediately alerted to the problem.

Writing tests might feel like extra work at first, but it pays off enormously. It forces you to think clearly about what your code is supposed to do and is the hallmark of a professional developer.

🤯 This seems like a lot of extra work. Why bother?

Unit testing is like being a car mechanic checking each part individually. 🔧

You wouldn't build a whole car and then, only at the very end, turn the key and hope it works. A good mechanic tests each system along the way:

  • "Does the brake light turn on when the pedal is pressed?" (This is one unit test.)
  • "Do the windshield wipers move when I flip the switch?" (This is another unit test.)
  • "Does the radio tune to the correct station?" (A third unit test.)

If all these small, individual tests pass, you have very high confidence that when you finally test the whole car, it will work correctly. Your test suite is your automated quality assurance team.

Practice ✍️

Let's write a test for a simple function. First, create a function is_even(n) that returns True if a number is even and False otherwise. Then, write a unittest test case with at least two test methods: one to check a few even numbers, and another to check a few odd numbers.

Click to view sample tests and helper functions
# functions.py (the code we want to test)
def is_even(n):
    return n % 2 == 0

# ---------------------------------------------
# test_functions.py (the test script)
import unittest
from functions import is_even

class TestEvenFunction(unittest.TestCase):
    def test_even_numbers(self):
        """Test that even numbers are correctly identified."""
        self.assertTrue(is_even(2))
        self.assertTrue(is_even(0))
        self.assertTrue(is_even(-4))

    def test_odd_numbers(self):
        """Test that odd numbers are correctly identified."""
        self.assertFalse(is_even(1))
        self.assertFalse(is_even(7))
        self.assertFalse(is_even(-3))

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

Day 24: The Art of Detective Work (Debugging & Logging)

Objectives

Bugs are a normal part of programming. The skill is in finding them efficiently! When your program crashes, Python gives you a traceback, which is a map of exactly where the error happened. Learning to read this from the bottom up is the first step in your detective work.

Sometimes, the error isn't a crash but just wrong behavior. You could add print() statements everywhere to see what's going on, but a much more powerful tool is a debugger. Python's built-in debugger, pdb, lets you set a breakpoint in your code. When your program hits that point, it pauses, and you get a command prompt where you can inspect the values of variables, step through your code line by line, and see exactly what's happening. It's like a superpower for finding bugs.

For programs that run for a long time (like a web server), you can't always be there to debug them. This is where logging comes in. The logging module lets you record messages from your program to the console or a file. You can record informational messages, warnings, or critical errors. This creates a detailed diary of your program's life, which is essential for diagnosing problems that happened in the past.

🤯 Debugging vs. Logging? What's the difference?

Debugging with pdb is like having a remote control that can pause live TV. ⏸️

  • When something weird happens on screen, you can hit pause (pdb.set_trace()).
  • While paused, you can look around the scene (inspect variables) and advance frame-by-frame (step through code) to see exactly what's going on. It's an interactive, live investigation.

Logging is like installing security cameras. 📹

  • You can't be watching your program 24/7. Logging records everything that happens.
  • If something goes wrong while you're away (e.g., your program crashes on a server at 3 AM), you can't pause it live. But you can go back and review the log files (the security footage) to see the sequence of events that led to the problem.

Practice ✍️

Take a simple function that divides two numbers. Use the logging module to log the inputs it receives and the result it's about to return. Add a check for division by zero and log an ERROR level message if it happens.

Click to see a sample debugging and logging setup
# logging_example.py
import logging

# Configure logging to show INFO level messages and above
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

def divide(a, b):
    logging.info(f"Attempting to divide {a} by {b}")
    if b == 0:
        logging.error("Division by zero attempted!")
        return None
    
    result = a / b
    logging.info(f"Calculation successful. Result: {result}")
    return result

# Run some examples
divide(10, 2)
divide(5, 0)
divide(100, 10)
        

Day 25: Tidying Your Workshop (Refactoring)

Objectives

Great code isn't just about what it does; it's also about how clean and well-organized it is. Refactoring is the process of improving the internal structure of your code *without* changing its external behavior. It's like tidying up your workshop—you're not building a new thing, you're just organizing your tools and materials so it's easier to work there in the future.

We refactor when we spot "code smells"—signs that the code could be cleaner. Common smells include duplicated code, functions that are way too long, or classes that are trying to do too many different jobs. The fix is often simple: extract a piece of code into its own function, rename a variable to be clearer, or break a big class into smaller, more focused ones.

As you get more experienced, you'll start to see common problems and common solutions. These reusable solutions are called design patterns. They are like proven recipes from expert chefs that you can adapt to your own cooking. Knowing a few basic patterns can help you structure your code in a robust and elegant way from the start.

🤯 What's the point of refactoring if the code already works?

Refactoring is like tidying your kitchen after cooking a big meal. 🧹

  • The meal you cooked (your program's output) is delicious and it works perfectly.
  • But the kitchen (your source code) is a total mess! There are dirty dishes everywhere, spices are out of order, and you can't find anything.
  • Refactoring is the process of cleaning the counters, washing the dishes, and putting everything back in its proper place.

Why do it? Because the *next time* you need to cook a meal (add a new feature or fix a bug), you'll be able to work in a clean, organized space. It makes future work faster, easier, and less likely to cause another mess.

Practice ✍️

Look back at the report-generating script you wrote on Day 20. Can you refactor it? Identify the three main parts: 1) reading the data, 2) aggregating the data, and 3) formatting the report. Break the code into three separate functions, each with a single, clear responsibility.

Click to see a sample refactor
# refactored_report.py
import csv

def read_score_data(filename):
    """Reads data from a CSV file and returns a list of dictionaries."""
    with open(filename, 'r', newline='') as f:
        return list(csv.DictReader(f))

def analyze_scores(data):
    """Takes score data and returns a dictionary of summary stats."""
    scores = [int(row['score']) for row in data]
    analysis = {
        'player_count': len(data),
        'average_score': sum(scores) / len(scores),
        'high_score': max(scores)
    }
    return analysis

def format_report(stats):
    """Takes analysis stats and returns a formatted string report."""
    return (
        f"--- Gaming Session Report ---\n"
        f"Date: 2025-08-29\n"
        f"-----------------------------\n"
        f"Total Players: {stats['player_count']}\n"
        f"Average Score: {stats['average_score']:.0f}\n"
        f"High Score: {stats['high_score']} points!\n"
    )

# --- Main script logic ---
try:
    score_data = read_score_data('scores.csv')
    statistics = analyze_scores(score_data)
    report = format_report(statistics)
    print(report)
except (FileNotFoundError, ZeroDivisionError):
    print("Could not generate report. Check scores.csv.")
        

Further resources