Testing Strategies
Testing object-oriented code taught me that good design and testability go hand in hand. When I first started writing classes, I’d create these tightly coupled monsters that were impossible to test in isolation. Every test required setting up a dozen dependencies, and failures were cryptic because so many things were happening at once.
The breakthrough came when I learned about dependency injection and mocking. Suddenly, I could test individual classes in isolation, verify interactions between objects, and catch bugs that would have been nearly impossible to find otherwise. Testing became a design tool that guided me toward cleaner, more maintainable code.
Unit Testing Class Behavior
Testing classes effectively requires understanding what you’re actually testing. You’re not just testing methods—you’re testing the behavior and contracts that your classes provide. This means focusing on public interfaces, state changes, and interactions with dependencies:
import unittest
from datetime import datetime
class BankAccount:
def __init__(self, account_number, initial_balance=0, overdraft_limit=0):
self.account_number = account_number
self.balance = initial_balance
self.overdraft_limit = overdraft_limit
self.transaction_history = []
self._locked = False
def deposit(self, amount):
if self._locked:
raise ValueError("Account is locked")
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
self._record_transaction("deposit", amount)
return self.balance
def withdraw(self, amount):
if self._locked:
raise ValueError("Account is locked")
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
available_balance = self.balance + self.overdraft_limit
if amount > available_balance:
raise ValueError("Insufficient funds")
self.balance -= amount
self._record_transaction("withdrawal", amount)
return self.balance
def lock_account(self):
self._locked = True
def unlock_account(self):
self._locked = False
def _record_transaction(self, transaction_type, amount):
self.transaction_history.append({
'type': transaction_type,
'amount': amount,
'timestamp': datetime.now(),
'balance_after': self.balance
})
This BankAccount class demonstrates the key elements that make classes testable: clear public methods, predictable state changes, and well-defined error conditions. The private _record_transaction
method handles internal bookkeeping, while the public methods provide the interface that tests will verify.
Now let’s look at how to test this class effectively:
class TestBankAccount(unittest.TestCase):
def setUp(self):
"""Set up test fixtures before each test method"""
self.account = BankAccount("12345", initial_balance=1000)
def test_initial_state(self):
"""Test that account is created with correct initial state"""
self.assertEqual(self.account.account_number, "12345")
self.assertEqual(self.account.balance, 1000)
self.assertEqual(self.account.overdraft_limit, 0)
self.assertFalse(self.account._locked)
self.assertEqual(len(self.account.transaction_history), 0)
def test_successful_deposit(self):
"""Test successful deposit operation"""
new_balance = self.account.deposit(500)
self.assertEqual(new_balance, 1500)
self.assertEqual(self.account.balance, 1500)
self.assertEqual(len(self.account.transaction_history), 1)
transaction = self.account.transaction_history[0]
self.assertEqual(transaction['type'], 'deposit')
self.assertEqual(transaction['amount'], 500)
def test_deposit_validation(self):
"""Test deposit input validation"""
with self.assertRaises(ValueError) as context:
self.account.deposit(-100)
self.assertIn("positive", str(context.exception))
# Balance should remain unchanged after failed deposits
self.assertEqual(self.account.balance, 1000)
def test_successful_withdrawal(self):
"""Test successful withdrawal operation"""
new_balance = self.account.withdraw(300)
self.assertEqual(new_balance, 700)
self.assertEqual(len(self.account.transaction_history), 1)
def test_insufficient_funds(self):
"""Test withdrawal with insufficient funds"""
with self.assertRaises(ValueError) as context:
self.account.withdraw(1500)
self.assertIn("Insufficient funds", str(context.exception))
self.assertEqual(self.account.balance, 1000) # Unchanged
def test_locked_account_operations(self):
"""Test that locked accounts prevent operations"""
self.account.lock_account()
with self.assertRaises(ValueError):
self.account.deposit(100)
# Unlocking should restore functionality
self.account.unlock_account()
self.account.deposit(100)
self.assertEqual(self.account.balance, 1100)
The key to effective unit testing is focusing on behavior rather than implementation details. Each test should verify a specific aspect of the class’s contract—what it promises to do under certain conditions. Notice how the tests check both successful operations and error conditions, ensuring the class behaves correctly in all scenarios.
Mocking Dependencies and External Services
Real applications rarely work in isolation—they interact with databases, APIs, file systems, and other external services. Mocking lets you test your classes without depending on these external systems.
Let’s start with a simple email service that depends on an external API:
import requests
from unittest.mock import Mock, patch
class EmailService:
def __init__(self, api_key, base_url="https://api.emailservice.com"):
self.api_key = api_key
self.base_url = base_url
def send_email(self, to_email, subject, body):
response = requests.post(
f"{self.base_url}/send",
headers={"Authorization": f"Bearer {self.api_key}"},
json={"to": to_email, "subject": subject, "body": body}
)
if response.status_code == 200:
return response.json()["message_id"]
else:
raise Exception(f"Failed to send email: {response.text}")
This service encapsulates the complexity of interacting with an external email API. Now let’s build a higher-level service that uses it:
class NotificationManager:
def __init__(self, email_service):
self.email_service = email_service
self.notification_log = []
def send_welcome_email(self, user_email, username):
subject = "Welcome to Our Service!"
body = f"Hello {username},\n\nWelcome to our service!"
try:
message_id = self.email_service.send_email(user_email, subject, body)
self.notification_log.append({
'type': 'welcome_email',
'recipient': user_email,
'message_id': message_id,
'status': 'sent'
})
return message_id
except Exception as e:
self.notification_log.append({
'type': 'welcome_email',
'recipient': user_email,
'error': str(e),
'status': 'failed'
})
raise
The NotificationManager depends on the EmailService, which makes it challenging to test without actually sending emails. This is where mocking becomes essential. Here’s how to test it effectively:
class TestNotificationManager(unittest.TestCase):
def setUp(self):
self.mock_email_service = Mock(spec=EmailService)
self.notification_manager = NotificationManager(self.mock_email_service)
def test_successful_welcome_email(self):
# Configure mock behavior
self.mock_email_service.send_email.return_value = "msg_12345"
# Execute the method under test
message_id = self.notification_manager.send_welcome_email("[email protected]", "Alice")
# Verify results and interactions
self.assertEqual(message_id, "msg_12345")
self.mock_email_service.send_email.assert_called_once_with(
"[email protected]",
"Welcome to Our Service!",
"Hello Alice,\n\nWelcome to our service!"
)
# Verify logging
self.assertEqual(len(self.notification_manager.notification_log), 1)
log_entry = self.notification_manager.notification_log[0]
self.assertEqual(log_entry['status'], 'sent')
This test demonstrates the power of mocking—we can verify that our NotificationManager correctly calls the email service and handles the response, without actually sending any emails or depending on external services.
Testing Inheritance Hierarchies
Testing inheritance requires careful consideration of which behaviors to test at each level. You want to avoid duplicating tests while ensuring that overridden methods work correctly.
Let’s start with a simple shape hierarchy that demonstrates common inheritance patterns:
class Shape:
def __init__(self, name):
self.name = name
def area(self):
raise NotImplementedError("Subclasses must implement area()")
def describe(self):
return f"{self.name} with area {self.area():.2f}"
class Rectangle(Shape):
def __init__(self, width, height):
super().__init__("Rectangle")
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
super().__init__("Circle")
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
When testing inheritance hierarchies, focus on testing each class’s specific behavior while also verifying that the inheritance relationships work correctly:
class TestShapeHierarchy(unittest.TestCase):
def test_rectangle_calculations(self):
rect = Rectangle(4, 5)
self.assertEqual(rect.area(), 20)
self.assertEqual(rect.name, "Rectangle")
def test_circle_calculations(self):
import math
circle = Circle(3)
expected_area = math.pi * 9
self.assertAlmostEqual(circle.area(), expected_area, places=5)
def test_polymorphic_behavior(self):
shapes = [Rectangle(3, 4), Circle(2)]
# All shapes should work polymorphically
for shape in shapes:
self.assertIsInstance(shape.area(), (int, float))
self.assertIn(shape.name, shape.describe())
def test_base_class_abstract_methods(self):
shape = Shape("Generic")
with self.assertRaises(NotImplementedError):
shape.area()
The key insight here is testing at the right level of abstraction. Test concrete implementations in the subclasses, but also verify that the polymorphic behavior works correctly when treating different subclasses uniformly.
Test Doubles and Dependency Injection
Creating testable object-oriented code often requires designing for dependency injection. This makes your classes more flexible and much easier to test.
Here’s a typical layered architecture where each class depends on the layer below it:
class DatabaseConnection:
def execute_query(self, query, params=None):
raise NotImplementedError("Real database implementation needed")
class UserRepository:
def __init__(self, db_connection):
self.db = db_connection
def create_user(self, username, email):
query = "INSERT INTO users (username, email) VALUES (?, ?)"
result = self.db.execute_query(query, (username, email))
return result['user_id']
def find_user_by_email(self, email):
query = "SELECT * FROM users WHERE email = ?"
result = self.db.execute_query(query, (email,))
return result['rows'][0] if result['rows'] else None
class UserService:
def __init__(self, user_repository, email_service):
self.user_repo = user_repository
self.email_service = email_service
def register_user(self, username, email):
existing_user = self.user_repo.find_user_by_email(email)
if existing_user:
raise ValueError("User with this email already exists")
user_id = self.user_repo.create_user(username, email)
self.email_service.send_welcome_email(email, username)
return user_id
The key insight here is that each class receives its dependencies through its constructor rather than creating them internally. This makes testing much easier because you can inject mock objects instead of real dependencies.
Here’s how to test the UserService effectively:
class TestUserService(unittest.TestCase):
def setUp(self):
self.mock_user_repo = Mock(spec=UserRepository)
self.mock_email_service = Mock()
self.user_service = UserService(self.mock_user_repo, self.mock_email_service)
def test_successful_user_registration(self):
# Configure mock behavior
self.mock_user_repo.find_user_by_email.return_value = None
self.mock_user_repo.create_user.return_value = 123
# Execute and verify
user_id = self.user_service.register_user("alice", "[email protected]")
self.assertEqual(user_id, 123)
# Verify all dependencies were called correctly
self.mock_user_repo.find_user_by_email.assert_called_once_with("[email protected]")
self.mock_user_repo.create_user.assert_called_once_with("alice", "[email protected]")
self.mock_email_service.send_welcome_email.assert_called_once()
def test_duplicate_user_registration(self):
# Configure mock to simulate existing user
self.mock_user_repo.find_user_by_email.return_value = {'id': 456}
# Verify exception handling
with self.assertRaises(ValueError):
self.user_service.register_user("alice", "[email protected]")
# Verify that create_user was never called
self.mock_user_repo.create_user.assert_not_called()
This approach lets you test the UserService’s business logic in complete isolation from the database and email systems. Each test focuses on a specific scenario and verifies both the return values and the interactions with dependencies.
Property-Based Testing for Classes
Property-based testing generates random inputs to verify that your classes maintain certain invariants regardless of the specific data they receive. This approach is particularly powerful for testing mathematical properties or business rules that should always hold true.
Here’s a simple Counter class that we’ll test using property-based techniques:
from hypothesis import given, strategies as st
class Counter:
def __init__(self, initial_value=0):
self.value = initial_value
self.history = [initial_value]
def increment(self, amount=1):
if not isinstance(amount, int) or amount < 0:
raise ValueError("Amount must be a non-negative integer")
self.value += amount
self.history.append(self.value)
return self.value
def decrement(self, amount=1):
if not isinstance(amount, int) or amount < 0:
raise ValueError("Amount must be a non-negative integer")
self.value -= amount
self.history.append(self.value)
return self.value
Instead of testing with specific values, property-based tests verify that certain relationships always hold:
class TestCounterProperties(unittest.TestCase):
@given(st.integers(min_value=0, max_value=1000))
def test_increment_increases_value(self, amount):
counter = Counter(0)
initial_value = counter.value
counter.increment(amount)
self.assertGreater(counter.value, initial_value)
@given(st.integers(min_value=-1000, max_value=1000),
st.integers(min_value=0, max_value=100))
def test_increment_decrement_symmetry(self, initial_value, amount):
counter = Counter(initial_value)
counter.increment(amount)
counter.decrement(amount)
self.assertEqual(counter.value, initial_value)
Property-based testing excels at finding edge cases you might not think to test manually. The @given
decorator generates hundreds of different input combinations, helping you discover bugs that only occur with specific data patterns.
Testing object-oriented code effectively requires understanding both the technical aspects of testing frameworks and the design principles that make code testable. The key is designing your classes with clear responsibilities, minimal dependencies, and well-defined interfaces that can be easily mocked and verified.
In the next part, we’ll explore performance optimization techniques for object-oriented Python code. You’ll learn about memory management, method caching, and design patterns that can significantly improve the performance of your classes and objects.