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 โจ
๐ฏ Introduction
Welcome to this exciting tutorial on test doubles! ๐ Have you ever tried to test code that depends on external services, databases, or APIs? Itโs like trying to test a carโs performance on a track thatโs still being built! ๐
Test doubles are your solution - theyโre stand-ins for real objects that help you test your code in isolation. Think of them as stunt doubles in movies ๐ฌ - they look like the real actor but are specifically designed for testing dangerous scenes!
By the end of this tutorial, youโll be creating test doubles like a pro, making your tests faster, more reliable, and easier to write. Letโs dive in! ๐โโ๏ธ
๐ Understanding Test Doubles
๐ค What are Test Doubles?
Test doubles are like understudies in theater ๐ญ - they step in when the real actor (your actual dependencies) canโt perform. They help you:
- โจ Test in isolation without external dependencies
- ๐ Run tests quickly without slow operations
- ๐ก๏ธ Create predictable test scenarios
- ๐ฏ Focus on testing one thing at a time
๐ก Types of Test Doubles
There are several types of test doubles, each with its own superpower:
- Stubs ๐ช: Provide canned answers to calls
- Mocks ๐ญ: Verify interactions happened correctly
- Fakes ๐จ: Have working implementations but simplified
- Spies ๐ต๏ธ: Record information about calls
- Dummies ๐ค: Just fill parameter lists
Today, weโll focus on the big three: Stubs, Mocks, and Fakes!
๐ง Basic Syntax and Usage
๐ Simple Stub Example
Letโs start with a friendly stub example:
# ๐ Hello, Test Doubles!
from unittest.mock import Mock
# ๐จ Creating a simple stub
def create_weather_stub():
weather_service = Mock()
# ๐ค๏ธ Stub always returns sunny weather
weather_service.get_temperature.return_value = 72
weather_service.get_conditions.return_value = "Sunny โ๏ธ"
return weather_service
# ๐ฎ Using our stub
weather = create_weather_stub()
print(f"Temperature: {weather.get_temperature()}ยฐF") # Always 72!
print(f"Conditions: {weather.get_conditions()}") # Always Sunny!
๐ก Explanation: Stubs provide predetermined responses. Perfect when you need consistent test data!
๐ฏ Mock with Assertions
Mocks go beyond stubs - they verify interactions:
from unittest.mock import Mock
# ๐ญ Create a mock email service
email_service = Mock()
# ๐ Use the mock in our code
def send_welcome_email(user_email, email_service):
subject = "Welcome! ๐"
body = "Thanks for joining us!"
email_service.send(user_email, subject, body)
# ๐งช Test with assertions
send_welcome_email("[email protected]", email_service)
# โ
Verify the email was sent correctly
email_service.send.assert_called_once_with(
"[email protected]",
"Welcome! ๐",
"Thanks for joining us!"
)
๐ก Practical Examples
๐ Example 1: E-commerce Order Processing
Letโs build a realistic order processing system:
from unittest.mock import Mock, MagicMock
from datetime import datetime
import pytest
# ๐๏ธ Our order processing system
class OrderProcessor:
def __init__(self, payment_gateway, inventory_service, email_service):
self.payment_gateway = payment_gateway
self.inventory_service = inventory_service
self.email_service = email_service
def process_order(self, order):
# ๐ฆ Check inventory
if not self.inventory_service.check_availability(order.items):
return {"status": "failed", "reason": "Out of stock ๐ข"}
# ๐ณ Process payment
payment_result = self.payment_gateway.charge(
order.customer_id,
order.total_amount
)
if not payment_result.success:
return {"status": "failed", "reason": "Payment declined ๐ณโ"}
# ๐ง Send confirmation email
self.email_service.send_confirmation(
order.customer_email,
order.order_id
)
return {
"status": "success",
"message": "Order processed! ๐",
"order_id": order.order_id
}
# ๐งช Testing with test doubles
def test_successful_order():
# ๐จ Create our test doubles
payment_gateway = Mock()
inventory_service = Mock()
email_service = Mock()
# ๐ฏ Set up stub responses
inventory_service.check_availability.return_value = True
payment_result = Mock()
payment_result.success = True
payment_gateway.charge.return_value = payment_result
# ๐ Create processor and test order
processor = OrderProcessor(payment_gateway, inventory_service, email_service)
order = Mock()
order.items = ["Widget", "Gadget"]
order.customer_id = "CUST123"
order.total_amount = 99.99
order.customer_email = "[email protected]"
order.order_id = "ORD456"
# ๐ Process the order
result = processor.process_order(order)
# โ
Verify success
assert result["status"] == "success"
assert "๐" in result["message"]
# ๐ Verify all services were called correctly
inventory_service.check_availability.assert_called_once_with(order.items)
payment_gateway.charge.assert_called_once_with("CUST123", 99.99)
email_service.send_confirmation.assert_called_once_with(
"[email protected]",
"ORD456"
)
๐ฏ Try it yourself: Add a test for when payment fails!
๐ฎ Example 2: Game Score API with Fakes
Letโs create a fake implementation for testing:
# ๐ Real API client (would hit actual API)
class GameScoreAPI:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.gamescores.com"
def get_high_scores(self, game_id):
# ๐ Would make HTTP request
pass
def submit_score(self, player_id, score):
# ๐ค Would POST to API
pass
# ๐จ Fake implementation for testing
class FakeGameScoreAPI:
def __init__(self):
self.scores = {} # ๐ In-memory storage
def get_high_scores(self, game_id):
# ๐ฏ Return fake high scores
if game_id not in self.scores:
self.scores[game_id] = []
return sorted(self.scores[game_id],
key=lambda x: x['score'],
reverse=True)[:10]
def submit_score(self, game_id, player_id, score):
# ๐พ Store in memory instead of API
if game_id not in self.scores:
self.scores[game_id] = []
self.scores[game_id].append({
'player': player_id,
'score': score,
'timestamp': datetime.now(),
'rank': self._calculate_rank(game_id, score)
})
return {'success': True, 'rank': self._calculate_rank(game_id, score)}
def _calculate_rank(self, game_id, score):
# ๐
Calculate player's rank
if game_id not in self.scores:
return 1
higher_scores = sum(1 for s in self.scores[game_id]
if s['score'] > score)
return higher_scores + 1
# ๐ฎ Game using our API
class ArcadeGame:
def __init__(self, score_api):
self.score_api = score_api
self.game_id = "space-invaders"
def end_game(self, player_id, final_score):
# ๐ Submit score and show result
result = self.score_api.submit_score(
self.game_id,
player_id,
final_score
)
if result['rank'] <= 3:
return f"๐ NEW HIGH SCORE! Rank #{result['rank']}!"
elif result['rank'] <= 10:
return f"๐ Top 10! Rank #{result['rank']}!"
else:
return f"Score submitted! Rank #{result['rank']} ๐ฎ"
# ๐งช Test with fake
def test_arcade_game_scoring():
# ๐จ Use fake instead of real API
fake_api = FakeGameScoreAPI()
game = ArcadeGame(fake_api)
# ๐ฎ Simulate multiple players
assert "๐" in game.end_game("Player1", 1000) # First player
assert "๐" in game.end_game("Player2", 2000) # New high score!
assert "๐" in game.end_game("Player3", 500) # Lower score
# ๐ Verify high scores
high_scores = fake_api.get_high_scores("space-invaders")
assert len(high_scores) == 3
assert high_scores[0]['score'] == 2000
๐ Advanced Concepts
๐งโโ๏ธ Advanced Mocking with Side Effects
When you need dynamic behavior:
from unittest.mock import Mock, call
# ๐ฏ Mock with side effects
database_mock = Mock()
# ๐ฒ Different results each time
database_mock.get_user.side_effect = [
{"name": "Alice", "status": "active"},
{"name": "Bob", "status": "inactive"},
None # Third call returns None
]
# ๐งช Test the progression
print(database_mock.get_user()) # Alice
print(database_mock.get_user()) # Bob
print(database_mock.get_user()) # None
# ๐ Mock that raises exceptions
api_mock = Mock()
api_mock.connect.side_effect = ConnectionError("Network timeout! ๐โ")
try:
api_mock.connect()
except ConnectionError as e:
print(f"Caught: {e}")
๐๏ธ Spy Pattern Implementation
Create a spy that records everything:
class SpyEmailService:
def __init__(self, real_service=None):
self.real_service = real_service
self.sent_emails = [] # ๐ Record all emails
def send(self, to, subject, body):
# ๐ธ Record the call
self.sent_emails.append({
'to': to,
'subject': subject,
'body': body,
'timestamp': datetime.now()
})
# ๐ฏ Optionally call real service
if self.real_service:
return self.real_service.send(to, subject, body)
return True # ๐ญ Fake success
def get_sent_count(self):
return len(self.sent_emails)
def was_email_sent_to(self, email):
return any(e['to'] == email for e in self.sent_emails)
def get_emails_with_subject(self, subject):
return [e for e in self.sent_emails if subject in e['subject']]
# ๐งช Using our spy
spy = SpyEmailService()
spy.send("[email protected]", "Welcome! ๐", "Thanks for joining!")
spy.send("[email protected]", "Alert! ๐จ", "System notification")
print(f"Emails sent: {spy.get_sent_count()}")
print(f"Email to user? {spy.was_email_sent_to('[email protected]')}")
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Over-Mocking
# โ Wrong - mocking too much!
def test_calculator_add():
calc = Mock()
calc.add.return_value = 5
assert calc.add(2, 3) == 5 # ๐ฅ Not testing anything real!
# โ
Correct - mock external dependencies only!
def test_calculator_with_logger():
logger = Mock() # Mock the logger
calc = Calculator(logger) # Real calculator
result = calc.add(2, 3)
assert result == 5 # Test real logic
logger.info.assert_called_once() # Verify logging
๐คฏ Pitfall 2: Brittle Mock Assertions
# โ Too specific - breaks easily!
mock.method.assert_called_with(
"exact string",
{"every": "single", "key": "must", "match": "perfectly"}
)
# โ
Better - focus on what matters!
mock.method.assert_called_once()
args, kwargs = mock.method.call_args
assert "string" in args[0] # Key part of string
assert kwargs.get("key") == "must" # Important values only
๐ ๏ธ Best Practices
- ๐ฏ Mock at boundaries: Only mock external dependencies
- ๐ Clear test names:
test_order_fails_when_payment_declined
- ๐ก๏ธ One mock per concept: Donโt create mega-mocks
- ๐จ Use appropriate double: Stub for queries, Mock for commands
- โจ Keep it simple: Prefer simple fakes over complex mocks
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Weather Alert System
Create a weather alert system with test doubles:
๐ Requirements:
- โ Weather API client (to be mocked)
- ๐ท๏ธ Alert service that sends notifications
- ๐ค User preferences for alert thresholds
- ๐ Schedule checker for when to send alerts
- ๐จ Different alert types (rain โ, snow โ๏ธ, heat ๐ก๏ธ)
๐ Bonus Points:
- Use all three types of test doubles
- Test error scenarios
- Create a fake that simulates weather patterns
๐ก Solution
๐ Click to see solution
from unittest.mock import Mock, MagicMock
from datetime import datetime, time
import pytest
# ๐ฆ๏ธ Weather Alert System
class WeatherAlertSystem:
def __init__(self, weather_api, notification_service, user_prefs):
self.weather_api = weather_api
self.notification_service = notification_service
self.user_prefs = user_prefs
def check_and_alert(self, user_id):
# ๐ Get user preferences
prefs = self.user_prefs.get_preferences(user_id)
if not prefs or not prefs.get('alerts_enabled'):
return None
# ๐ก๏ธ Get current weather
weather = self.weather_api.get_current_weather(prefs['location'])
alerts_sent = []
# โ Check rain alert
if weather['rain_chance'] > prefs.get('rain_threshold', 70):
alert = f"โ Rain alert! {weather['rain_chance']}% chance"
self.notification_service.send_alert(user_id, alert)
alerts_sent.append(alert)
# ๐ก๏ธ Check temperature alert
if weather['temperature'] > prefs.get('heat_threshold', 90):
alert = f"๐ก๏ธ Heat alert! {weather['temperature']}ยฐF"
self.notification_service.send_alert(user_id, alert)
alerts_sent.append(alert)
# โ๏ธ Check snow alert
if weather.get('snow_chance', 0) > prefs.get('snow_threshold', 50):
alert = f"โ๏ธ Snow alert! {weather['snow_chance']}% chance"
self.notification_service.send_alert(user_id, alert)
alerts_sent.append(alert)
return alerts_sent
# ๐จ Fake Weather API for testing
class FakeWeatherAPI:
def __init__(self):
self.weather_data = {}
self.call_count = 0
def set_weather(self, location, weather):
"""Helper method to set test data"""
self.weather_data[location] = weather
def get_current_weather(self, location):
self.call_count += 1
# ๐ฏ Return fake data or default
return self.weather_data.get(location, {
'temperature': 72,
'rain_chance': 10,
'snow_chance': 0,
'conditions': 'Sunny โ๏ธ'
})
# ๐งช Test Suite
class TestWeatherAlertSystem:
def test_rain_alert_triggered(self):
# ๐ญ Setup mocks
weather_api = Mock()
notification_service = Mock()
user_prefs = Mock()
# ๐ฏ Configure stubs
user_prefs.get_preferences.return_value = {
'alerts_enabled': True,
'location': 'Seattle',
'rain_threshold': 60
}
weather_api.get_current_weather.return_value = {
'temperature': 65,
'rain_chance': 85, # Above threshold!
'conditions': 'Cloudy โ๏ธ'
}
# ๐ Test the system
alert_system = WeatherAlertSystem(
weather_api,
notification_service,
user_prefs
)
alerts = alert_system.check_and_alert('user123')
# โ
Verify behavior
assert len(alerts) == 1
assert "โ" in alerts[0]
notification_service.send_alert.assert_called_once()
def test_multiple_alerts_with_fake(self):
# ๐จ Use fake for more complex scenario
fake_weather = FakeWeatherAPI()
notification_service = Mock()
user_prefs = Mock()
# ๐ฆ๏ธ Set extreme weather
fake_weather.set_weather('Phoenix', {
'temperature': 115, # Hot! ๐ฅ
'rain_chance': 5,
'conditions': 'Scorching โ๏ธ'
})
user_prefs.get_preferences.return_value = {
'alerts_enabled': True,
'location': 'Phoenix',
'heat_threshold': 100
}
alert_system = WeatherAlertSystem(
fake_weather,
notification_service,
user_prefs
)
alerts = alert_system.check_and_alert('user456')
# โ
Should get heat alert
assert len(alerts) == 1
assert "๐ก๏ธ" in alerts[0]
assert "115ยฐF" in alerts[0]
def test_spy_pattern_for_api_calls(self):
# ๐ต๏ธ Create a spy to track API usage
class WeatherAPISpy:
def __init__(self, real_api=None):
self.real_api = real_api
self.calls = []
def get_current_weather(self, location):
self.calls.append({
'method': 'get_current_weather',
'location': location,
'timestamp': datetime.now()
})
if self.real_api:
return self.real_api.get_current_weather(location)
# ๐ฏ Default response
return {'temperature': 70, 'rain_chance': 20}
spy = WeatherAPISpy()
notification_service = Mock()
user_prefs = Mock()
user_prefs.get_preferences.return_value = {
'alerts_enabled': True,
'location': 'Boston'
}
alert_system = WeatherAlertSystem(spy, notification_service, user_prefs)
alert_system.check_and_alert('user789')
# ๐ Verify API was called correctly
assert len(spy.calls) == 1
assert spy.calls[0]['location'] == 'Boston'
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Create stubs for predictable test data ๐ช
- โ Use mocks to verify interactions ๐ญ
- โ Build fakes for complex scenarios ๐จ
- โ Avoid common testing pitfalls ๐ก๏ธ
- โ Write faster, more reliable tests ๐
Remember: Test doubles are your friends - they make testing easier, not harder! Theyโre here to help you write better, more maintainable tests. ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered test doubles!
Hereโs what to do next:
- ๐ป Practice with the weather alert exercise
- ๐๏ธ Refactor existing tests to use test doubles
- ๐ Explore advanced mocking with
pytest-mock
- ๐ Learn about property-based testing next!
Remember: Every testing expert started by writing their first mock. Keep practicing, keep learning, and most importantly, keep your tests fast and reliable! ๐
Happy testing! ๐๐งชโจ