Skip to main content

Classes, Methods & Protocols

In this chapter, we'll learn about object-oriented programming in Python with a TDD approach. We'll cover:

  • Defining classes with class
  • Instance methods and the self parameter
  • The __init__ constructor
  • Python protocols (dunder methods)
  • Properties and encapsulation
  • Inheritance basics

Your first class

Let's build a Rectangle class using TDD.

Write the test first

Create test_shapes.py:

from shapes import Rectangle


def test_rectangle_area():
rectangle = Rectangle(10, 5)
got = rectangle.area()
want = 50
assert got == want

Make it compile

Create shapes.py:

class Rectangle:
pass

Run the test:

TypeError: Rectangle() takes no arguments

Add the constructor

class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height

The __init__ method is Python's constructor. It's called when you create a new instance. The self parameter refers to the instance being created.

Run the test:

AttributeError: 'Rectangle' object has no attribute 'area'

Add the area method

class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height

def area(self):
return self.width * self.height

The test passes!

Add more tests

def test_rectangle_perimeter():
rectangle = Rectangle(10, 5)
got = rectangle.perimeter()
want = 30
assert got == want

Implementation:

class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height

def area(self):
return self.width * self.height

def perimeter(self):
return 2 * (self.width + self.height)

Understanding self

self is the first parameter of every instance method. It's how methods access the instance's attributes:

class Dog:
def __init__(self, name):
self.name = name # Store name on the instance

def bark(self):
return f"{self.name} says woof!" # Access instance attribute

Test:

def test_dog_bark():
dog = Dog("Buddy")
got = dog.bark()
want = "Buddy says woof!"
assert got == want

Dunder methods (protocols)

Python uses special methods (called "dunder" methods for "double underscore") to define how objects behave with built-in operations.

__str__ - String representation

def test_rectangle_str():
rectangle = Rectangle(10, 5)
got = str(rectangle)
want = "Rectangle(10 x 5)"
assert got == want

Implementation:

class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height

def area(self):
return self.width * self.height

def perimeter(self):
return 2 * (self.width + self.height)

def __str__(self):
return f"Rectangle({self.width} x {self.height})"

__repr__ - Developer representation

__repr__ should return a string that could recreate the object:

def test_rectangle_repr():
rectangle = Rectangle(10, 5)
got = repr(rectangle)
want = "Rectangle(10, 5)"
assert got == want

Implementation:

def __repr__(self):
return f"Rectangle({self.width}, {self.height})"

__eq__ - Equality

def test_rectangle_equality():
r1 = Rectangle(10, 5)
r2 = Rectangle(10, 5)
r3 = Rectangle(5, 10)

assert r1 == r2
assert r1 != r3

Implementation:

def __eq__(self, other):
if not isinstance(other, Rectangle):
return NotImplemented
return self.width == other.width and self.height == other.height

__lt__ and comparison operators

For sorting, implement __lt__ (less than):

def test_rectangle_comparison():
r1 = Rectangle(10, 5) # area = 50
r2 = Rectangle(6, 6) # area = 36

assert r2 < r1 # Compare by area
assert sorted([r1, r2]) == [r2, r1]

Implementation:

def __lt__(self, other):
return self.area() < other.area()

With __lt__ and __eq__, Python can derive the other comparison operators.

__len__ - Length

class Playlist:
def __init__(self):
self.songs = []

def add(self, song):
self.songs.append(song)

def __len__(self):
return len(self.songs)

Test:

def test_playlist_length():
playlist = Playlist()
assert len(playlist) == 0

playlist.add("Song 1")
playlist.add("Song 2")
assert len(playlist) == 2

Properties

Properties let you control access to attributes:

class Circle:
def __init__(self, radius):
self._radius = radius # Convention: underscore means "private"

@property
def radius(self):
"""Get the radius."""
return self._radius

@radius.setter
def radius(self, value):
"""Set the radius, ensuring it's positive."""
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value

@property
def area(self):
"""Calculate area (read-only property)."""
import math
return math.pi * self._radius ** 2

Test:

import pytest
from shapes import Circle


def test_circle_radius():
circle = Circle(5)
assert circle.radius == 5

circle.radius = 10
assert circle.radius == 10


def test_circle_negative_radius():
circle = Circle(5)
with pytest.raises(ValueError):
circle.radius = -1


def test_circle_area():
import math
circle = Circle(5)
expected = math.pi * 25
assert circle.area == pytest.approx(expected)

Note: pytest.approx handles floating-point comparison.

Class attributes vs instance attributes

class Dog:
# Class attribute - shared by all instances
species = "Canis familiaris"

def __init__(self, name):
# Instance attribute - unique to each instance
self.name = name

Test:

def test_dog_attributes():
dog1 = Dog("Buddy")
dog2 = Dog("Max")

# Instance attributes are unique
assert dog1.name == "Buddy"
assert dog2.name == "Max"

# Class attribute is shared
assert dog1.species == "Canis familiaris"
assert dog2.species == "Canis familiaris"
assert Dog.species == "Canis familiaris"

Class methods and static methods

class Temperature:
def __init__(self, celsius):
self.celsius = celsius

@classmethod
def from_fahrenheit(cls, fahrenheit):
"""Create a Temperature from Fahrenheit."""
celsius = (fahrenheit - 32) * 5 / 9
return cls(celsius)

@staticmethod
def is_freezing(celsius):
"""Check if temperature is at or below freezing."""
return celsius <= 0

Test:

def test_temperature_from_fahrenheit():
temp = Temperature.from_fahrenheit(32)
assert temp.celsius == pytest.approx(0)


def test_is_freezing():
assert Temperature.is_freezing(0) is True
assert Temperature.is_freezing(-5) is True
assert Temperature.is_freezing(10) is False

Inheritance

Classes can inherit from other classes:

class Shape:
"""Base class for shapes."""

def area(self):
raise NotImplementedError("Subclasses must implement area()")

def perimeter(self):
raise NotImplementedError("Subclasses must implement perimeter()")


class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height

def area(self):
return self.width * self.height

def perimeter(self):
return 2 * (self.width + self.height)


class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side) # Call parent constructor

Test:

def test_square():
square = Square(5)
assert square.area() == 25
assert square.perimeter() == 20


def test_square_is_rectangle():
square = Square(5)
assert isinstance(square, Rectangle)
assert isinstance(square, Shape)

Composition over inheritance

Often, composition is preferable to inheritance:

class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower

def start(self):
return "Engine started"


class Car:
def __init__(self, engine):
self.engine = engine # Composition: Car HAS an Engine

def start(self):
return self.engine.start()

Test:

def test_car_with_engine():
engine = Engine(200)
car = Car(engine)

assert car.start() == "Engine started"
assert car.engine.horsepower == 200

A complete example: BankAccount

Let's build a BankAccount class with full TDD:

# test_bank.py
import pytest
from bank import BankAccount


def test_initial_balance():
account = BankAccount("Alice", 100)
assert account.balance == 100


def test_deposit():
account = BankAccount("Alice", 100)
account.deposit(50)
assert account.balance == 150


def test_withdraw():
account = BankAccount("Alice", 100)
account.withdraw(30)
assert account.balance == 70


def test_withdraw_insufficient_funds():
account = BankAccount("Alice", 100)
with pytest.raises(ValueError, match="Insufficient funds"):
account.withdraw(150)


def test_account_str():
account = BankAccount("Alice", 100)
assert str(account) == "BankAccount(Alice: $100.00)"

Implementation:

# bank.py
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance

@property
def balance(self):
return self._balance

def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount

def withdraw(self, amount):
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount

def __str__(self):
return f"BankAccount({self.owner}: ${self._balance:.2f})"

def __repr__(self):
return f"BankAccount({self.owner!r}, {self._balance})"

Wrapping up

We've covered:

  • Classes - Define with class ClassName:
  • __init__ - Constructor method
  • self - Reference to the current instance
  • Instance methods - Functions that operate on instance data
  • Dunder methods - __str__, __repr__, __eq__, __lt__, __len__
  • Properties - @property for controlled attribute access
  • Class vs instance attributes - Shared vs unique data
  • @classmethod and @staticmethod - Alternative constructors and utilities
  • Inheritance - Create specialized classes with class Child(Parent):
  • Composition - Building classes from other classes

TDD and classes

When testing classes:

  1. Test the constructor and initial state
  2. Test each method independently
  3. Test edge cases and error conditions
  4. Test how the class interacts with built-in functions (len, str, ==)

Classes are a powerful way to organize code. Combined with TDD, you can build robust, well-tested object-oriented systems.