Skip to main content

Mock external dependencies in Python unittest

· 9 min read
Serhii Hrekov
software engineer, creator, artist, programmer, projects founder

External dependencies are a major source of pain in unit testing. They can be slow, unreliable, and difficult to control. Mocking these external services is the single most common and valuable use of the unittest.mock library [1]. This article will provide practical, hands-on examples for mocking three of the most frequent external dependencies: HTTP requests, database connections, and file system operations.

Mocking HTTP Requests

Your application likely communicates with external APIs. Making real network calls during testing is a bad practice. It makes tests slow, can incur costs, and relies on the external API being available and returning predictable data. We can use patch to replace the requests.get or requests.post functions with a mock object.

Example: Mocking an API call

Imagine a function that fetches a list of users from a remote API.

api_client.py

import requests

def get_users():
"""Fetches user data from a remote API."""
try:
response = requests.get('https://api.example.com/users')
response.raise_for_status() # Raise an exception for HTTP errors
return response.json()
except requests.exceptions.HTTPError as e:
print(f"API Error: {e}")
return None

To test this function, we will mock the requests.get call.

test_api_client.py

import unittest
from unittest.mock import patch, MagicMock
from api_client import get_users
import requests

class TestAPIClient(unittest.TestCase):

# Use @patch to replace requests.get in the module where it's used.
@patch('api_client.requests.get')
def test_get_users_success(self, mock_get):
# Configure the mock response object
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [{'id': 1, 'name': 'Alice'}]
mock_response.raise_for_status.return_value = None

# Make the mock object returned by `requests.get` be our mock_response
mock_get.return_value = mock_response

# Call the function under test
users = get_users()

# Assert the mock was called once with the correct URL
mock_get.assert_called_once_with('https://api.example.com/users')

# Assert the function returned the expected data
self.assertEqual(len(users), 1)
self.assertEqual(users[0]['name'], 'Alice')

@patch('api_client.requests.get')
def test_get_users_api_error(self, mock_get):
# Configure the mock to raise a real exception
mock_get.side_effect = requests.exceptions.HTTPError('404 Not Found')

users = get_users()

# Assert that the function correctly handled the error and returned None
self.assertIsNone(users)

The @patch decorator is crucial here. The string 'api_client.requests.get' tells Python to find the requests module as it is imported inside the api_client module and replace its get function with a mock [5].


Mocking Database Connections

Testing database-dependent code can be slow and require a live database. We can mock the database connection to simulate queries and results without ever touching a real database.

Example: Mocking a database client

Consider a function that retrieves a user from a database using a simple client.

db_client.py

import sqlite3

class DatabaseClient:
def __init__(self, db_path):
self.conn = sqlite3.connect(db_path)
self.cursor = self.conn.cursor()

def get_user_by_id(self, user_id):
self.cursor.execute("SELECT name FROM users WHERE id=?", (user_id,))
result = self.cursor.fetchone()
return result[0] if result else None

To test this, we will mock the database connection and cursor objects. This is a great use case for MagicMock as it can simulate the chained calls of connect().cursor().fetchone() [2, 6].

test_db_client.py

import unittest
from unittest.mock import patch, MagicMock
from db_client import DatabaseClient

class TestDatabaseClient(unittest.TestCase):

# Patch the `sqlite3.connect` function in the module where it's used
@patch('db_client.sqlite3.connect')
def test_get_user_by_id_exists(self, mock_connect):
# Configure the chain of mocked objects
mock_cursor = MagicMock()
mock_connect.return_value.cursor.return_value = mock_cursor

# Set the return value of fetchone() to simulate a database row
mock_cursor.fetchone.return_value = ('Alice',)

db_client = DatabaseClient('test_db.sqlite')
user_name = db_client.get_user_by_id(1)

# Assert that the query was executed correctly
mock_cursor.execute.assert_called_once_with("SELECT name FROM users WHERE id=?", (1,))

# Assert the function returned the expected result
self.assertEqual(user_name, 'Alice')

@patch('db_client.sqlite3.connect')
def test_get_user_by_id_not_found(self, mock_connect):
mock_cursor = MagicMock()
mock_connect.return_value.cursor.return_value = mock_cursor

# Simulate a database returning no results
mock_cursor.fetchone.return_value = None

db_client = DatabaseClient('test_db.sqlite')
user_name = db_client.get_user_by_id(999)

# Assert the function handled the "not found" case
self.assertIsNone(user_name)

By patching sqlite3.connect, we intercept the first call in the chain, allowing us to control the behavior of all subsequent calls and return values without a real connection.


Mocking File System Operations

File I/O can be slow and introduce state, as a test might read from or write to a real file, affecting subsequent tests. unittest.mock provides a special helper function, mock_open, specifically for this purpose [7].

Example: Mocking file reads and writes

Consider a function that reads a configuration file and another that writes to a log file.

file_operations.py

def read_config(file_path):
"""Reads a configuration from a file."""
with open(file_path, 'r') as f:
return f.read()

def write_log(message, file_path='app.log'):
"""Appends a message to a log file."""
with open(file_path, 'a') as f:
f.write(message + '\n')

We can test these functions using mock_open.

test_file_operations.py

import unittest
from unittest.mock import patch, mock_open, call
from file_operations import read_config, write_log

class TestFileOperations(unittest.TestCase):

@patch('builtins.open', new_callable=mock_open, read_data='key: value')
def test_read_config(self, mock_file):
config = read_config('config.txt')

# The mock_open helper is configured to return 'key: value'
self.assertEqual(config, 'key: value')

# We can still assert how it was used
mock_file.assert_called_once_with('config.txt', 'r')

@patch('builtins.open', new_callable=mock_open)
def test_write_log(self, mock_file):
write_log('Test message')

# Assert the file was opened in append mode
mock_file.assert_called_once_with('app.log', 'a')

# The `mock_file.return_value` is the mock file handle
# Assert that the write method on the file handle was called
mock_file.return_value.write.assert_called_once_with('Test message\n')

The new_callable=mock_open argument to patch is key. It replaces the built-in open function with a special mock object that understands file-like operations such as read(), write(), and context management (__enter__, __exit__).

This article has demonstrated the power of mocking for isolating your code from external services. In the next article, we will explore more advanced topics, including mocking entire classes and the key differences between mocks, stubs, and fakes.

Sources

  1. Real Python - The Python Mocking Library
  2. Test-Driven Development with Python and Django
  3. Python Mock vs MagicMock
  4. Python unittest.mock documentation
  5. Where to patch in Python
  6. Mocking Database Connections in Python
  7. unittest.mock.mock_open helper

This video provides a great visual explanation of mocking HTTP requests in Python: Intro to Python Mocks | Python tutorial