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.