Skip to main content
โšก Calmops

Mocking and Fixtures in pytest: Master Test Isolation and Reusability

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.py to share fixtures across test files
  • Prefer pytest-mock for 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