Prerequisites
- Basic understanding of programming concepts 📝
- Python installation (3.8+) 🐍
- VS Code or preferred IDE 💻
What you'll learn
- Understand the concept fundamentals 🎯
- Apply the concept in real projects 🏗️
- Debug common issues 🐛
- Write clean, Pythonic code ✨
📘 Testing Classes: Unit Tests
🎯 Introduction
Hey there, Python developer! 👋 Ever created a beautiful class and wondered, “But does it really work?” 🤔 That’s where unit testing comes to the rescue!
Today, we’re diving into the world of testing classes with unit tests. Think of unit tests as your personal quality control team that works 24/7, checking every method and attribute to ensure your classes behave exactly as expected. Let’s make testing fun and powerful! 🎉
📚 Understanding Unit Testing for Classes
What Are Unit Tests? 🧪
Unit tests are like individual quality checks for your code. Imagine you’re building a robot 🤖:
- You test each part separately (the arm, the leg, the sensor)
- Only after each part works perfectly do you assemble them
- If something breaks, you know exactly which part failed!
import unittest
# The class we want to test 🎯
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount > 0:
self.balance += amount
return True
return False
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance -= amount
return True
return False
# The test class 🧪
class TestBankAccount(unittest.TestCase):
def test_initial_balance(self):
# Testing the constructor 🏗️
account = BankAccount("Alice", 100)
self.assertEqual(account.balance, 100)
self.assertEqual(account.owner, "Alice")
Why Test Classes? 🤷♀️
Testing classes is crucial because:
- Confidence: Know your code works before deployment 💪
- Documentation: Tests show how to use your class 📖
- Refactoring Safety: Change code without fear 🛡️
- Bug Prevention: Catch issues early 🐛
🔧 Basic Syntax and Usage
Setting Up Your First Test Class
Here’s the basic structure for testing any Python class:
import unittest
class TestYourClass(unittest.TestCase):
def setUp(self):
# This runs before each test method 🏁
self.test_object = YourClass()
def tearDown(self):
# This runs after each test method 🧹
# Clean up resources if needed
pass
def test_something(self):
# Test methods must start with 'test_' 📝
result = self.test_object.some_method()
self.assertEqual(result, expected_value)
# Run the tests
if __name__ == '__main__':
unittest.main()
Essential Assertion Methods 🔍
class TestAssertions(unittest.TestCase):
def test_equality_assertions(self):
# Check if values are equal ✅
self.assertEqual(2 + 2, 4)
self.assertNotEqual(2 + 2, 5)
def test_truthiness_assertions(self):
# Check if something is True/False 🔮
self.assertTrue(isinstance(5, int))
self.assertFalse(isinstance("5", int))
def test_membership_assertions(self):
# Check if item is in collection 📦
self.assertIn("Python", ["Python", "Java", "C++"])
self.assertNotIn("Ruby", ["Python", "Java", "C++"])
def test_exception_assertions(self):
# Check if exception is raised 💥
with self.assertRaises(ValueError):
int("not a number")
💡 Practical Examples
Example 1: Testing a Shopping Cart Class 🛒
Let’s test a real-world shopping cart implementation:
# The Shopping Cart class 🛍️
class ShoppingCart:
def __init__(self):
self.items = {}
self.discount = 0
def add_item(self, item, price, quantity=1):
if item in self.items:
self.items[item]['quantity'] += quantity
else:
self.items[item] = {'price': price, 'quantity': quantity}
def remove_item(self, item):
if item in self.items:
del self.items[item]
return True
return False
def get_total(self):
total = sum(details['price'] * details['quantity']
for details in self.items.values())
return total * (1 - self.discount)
def apply_discount(self, percentage):
if 0 <= percentage <= 100:
self.discount = percentage / 100
return True
return False
# Comprehensive test suite 🧪
class TestShoppingCart(unittest.TestCase):
def setUp(self):
# Fresh cart for each test 🆕
self.cart = ShoppingCart()
def test_add_single_item(self):
# Test adding one item 📦
self.cart.add_item("Apple", 1.50)
self.assertIn("Apple", self.cart.items)
self.assertEqual(self.cart.items["Apple"]["quantity"], 1)
def test_add_multiple_items(self):
# Test adding multiple items 📦📦
self.cart.add_item("Banana", 0.75, 3)
self.cart.add_item("Orange", 2.00, 2)
self.assertEqual(len(self.cart.items), 2)
self.assertEqual(self.cart.get_total(), 6.25)
def test_remove_item(self):
# Test removing items 🗑️
self.cart.add_item("Milk", 3.50)
result = self.cart.remove_item("Milk")
self.assertTrue(result)
self.assertNotIn("Milk", self.cart.items)
# Try removing non-existent item
result = self.cart.remove_item("Cheese")
self.assertFalse(result)
def test_discount_application(self):
# Test discount functionality 💰
self.cart.add_item("Laptop", 1000)
self.cart.apply_discount(20)
self.assertEqual(self.cart.get_total(), 800)
# Test invalid discount
result = self.cart.apply_discount(150)
self.assertFalse(result)
Example 2: Testing a Game Character Class 🎮
# Game character class 🦸♂️
class GameCharacter:
def __init__(self, name, health=100, level=1):
self.name = name
self.health = health
self.max_health = health
self.level = level
self.experience = 0
self.inventory = []
def take_damage(self, damage):
self.health = max(0, self.health - damage)
return self.health > 0 # Returns True if still alive
def heal(self, amount):
old_health = self.health
self.health = min(self.max_health, self.health + amount)
return self.health - old_health # Actual healing done
def gain_experience(self, exp):
self.experience += exp
levels_gained = 0
while self.experience >= 100:
self.experience -= 100
self.level += 1
self.max_health += 10
levels_gained += 1
return levels_gained
def add_item(self, item):
if len(self.inventory) < 10: # Max 10 items
self.inventory.append(item)
return True
return False
# Test the game character 🧪
class TestGameCharacter(unittest.TestCase):
def setUp(self):
self.hero = GameCharacter("Pythonista", 100, 1)
def test_character_creation(self):
# Test initial stats 📊
self.assertEqual(self.hero.name, "Pythonista")
self.assertEqual(self.hero.health, 100)
self.assertEqual(self.hero.level, 1)
self.assertEqual(self.hero.experience, 0)
def test_damage_and_healing(self):
# Test combat mechanics ⚔️
alive = self.hero.take_damage(30)
self.assertTrue(alive)
self.assertEqual(self.hero.health, 70)
# Test healing 💊
healed = self.hero.heal(20)
self.assertEqual(healed, 20)
self.assertEqual(self.hero.health, 90)
# Test overhealing
healed = self.hero.heal(50)
self.assertEqual(healed, 10)
self.assertEqual(self.hero.health, 100)
def test_lethal_damage(self):
# Test character death 💀
alive = self.hero.take_damage(150)
self.assertFalse(alive)
self.assertEqual(self.hero.health, 0)
def test_leveling_system(self):
# Test experience and leveling 📈
levels = self.hero.gain_experience(250)
self.assertEqual(levels, 2)
self.assertEqual(self.hero.level, 3)
self.assertEqual(self.hero.max_health, 120)
self.assertEqual(self.hero.experience, 50)
def test_inventory_management(self):
# Test inventory system 🎒
items = ["Sword", "Shield", "Potion"]
for item in items:
result = self.hero.add_item(item)
self.assertTrue(result)
self.assertEqual(len(self.hero.inventory), 3)
# Test inventory limit
for i in range(10):
self.hero.add_item(f"Item {i}")
result = self.hero.add_item("Too many!")
self.assertFalse(result)
Example 3: Testing a User Authentication Class 🔐
import hashlib
import re
# User authentication class 🔑
class UserAuth:
def __init__(self):
self.users = {}
self.logged_in = set()
def hash_password(self, password):
# Simple hashing (use bcrypt in production!) 🔒
return hashlib.sha256(password.encode()).hexdigest()
def validate_email(self, email):
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def register(self, username, email, password):
if username in self.users:
return False, "Username already exists"
if not self.validate_email(email):
return False, "Invalid email format"
if len(password) < 8:
return False, "Password too short"
self.users[username] = {
'email': email,
'password': self.hash_password(password)
}
return True, "Registration successful"
def login(self, username, password):
if username not in self.users:
return False, "User not found"
if self.users[username]['password'] != self.hash_password(password):
return False, "Incorrect password"
self.logged_in.add(username)
return True, "Login successful"
def logout(self, username):
if username in self.logged_in:
self.logged_in.remove(username)
return True
return False
# Test authentication system 🧪
class TestUserAuth(unittest.TestCase):
def setUp(self):
self.auth = UserAuth()
def test_successful_registration(self):
# Test valid registration ✅
success, message = self.auth.register(
"alice", "[email protected]", "password123"
)
self.assertTrue(success)
self.assertEqual(message, "Registration successful")
self.assertIn("alice", self.auth.users)
def test_registration_validations(self):
# Test duplicate username ❌
self.auth.register("bob", "[email protected]", "password123")
success, message = self.auth.register(
"bob", "[email protected]", "password456"
)
self.assertFalse(success)
self.assertEqual(message, "Username already exists")
# Test invalid email ❌
success, message = self.auth.register(
"carol", "not-an-email", "password123"
)
self.assertFalse(success)
self.assertEqual(message, "Invalid email format")
# Test short password ❌
success, message = self.auth.register(
"dave", "[email protected]", "short"
)
self.assertFalse(success)
self.assertEqual(message, "Password too short")
def test_login_logout_flow(self):
# Register a user first 👤
self.auth.register("testuser", "[email protected]", "testpass123")
# Test successful login ✅
success, message = self.auth.login("testuser", "testpass123")
self.assertTrue(success)
self.assertIn("testuser", self.auth.logged_in)
# Test wrong password ❌
success, message = self.auth.login("testuser", "wrongpass")
self.assertFalse(success)
self.assertEqual(message, "Incorrect password")
# Test logout 👋
result = self.auth.logout("testuser")
self.assertTrue(result)
self.assertNotIn("testuser", self.auth.logged_in)
🚀 Advanced Concepts
Test Fixtures and Mocking 🎭
from unittest.mock import Mock, patch
import requests
# Class that uses external API 🌐
class WeatherService:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.weather.com"
def get_temperature(self, city):
response = requests.get(
f"{self.base_url}/current",
params={"city": city, "key": self.api_key}
)
if response.status_code == 200:
return response.json()["temperature"]
return None
# Testing with mocks 🧪
class TestWeatherService(unittest.TestCase):
def setUp(self):
self.service = WeatherService("test-key")
@patch('requests.get')
def test_successful_temperature_fetch(self, mock_get):
# Mock the API response 🎭
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"temperature": 22}
mock_get.return_value = mock_response
temp = self.service.get_temperature("London")
self.assertEqual(temp, 22)
# Verify the API was called correctly ✅
mock_get.assert_called_once_with(
"https://api.weather.com/current",
params={"city": "London", "key": "test-key"}
)
Test Parameterization 🔄
import unittest
from parameterized import parameterized
class Calculator:
def add(self, a, b):
return a + b
def divide(self, a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calc = Calculator()
@parameterized.expand([
(2, 3, 5), # Test case 1 ✅
(-1, 1, 0), # Test case 2 ✅
(0, 0, 0), # Test case 3 ✅
(10, -5, 5), # Test case 4 ✅
])
def test_addition(self, a, b, expected):
result = self.calc.add(a, b)
self.assertEqual(result, expected)
@parameterized.expand([
(10, 2, 5), # Normal division ✅
(7, 2, 3.5), # Decimal result ✅
(-10, 2, -5), # Negative dividend ✅
])
def test_division(self, a, b, expected):
result = self.calc.divide(a, b)
self.assertEqual(result, expected)
Test Organization with Suites 📋
# Organize tests into suites 🗂️
def create_test_suite():
suite = unittest.TestSuite()
# Add individual test methods
suite.addTest(TestBankAccount('test_initial_balance'))
suite.addTest(TestBankAccount('test_deposit'))
# Add entire test classes
suite.addTests(unittest.TestLoader().loadTestsFromTestCase(TestShoppingCart))
# Add tests from modules
# suite.addTests(unittest.TestLoader().loadTestsFromModule(test_module))
return suite
if __name__ == '__main__':
runner = unittest.TextTestRunner(verbosity=2)
runner.run(create_test_suite())
⚠️ Common Pitfalls and Solutions
Pitfall 1: Not Isolating Tests 🏝️
# ❌ Wrong: Tests depend on each other
class BadTestExample(unittest.TestCase):
cart = ShoppingCart() # Shared state - BAD!
def test_add_item(self):
self.cart.add_item("Apple", 1.50)
# This affects other tests!
def test_total(self):
# This might fail if test_add_item didn't run first
total = self.cart.get_total()
# ✅ Correct: Each test is independent
class GoodTestExample(unittest.TestCase):
def setUp(self):
self.cart = ShoppingCart() # Fresh instance for each test
def test_add_item(self):
self.cart.add_item("Apple", 1.50)
self.assertEqual(len(self.cart.items), 1)
def test_total(self):
self.cart.add_item("Apple", 1.50) # Set up test data
total = self.cart.get_total()
self.assertEqual(total, 1.50)
Pitfall 2: Testing Implementation Instead of Behavior 🎯
# ❌ Wrong: Testing internal details
def test_internal_implementation(self):
account = BankAccount("Alice", 100)
account.deposit(50)
# Don't test private attributes!
self.assertEqual(account._transaction_count, 1)
# ✅ Correct: Test observable behavior
def test_deposit_behavior(self):
account = BankAccount("Alice", 100)
account.deposit(50)
# Test the public interface
self.assertEqual(account.balance, 150)
Pitfall 3: Not Testing Edge Cases 🔍
# ❌ Wrong: Only testing happy path
def test_withdraw_basic(self):
account = BankAccount("Alice", 100)
account.withdraw(50)
self.assertEqual(account.balance, 50)
# ✅ Correct: Test edge cases too
def test_withdraw_comprehensive(self):
account = BankAccount("Alice", 100)
# Test normal withdrawal ✅
self.assertTrue(account.withdraw(50))
self.assertEqual(account.balance, 50)
# Test exact balance withdrawal ✅
self.assertTrue(account.withdraw(50))
self.assertEqual(account.balance, 0)
# Test overdraft attempt ❌
self.assertFalse(account.withdraw(1))
# Test negative amount ❌
self.assertFalse(account.withdraw(-10))
# Test zero amount ❌
self.assertFalse(account.withdraw(0))
🛠️ Best Practices
1. Follow the AAA Pattern 📐
def test_with_aaa_pattern(self):
# Arrange: Set up test data 🏗️
cart = ShoppingCart()
# Act: Perform the action 🎬
cart.add_item("Laptop", 999.99)
cart.apply_discount(10)
# Assert: Check the result ✅
self.assertEqual(cart.get_total(), 899.99)
2. Use Descriptive Test Names 📝
# ❌ Bad naming
def test1(self):
pass
def test_stuff(self):
pass
# ✅ Good naming
def test_deposit_positive_amount_increases_balance(self):
pass
def test_withdraw_more_than_balance_returns_false(self):
pass
def test_new_account_has_zero_balance_by_default(self):
pass
3. Keep Tests Fast and Focused ⚡
# ✅ Good: Fast, focused test
def test_email_validation(self):
auth = UserAuth()
self.assertTrue(auth.validate_email("[email protected]"))
self.assertFalse(auth.validate_email("invalid-email"))
# ❌ Bad: Slow, doing too much
def test_everything(self):
# Testing too many things in one test
auth = UserAuth()
# Register 100 users...
# Login all of them...
# Test every feature...
4. Use setUp and tearDown Wisely 🧹
class TestDatabaseOperations(unittest.TestCase):
def setUp(self):
# Create test database connection 🔌
self.db = TestDatabase()
self.db.connect()
def tearDown(self):
# Clean up after each test 🧹
self.db.clear_all_data()
self.db.disconnect()
def test_user_creation(self):
# Your test uses the fresh database
user = self.db.create_user("testuser")
self.assertIsNotNone(user.id)
🧪 Hands-On Exercise
Time to practice! Create a test suite for this Library Management System:
class Book:
def __init__(self, title, author, isbn):
self.title = title
self.author = author
self.isbn = isbn
self.available = True
self.borrowed_by = None
class Library:
def __init__(self, name):
self.name = name
self.books = {}
self.members = set()
def add_book(self, book):
# Add a book to the library
pass
def register_member(self, member_id):
# Register a new library member
pass
def borrow_book(self, isbn, member_id):
# Member borrows a book
pass
def return_book(self, isbn):
# Return a borrowed book
pass
def search_by_author(self, author):
# Find all books by an author
pass
💡 Click here for the solution
import unittest
class Book:
def __init__(self, title, author, isbn):
self.title = title
self.author = author
self.isbn = isbn
self.available = True
self.borrowed_by = None
class Library:
def __init__(self, name):
self.name = name
self.books = {}
self.members = set()
def add_book(self, book):
if book.isbn not in self.books:
self.books[book.isbn] = book
return True
return False
def register_member(self, member_id):
if member_id not in self.members:
self.members.add(member_id)
return True
return False
def borrow_book(self, isbn, member_id):
if member_id not in self.members:
return False, "Member not registered"
if isbn not in self.books:
return False, "Book not found"
book = self.books[isbn]
if not book.available:
return False, "Book not available"
book.available = False
book.borrowed_by = member_id
return True, "Book borrowed successfully"
def return_book(self, isbn):
if isbn not in self.books:
return False, "Book not found"
book = self.books[isbn]
if book.available:
return False, "Book was not borrowed"
book.available = True
book.borrowed_by = None
return True, "Book returned successfully"
def search_by_author(self, author):
return [book for book in self.books.values()
if book.author.lower() == author.lower()]
# Complete test suite 🧪
class TestLibrary(unittest.TestCase):
def setUp(self):
self.library = Library("City Library")
# Create some test books 📚
self.book1 = Book("Python 101", "John Doe", "123")
self.book2 = Book("Advanced Python", "John Doe", "456")
self.book3 = Book("Django Web", "Jane Smith", "789")
def test_add_books(self):
# Test adding books ✅
self.assertTrue(self.library.add_book(self.book1))
self.assertIn("123", self.library.books)
# Test duplicate ISBN ❌
self.assertFalse(self.library.add_book(self.book1))
def test_register_members(self):
# Test member registration ✅
self.assertTrue(self.library.register_member("M001"))
self.assertIn("M001", self.library.members)
# Test duplicate member ❌
self.assertFalse(self.library.register_member("M001"))
def test_borrow_book_flow(self):
# Setup
self.library.add_book(self.book1)
self.library.register_member("M001")
# Test successful borrowing ✅
success, message = self.library.borrow_book("123", "M001")
self.assertTrue(success)
self.assertEqual(message, "Book borrowed successfully")
self.assertFalse(self.book1.available)
self.assertEqual(self.book1.borrowed_by, "M001")
# Test borrowing already borrowed book ❌
success, message = self.library.borrow_book("123", "M001")
self.assertFalse(success)
self.assertEqual(message, "Book not available")
def test_borrow_validations(self):
self.library.add_book(self.book1)
# Test unregistered member ❌
success, message = self.library.borrow_book("123", "M999")
self.assertFalse(success)
self.assertEqual(message, "Member not registered")
# Test non-existent book ❌
self.library.register_member("M001")
success, message = self.library.borrow_book("999", "M001")
self.assertFalse(success)
self.assertEqual(message, "Book not found")
def test_return_book(self):
# Setup: borrow a book first
self.library.add_book(self.book1)
self.library.register_member("M001")
self.library.borrow_book("123", "M001")
# Test successful return ✅
success, message = self.library.return_book("123")
self.assertTrue(success)
self.assertEqual(message, "Book returned successfully")
self.assertTrue(self.book1.available)
self.assertIsNone(self.book1.borrowed_by)
# Test returning non-borrowed book ❌
success, message = self.library.return_book("123")
self.assertFalse(success)
self.assertEqual(message, "Book was not borrowed")
def test_search_by_author(self):
# Add multiple books
self.library.add_book(self.book1)
self.library.add_book(self.book2)
self.library.add_book(self.book3)
# Search for John Doe's books 🔍
johns_books = self.library.search_by_author("John Doe")
self.assertEqual(len(johns_books), 2)
self.assertIn(self.book1, johns_books)
self.assertIn(self.book2, johns_books)
# Test case-insensitive search 🔍
johns_books_lower = self.library.search_by_author("john doe")
self.assertEqual(len(johns_books_lower), 2)
# Search for non-existent author 🔍
no_books = self.library.search_by_author("Unknown Author")
self.assertEqual(len(no_books), 0)
if __name__ == '__main__':
unittest.main(verbosity=2)
🎓 Key Takeaways
You’ve mastered testing classes with unit tests! Here’s what you’ve learned:
-
Unit Testing Fundamentals 🧪
- Test each method independently
- Use setUp() and tearDown() for test isolation
- Follow the AAA pattern (Arrange, Act, Assert)
-
Essential Assertions ✅
- assertEqual() for value checking
- assertTrue()/assertFalse() for boolean tests
- assertRaises() for exception testing
-
Best Practices 🏆
- Keep tests fast and focused
- Test edge cases and error conditions
- Use descriptive test names
-
Advanced Techniques 🚀
- Mock external dependencies
- Parameterize tests for multiple inputs
- Organize tests into suites
🤝 Next Steps
Congratulations on mastering unit testing for classes! 🎉 You’re now equipped to write robust, reliable code with confidence.
Ready for more Python adventures? Next up, we’ll explore:
- Pydantic: Data Validation Classes - Take your classes to the next level with automatic validation! 🛡️
- Type Hints in Classes - Make your code even more reliable with static typing! 📝
- Advanced Testing Patterns - Mock objects, fixtures, and more! 🎭
Keep testing, keep learning, and remember: tested code is trusted code! 🚀✨
Happy coding! 🐍💪