Mocking and Fixtures in pytest: Master Test Isolation and Reusability
Writing tests is one thing. Writing good tests that are fast, reliable, and maintainable is another. Two pytest features stand out as essential for achieving this: fixtures and mocking. Fixtures eliminate setup boilerplate and make tests more readable. Mocking isolates your code from external dependencies, making tests faster and more predictable. Together, they’re the foundation of professional test suites.
If you’ve ever struggled with slow tests that depend on external services, or found yourself copying setup code across multiple test files, this guide will transform how you write tests. We’ll explore both fixtures and mocking in depth, with practical examples you can apply immediately.
Understanding Fixtures and Mocking
What Are Fixtures?
Fixtures are reusable pieces of test setup. Instead of repeating initialization code in every test, you define it once in a fixture and inject it where needed. Think of them as test dependencies.
# Without fixtures: Repetitive setup
def test_user_creation():
db = Database()
db.connect()
user = User(db, 'john', '[email protected]')
assert user.name == 'john'
db.close()
def test_user_deletion():
db = Database()
db.connect()
user = User(db, 'john', '[email protected]')
user.delete()
assert not db.has_user('john')
db.close()
# With fixtures: Clean and reusable
@pytest.fixture
def db():
database = Database()
database.connect()
yield database
database.close()
def test_user_creation(db):
user = User(db, 'john', '[email protected]')
assert user.name == 'john'
def test_user_deletion(db):
user = User(db, 'john', '[email protected]')
user.delete()
assert not db.has_user('john')
What Is Mocking?
Mocking replaces real objects with test doubles that simulate their behavior. This isolates your code from external dependencies like APIs, databases, or file systems, making tests faster and more reliable.
# Without mocking: Depends on external API
def test_user_fetch():
user_service = UserService()
user = user_service.fetch_from_api(123) # Makes real HTTP request
assert user.name == 'John'
# With mocking: Isolated and fast
def test_user_fetch():
mock_api = Mock()
mock_api.get.return_value = {'name': 'John', 'id': 123}
user_service = UserService(api=mock_api)
user = user_service.fetch_from_api(123)
assert user.name == 'John'
Why They Matter
- Speed: Tests run faster without external I/O
- Reliability: No flaky tests due to network issues or external service changes
- Isolation: Test one thing at a time without side effects
- Maintainability: Reusable fixtures reduce code duplication
- Clarity: Tests focus on behavior, not setup details
Deep Dive: pytest Fixtures
Basic Fixture Syntax
The @pytest.fixture decorator transforms a function into a fixture:
import pytest
@pytest.fixture
def sample_user():
"""Provide a sample user for tests"""
return {'id': 1, 'name': 'John Doe', 'email': '[email protected]'}
def test_user_name(sample_user):
"""Test accessing user name"""
assert sample_user['name'] == 'John Doe'
def test_user_email(sample_user):
"""Test accessing user email"""
assert sample_user['email'] == '[email protected]'
Fixture Scope: Controlling Lifetime
Fixture scope determines how long a fixture persists:
import pytest
# Function scope (default): Fresh for each test
@pytest.fixture(scope='function')
def function_scoped():
print("Setup for function")
yield "data"
print("Teardown for function")
# Class scope: Shared across tests in a class
@pytest.fixture(scope='class')
def class_scoped():
print("Setup for class")
yield "data"
print("Teardown for class")
# Module scope: Shared across all tests in a module
@pytest.fixture(scope='module')
def module_scoped():
print("Setup for module")
yield "data"
print("Teardown for module")
# Session scope: Shared across entire test session
@pytest.fixture(scope='session')
def session_scoped():
print("Setup for session")
yield "data"
print("Teardown for session")
class TestExample:
def test_one(self, function_scoped, class_scoped):
pass
def test_two(self, function_scoped, class_scoped):
pass
Output:
Setup for function
Teardown for function
Setup for function
Teardown for function
Setup for class
Setup for module
Setup for session
Setup and Teardown with yield
Use yield to separate setup from cleanup:
import pytest
import tempfile
import os
@pytest.fixture
def temp_file():
"""Create and cleanup a temporary file"""
# Setup
fd, path = tempfile.mkstemp()
print(f"Created temp file: {path}")
yield path
# Teardown
os.close(fd)
os.remove(path)
print(f"Cleaned up temp file: {path}")
def test_file_write(temp_file):
"""Test writing to a file"""
with open(temp_file, 'w') as f:
f.write('test data')
with open(temp_file, 'r') as f:
content = f.read()
assert content == 'test data'
# File is automatically cleaned up after test
Fixture Dependencies
Fixtures can depend on other fixtures, creating a dependency chain:
@pytest.fixture
def database():
"""Mock database"""
db = {'users': []}
print("Database initialized")
yield db
print("Database cleaned up")
@pytest.fixture
def user_repository(database):
"""Repository that depends on database"""
class UserRepository:
def __init__(self, db):
self.db = db
def add_user(self, name):
self.db['users'].append(name)
def get_users(self):
return self.db['users']
return UserRepository(database)
@pytest.fixture
def user_service(user_repository):
"""Service that depends on repository"""
class UserService:
def __init__(self, repo):
self.repo = repo
def register_user(self, name):
self.repo.add_user(name)
return f"User {name} registered"
return UserService(user_repository)
def test_user_registration(user_service):
"""Test user registration through service"""
result = user_service.register_user('Alice')
assert result == "User Alice registered"
Parameterized Fixtures
Test multiple scenarios by parameterizing fixtures:
import pytest
@pytest.fixture(params=['admin', 'user', 'guest'])
def user_role(request):
"""Provide different user roles"""
return request.param
def test_user_permissions(user_role):
"""Test permissions for different roles"""
permissions = {
'admin': ['read', 'write', 'delete'],
'user': ['read', 'write'],
'guest': ['read']
}
assert user_role in permissions
assert len(permissions[user_role]) > 0
# This test runs 3 times, once for each role
conftest.py: Sharing Fixtures Across Files
Create a conftest.py file in your tests directory to share fixtures across multiple test files:
# tests/conftest.py
import pytest
from unittest.mock import Mock
@pytest.fixture
def api_client():
"""Shared API client mock"""
client = Mock()
client.get.return_value = {'status': 200}
client.post.return_value = {'status': 201}
return client
@pytest.fixture
def sample_user():
"""Shared user data"""
return {
'id': 1,
'name': 'John Doe',
'email': '[email protected]',
'role': 'admin'
}
@pytest.fixture
def sample_post():
"""Shared post data"""
return {
'id': 1,
'title': 'Test Post',
'content': 'This is a test post',
'author_id': 1
}
Now these fixtures are available in all test files:
# tests/test_users.py
def test_get_user(api_client, sample_user):
"""Test getting a user"""
api_client.get.return_value = sample_user
result = api_client.get('/users/1')
assert result['name'] == 'John Doe'
# tests/test_posts.py
def test_create_post(api_client, sample_post):
"""Test creating a post"""
api_client.post.return_value = sample_post
result = api_client.post('/posts', sample_post)
assert result['title'] == 'Test Post'
Fixture Autouse
Automatically apply fixtures without explicitly requesting them:
import pytest
@pytest.fixture(autouse=True)
def reset_state():
"""Automatically reset state before each test"""
print("Resetting state")
yield
print("State reset complete")
def test_one():
"""This test automatically uses reset_state"""
assert True
def test_two():
"""This test also automatically uses reset_state"""
assert True
Understanding Mocking
Mocks vs Stubs vs Fakes
Understanding the differences helps you choose the right tool:
from unittest.mock import Mock
# Stub: Provides canned responses
class UserRepositoryStub:
def get_user(self, user_id):
return {'id': 1, 'name': 'John'}
# Mock: Verifies interactions
mock_repo = Mock()
mock_repo.get_user.return_value = {'id': 1, 'name': 'John'}
mock_repo.get_user(1)
mock_repo.get_user.assert_called_once_with(1)
# Fake: Working implementation for testing
class UserRepositoryFake:
def __init__(self):
self.users = {}
def get_user(self, user_id):
return self.users.get(user_id)
def save_user(self, user):
self.users[user['id']] = user
Creating Mocks with unittest.mock
from unittest.mock import Mock, MagicMock, patch
# Basic Mock
mock_obj = Mock()
mock_obj.method.return_value = 'result'
assert mock_obj.method() == 'result'
# MagicMock: Supports magic methods
magic_mock = MagicMock()
magic_mock.__len__.return_value = 5
assert len(magic_mock) == 5
# Accessing call information
mock_obj.method('arg1', 'arg2')
mock_obj.method.assert_called_once_with('arg1', 'arg2')
assert mock_obj.method.call_count == 1
The patch Decorator
Replace objects in specific scopes:
from unittest.mock import patch, Mock
# Patch as decorator
@patch('requests.get')
def test_api_call(mock_get):
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {'data': 'test'}
import requests
response = requests.get('https://api.example.com')
assert response.status_code == 200
mock_get.assert_called_once_with('https://api.example.com')
# Patch as context manager
def test_api_call_context():
with patch('requests.get') as mock_get:
mock_get.return_value.status_code = 200
import requests
response = requests.get('https://api.example.com')
assert response.status_code == 200
Mocking External APIs
from unittest.mock import patch, Mock
import pytest
class WeatherService:
def __init__(self, api_client):
self.api_client = api_client
def get_temperature(self, city):
response = self.api_client.get(f'/weather/{city}')
return response['temperature']
@pytest.fixture
def mock_api_client():
"""Mock API client"""
return Mock()
def test_get_temperature(mock_api_client):
"""Test getting temperature from API"""
mock_api_client.get.return_value = {'temperature': 72, 'city': 'New York'}
service = WeatherService(mock_api_client)
temp = service.get_temperature('New York')
assert temp == 72
mock_api_client.get.assert_called_once_with('/weather/New York')
def test_api_error_handling(mock_api_client):
"""Test handling API errors"""
mock_api_client.get.side_effect = Exception("API Error")
service = WeatherService(mock_api_client)
with pytest.raises(Exception, match="API Error"):
service.get_temperature('New York')
Mocking Database Connections
from unittest.mock import Mock, patch
import pytest
class UserRepository:
def __init__(self, db_connection):
self.db = db_connection
def get_user(self, user_id):
cursor = self.db.cursor()
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
return cursor.fetchone()
def save_user(self, user):
cursor = self.db.cursor()
cursor.execute(f"INSERT INTO users VALUES ({user['id']}, '{user['name']}')")
self.db.commit()
@pytest.fixture
def mock_db():
"""Mock database connection"""
db = Mock()
cursor = Mock()
db.cursor.return_value = cursor
return db
def test_get_user(mock_db):
"""Test retrieving a user"""
mock_db.cursor().fetchone.return_value = {'id': 1, 'name': 'John'}
repo = UserRepository(mock_db)
user = repo.get_user(1)
assert user['name'] == 'John'
def test_save_user(mock_db):
"""Test saving a user"""
repo = UserRepository(mock_db)
repo.save_user({'id': 1, 'name': 'John'})
mock_db.cursor().execute.assert_called()
mock_db.commit.assert_called_once()
Mocking File System Operations
from unittest.mock import patch, mock_open, Mock
import pytest
class FileProcessor:
def read_file(self, filename):
with open(filename, 'r') as f:
return f.read()
def write_file(self, filename, content):
with open(filename, 'w') as f:
f.write(content)
def test_read_file():
"""Test reading a file"""
mock_file_content = "test content"
with patch('builtins.open', mock_open(read_data=mock_file_content)):
processor = FileProcessor()
content = processor.read_file('test.txt')
assert content == "test content"
def test_write_file():
"""Test writing a file"""
with patch('builtins.open', mock_open()) as mock_file:
processor = FileProcessor()
processor.write_file('test.txt', 'new content')
mock_file.assert_called_once_with('test.txt', 'w')
mock_file().write.assert_called_once_with('new content')
Mocking Time-Dependent Code
from unittest.mock import patch
from datetime import datetime
import pytest
class EventScheduler:
def is_event_today(self, event_date):
today = datetime.now().date()
return event_date.date() == today
def test_event_today():
"""Test event scheduling"""
event_date = datetime(2024, 12, 17)
with patch('datetime.datetime') as mock_datetime:
mock_datetime.now.return_value = datetime(2024, 12, 17, 10, 0, 0)
scheduler = EventScheduler()
assert scheduler.is_event_today(event_date) == True
def test_event_tomorrow():
"""Test event not today"""
event_date = datetime(2024, 12, 18)
with patch('datetime.datetime') as mock_datetime:
mock_datetime.now.return_value = datetime(2024, 12, 17, 10, 0, 0)
scheduler = EventScheduler()
assert scheduler.is_event_today(event_date) == False
Using pytest-mock Plugin
The pytest-mock plugin provides a cleaner interface to mocking:
pip install pytest-mock
Advantages of pytest-mock
import pytest
# Without pytest-mock: Manual cleanup
def test_without_pytest_mock():
with patch('requests.get') as mock_get:
mock_get.return_value.status_code = 200
# ...
# With pytest-mock: Automatic cleanup
def test_with_pytest_mock(mocker):
mock_get = mocker.patch('requests.get')
mock_get.return_value.status_code = 200
# Automatically cleaned up after test
def test_spy_on_method(mocker):
"""Spy on a method without replacing it"""
class Calculator:
def add(self, a, b):
return a + b
calc = Calculator()
spy = mocker.spy(calc, 'add')
result = calc.add(2, 3)
assert result == 5
spy.assert_called_once_with(2, 3)
Combining Fixtures and Mocking
import pytest
from unittest.mock import Mock
class UserService:
def __init__(self, repository, email_service):
self.repository = repository
self.email_service = email_service
def create_user(self, name, email):
user = {'name': name, 'email': email}
self.repository.save(user)
self.email_service.send_welcome_email(email)
return user
@pytest.fixture
def mock_repository():
"""Mock user repository"""
repo = Mock()
repo.save.return_value = None
return repo
@pytest.fixture
def mock_email_service():
"""Mock email service"""
service = Mock()
service.send_welcome_email.return_value = True
return service
@pytest.fixture
def user_service(mock_repository, mock_email_service):
"""User service with mocked dependencies"""
return UserService(mock_repository, mock_email_service)
def test_create_user(user_service, mock_repository, mock_email_service):
"""Test creating a user"""
user = user_service.create_user('John', '[email protected]')
assert user['name'] == 'John'
mock_repository.save.assert_called_once()
mock_email_service.send_welcome_email.assert_called_once_with('[email protected]')
Best Practices
Fixture Best Practices
import pytest
# โ
GOOD: Descriptive fixture names
@pytest.fixture
def authenticated_user():
return {'id': 1, 'name': 'John', 'authenticated': True}
# โ BAD: Vague fixture names
@pytest.fixture
def user():
return {'id': 1, 'name': 'John', 'authenticated': True}
# โ
GOOD: Fixtures with clear responsibility
@pytest.fixture
def database_connection():
"""Provide database connection"""
conn = Database()
conn.connect()
yield conn
conn.close()
# โ BAD: Fixtures doing too much
@pytest.fixture
def everything():
db = Database()
api = APIClient()
cache = Cache()
return {'db': db, 'api': api, 'cache': cache}
# โ
GOOD: Use fixture dependencies
@pytest.fixture
def user_repository(database_connection):
return UserRepository(database_connection)
# โ BAD: Fixtures creating their own dependencies
@pytest.fixture
def user_repository():
db = Database()
return UserRepository(db)
Mocking Best Practices
from unittest.mock import Mock, patch
import pytest
# โ
GOOD: Mock only what you need
def test_user_creation(mocker):
mock_email = mocker.patch('email_service.send')
user_service = UserService()
user = user_service.create_user('[email protected]')
assert user.email == '[email protected]'
# โ BAD: Over-mocking
def test_user_creation_bad(mocker):
mocker.patch('database.connect')
mocker.patch('cache.set')
mocker.patch('logger.info')
mocker.patch('email_service.send')
# Too many mocks make test hard to understand
# โ
GOOD: Verify important interactions
def test_payment_processing(mocker):
mock_payment_gateway = mocker.patch('payment.process')
mock_payment_gateway.return_value = {'status': 'success'}
service = PaymentService()
result = service.process_payment(100)
mock_payment_gateway.assert_called_once_with(100)
assert result['status'] == 'success'
# โ BAD: Verifying everything
def test_payment_processing_bad(mocker):
mock_payment = mocker.patch('payment.process')
mock_logger = mocker.patch('logger.info')
mock_cache = mocker.patch('cache.set')
service = PaymentService()
result = service.process_payment(100)
mock_payment.assert_called_once()
mock_logger.assert_called()
mock_cache.assert_called()
# Too many assertions make test brittle
Avoiding Common Pitfalls
import pytest
from unittest.mock import Mock, patch
# โ PITFALL: Forgetting to patch in the right place
def test_wrong_patch_location():
with patch('module.requests.get'): # Wrong!
pass
# โ
CORRECT: Patch where it's used
def test_correct_patch_location():
with patch('my_module.requests.get'): # Correct!
pass
# โ PITFALL: Not resetting mocks between tests
mock_obj = Mock()
def test_one():
mock_obj.method()
def test_two():
# mock_obj still has call history from test_one!
assert mock_obj.method.call_count == 1 # Fails!
# โ
CORRECT: Use fixtures for fresh mocks
@pytest.fixture
def fresh_mock():
return Mock()
def test_one(fresh_mock):
fresh_mock.method()
def test_two(fresh_mock):
# fresh_mock is brand new
assert fresh_mock.method.call_count == 0
# โ PITFALL: Mocking too broadly
def test_too_broad_mock(mocker):
mocker.patch('builtins.open') # Patches ALL file operations!
# This breaks pytest's own file operations
# โ
CORRECT: Mock specifically
def test_specific_mock(mocker):
mocker.patch('my_module.open') # Only patches in my_module
Real-World Example: Testing a Payment Service
# payment_service.py
class PaymentService:
def __init__(self, payment_gateway, notification_service, logger):
self.gateway = payment_gateway
self.notifier = notification_service
self.logger = logger
def process_payment(self, user_id, amount):
try:
self.logger.info(f"Processing payment for user {user_id}")
result = self.gateway.charge(user_id, amount)
if result['status'] == 'success':
self.notifier.send_confirmation(user_id, amount)
self.logger.info(f"Payment successful for user {user_id}")
return {'status': 'success', 'transaction_id': result['id']}
else:
self.logger.error(f"Payment failed for user {user_id}")
return {'status': 'failed', 'reason': result['error']}
except Exception as e:
self.logger.error(f"Payment error: {str(e)}")
raise
# test_payment_service.py
import pytest
from unittest.mock import Mock
@pytest.fixture
def mock_gateway():
"""Mock payment gateway"""
gateway = Mock()
gateway.charge.return_value = {'status': 'success', 'id': 'txn_123'}
return gateway
@pytest.fixture
def mock_notifier():
"""Mock notification service"""
return Mock()
@pytest.fixture
def mock_logger():
"""Mock logger"""
return Mock()
@pytest.fixture
def payment_service(mock_gateway, mock_notifier, mock_logger):
"""Payment service with mocked dependencies"""
return PaymentService(mock_gateway, mock_notifier, mock_logger)
def test_successful_payment(payment_service, mock_gateway, mock_notifier, mock_logger):
"""Test successful payment processing"""
result = payment_service.process_payment(user_id=1, amount=100)
assert result['status'] == 'success'
assert result['transaction_id'] == 'txn_123'
mock_gateway.charge.assert_called_once_with(1, 100)
mock_notifier.send_confirmation.assert_called_once_with(1, 100)
mock_logger.info.assert_called()
def test_failed_payment(payment_service, mock_gateway, mock_notifier, mock_logger):
"""Test failed payment processing"""
mock_gateway.charge.return_value = {'status': 'failed', 'error': 'Insufficient funds'}
result = payment_service.process_payment(user_id=1, amount=100)
assert result['status'] == 'failed'
mock_notifier.send_confirmation.assert_not_called()
mock_logger.error.assert_called()
def test_payment_exception(payment_service, mock_gateway, mock_logger):
"""Test payment exception handling"""
mock_gateway.charge.side_effect = Exception("Gateway error")
with pytest.raises(Exception, match="Gateway error"):
payment_service.process_payment(user_id=1, amount=100)
mock_logger.error.assert_called()
Conclusion
Fixtures and mocking are powerful tools that transform your testing approach:
- Fixtures eliminate boilerplate, improve readability, and make tests maintainable
- Mocking isolates your code, speeds up tests, and makes them reliable
- Together they enable you to write professional, production-grade test suites
Key takeaways:
- Use fixtures for setup and teardown, not just data
- Leverage fixture dependencies to build complex test scenarios
- Mock external dependencies, not your own code
- Keep mocks focused and verify important interactions
- Use
conftest.pyto share fixtures across test files - Prefer
pytest-mockfor cleaner, more maintainable mocking
Start applying these patterns in your tests today. You’ll write faster, more reliable tests that give you confidence in your code. Happy testing!
Comments