4. Mocking external dependencies
The Music Time Machine talks to Spotify constantly: fetching top tracks, refreshing tokens, creating playlists. Testing that code seems to require a working Spotify connection, but real API calls in tests create four problems pytest can't solve. Mocking replaces Spotify with a fake you control, and turns those four problems into a controlled boundary you can reason about.
Why mocking matters
Real API calls in tests fail four ways. Each one shows up in the suite eventually; the combination kills the suite's value as a regression detector.
- Slow. Each Spotify call adds 100-500ms of network latency. Twenty tests with three calls each takes 6-30 seconds at a minimum. Professional suites should run in under five seconds; slow suites get skipped, which defeats the purpose.
- Flaky. Network failures, rate limits, service outages, and token expirations cause tests to fail for reasons unrelated to your code. A test that passes this morning fails this afternoon because Spotify's API is having issues. The suite stops being trustworthy.
- Expensive. Spotify rate-limits API calls. Running tests against the real API consumes calls that should go to development and production use. Some APIs charge per request; testing could cost real money.
- Can't simulate errors. How do you test rate-limit handling without triggering a real rate limit? How do you test 401 handling without deliberately invalidating your tokens? Real APIs don't cooperate with the failures you need to test.
Mocking solves all four. Instead of calling Spotify, your test calls a Mock object that pretends to be Spotify. You define exactly what the mock returns, how long it takes (instantly), and what errors it raises. The Music Time Machine has two boundaries that need mocking: Chapter 16's per-feature scripts (which use the Spotipy library), and Chapter 17's SpotifyClient (which uses the requests library directly for OAuth).
Mock target: Spotipy in Chapter 16's scripts
Chapter 16's create_monthly_snapshot(sp, conn) takes a Spotipy client as its first argument and uses it to fetch the user's top tracks. The function is doing real work (writing to the database), but everything it learns about Spotify comes through the sp argument. That makes it ideal for mocking: replace the sp with a Mock that returns a fake response, hand it to the function, then verify the function wrote the right rows.
Add this test to tests/test_features.py:
from unittest.mock import MagicMock
import sqlite3
from monthly_snapshots import create_monthly_snapshot
def test_create_monthly_snapshot_writes_rows_for_each_track():
"""Mock Spotipy returning two tracks; assert two snapshot rows written."""
# Arrange -- mock Spotipy with a fake top-tracks response.
sp = MagicMock()
sp.current_user_top_tracks.return_value = {
'items': [
{
'id': 'track_a',
'name': 'Karma Police',
'artists': [{'name': 'Radiohead'}],
'album': {
'name': 'OK Computer',
'images': [{'url': 'https://i.scdn.co/image/track_a'}],
},
'duration_ms': 261000,
'external_urls': {'spotify': 'https://open.spotify.com/track/track_a'},
},
{
'id': 'track_b',
'name': 'Paranoid Android',
'artists': [{'name': 'Radiohead'}],
'album': {
'name': 'OK Computer',
'images': [{'url': 'https://i.scdn.co/image/track_b'}],
},
'duration_ms': 383000,
'external_urls': {'spotify': 'https://open.spotify.com/track/track_b'},
},
]
}
# In-memory database with the schema create_monthly_snapshot expects.
# The next page introduces a seeded_db fixture that covers this; for
# this first mocking example we keep the setup inline so the test runs
# without forward-referencing fixtures we haven't built yet.
conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE tracks (
track_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
duration_ms INTEGER NOT NULL,
album_image_url TEXT,
spotify_url TEXT,
raw_json TEXT
);
CREATE TABLE snapshots (
track_id TEXT NOT NULL,
snapshot_date DATE NOT NULL,
time_range TEXT NOT NULL,
rank INTEGER NOT NULL,
PRIMARY KEY (track_id, snapshot_date, time_range)
);
""")
# Act
status = create_monthly_snapshot(sp, conn)
# Assert -- mock got called exactly once with the expected args
sp.current_user_top_tracks.assert_called_once_with(
limit=50, time_range='short_term'
)
# And the function wrote two snapshot rows
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM snapshots")
assert cursor.fetchone()[0] == 2
conn.close()
Run it:
pytest tests/test_features.py -v
tests/test_features.py::test_create_monthly_snapshot_writes_rows_for_each_track PASSED [100%]
========================== 1 passed in 0.04s ===========================
Three things worth pausing on. First, MagicMock() is the most permissive mock type; any attribute access or method call works without configuration, which is useful here because Spotipy clients have many methods and we only care about one. Second, return_value sets what the mocked method returns when called. Third, assert_called_once_with verifies the function called Spotipy with the right parameters; if a future refactor changes limit=50 to limit=100, this test catches it.
The mock runs instantly (no network), works offline, never hits rate limits. The test still exercises the real database write logic; only the Spotify boundary is faked.
Simulating errors with side_effect
The side_effect attribute is where mocks earn their keep. Setting side_effect to an exception makes the mock raise that exception when called -- which lets you test error-handling paths that real APIs won't cooperate with.
Chapter 16's retry_with_backoff function should retry on transient errors and re-raise after the configured maximum. Test that explicitly:
from unittest.mock import MagicMock, patch
import pytest
from errors import TransientError
from retry import retry_with_backoff
def test_retry_succeeds_after_two_transient_failures():
"""Mock callable: fails twice then returns 'ok'. retry_with_backoff returns 'ok'."""
flaky = MagicMock(side_effect=[
TransientError("timeout"),
TransientError("connection reset"),
"ok",
])
# Patch time.sleep so the test doesn't actually wait 1+2 seconds.
with patch("retry.time.sleep"):
result = retry_with_backoff(flaky, max_attempts=3, base_delay=1.0)
assert result == "ok"
assert flaky.call_count == 3
def test_retry_re_raises_after_max_attempts():
"""Mock callable raises every call; retry_with_backoff re-raises after attempts exhausted."""
always_fails = MagicMock(side_effect=TransientError("server unreachable"))
with patch("retry.time.sleep"):
with pytest.raises(TransientError, match="server unreachable"):
retry_with_backoff(always_fails, max_attempts=3, base_delay=1.0)
assert always_fails.call_count == 3
Two patterns worth pulling out. side_effect can take a list, in which case each call returns or raises the next item, useful for "fail twice, succeed on the third try" scenarios. The patch("retry.time.sleep") context manager replaces time.sleep inside the retry module with a mock for the duration of the with block. With max_attempts=3, that's two sleeps between attempts (1s + 2s = 3s without the patch), so the patched test finishes in milliseconds rather than three seconds. Both tests assert the call count to verify the retry loop went around the right number of times.
Mock target: SpotifyClient (OAuth boundary)
Chapter 17's SpotifyClient sits in spotify_client.py and handles three OAuth operations: building the authorisation URL, exchanging an authorisation code for a token pair, and refreshing the access token. The first method is pure (string concatenation), so it's a unit test from the previous page. The two HTTP-call methods need mocks because they POST to https://accounts.spotify.com/api/token.
The cleanest mock target is requests.post: replace it at the module boundary, control the response, then assert SpotifyClient handled the response correctly. Add this to tests/test_spotify_client.py:
from unittest.mock import MagicMock, patch
import pytest
import requests
from spotify_client import SpotifyClient
def test_exchange_code_returns_token_payload(monkeypatch):
"""Successful token exchange returns the JSON payload Spotify sends."""
# Arrange -- credentials via env vars; mock requests.post to return a fake response.
monkeypatch.setenv('SPOTIFY_CLIENT_ID', 'fake_id')
monkeypatch.setenv('SPOTIFY_CLIENT_SECRET', 'fake_secret')
fake_response = MagicMock()
fake_response.json.return_value = {
'access_token': 'AT_xxx',
'refresh_token': 'RT_xxx',
'expires_in': 3600,
'token_type': 'Bearer',
}
fake_response.raise_for_status = MagicMock() # no-op on success
client = SpotifyClient()
# Act
with patch('spotify_client.requests.post', return_value=fake_response) as mocked:
tokens = client.exchange_code_for_token(
code='AUTH_CODE_xxx',
redirect_uri='http://127.0.0.1:5000/callback',
)
# Assert
assert tokens['access_token'] == 'AT_xxx'
assert tokens['refresh_token'] == 'RT_xxx'
assert tokens['expires_in'] == 3600
mocked.assert_called_once()
# The POST must hit Spotify's token endpoint with HTTP Basic auth.
posted_url = mocked.call_args.args[0]
assert posted_url == 'https://accounts.spotify.com/api/token'
def test_refresh_token_handles_missing_refresh_in_response(monkeypatch):
"""Spotify sometimes returns a new refresh_token, sometimes doesn't.
SpotifyClient must handle both shapes."""
monkeypatch.setenv('SPOTIFY_CLIENT_ID', 'fake_id')
monkeypatch.setenv('SPOTIFY_CLIENT_SECRET', 'fake_secret')
fake_response = MagicMock()
fake_response.json.return_value = {
'access_token': 'AT_new',
'expires_in': 3600,
'token_type': 'Bearer',
# No refresh_token -- Spotify omitted it on this refresh.
}
fake_response.raise_for_status = MagicMock()
client = SpotifyClient()
with patch('spotify_client.requests.post', return_value=fake_response):
result = client.refresh_access_token('OLD_REFRESH_TOKEN')
assert result['access_token'] == 'AT_new'
assert 'refresh_token' not in result # Caller must preserve the old one.
def test_exchange_code_propagates_401(monkeypatch):
"""Bad client credentials -> requests raises HTTPError, SpotifyClient lets it through."""
monkeypatch.setenv('SPOTIFY_CLIENT_ID', 'wrong_id')
monkeypatch.setenv('SPOTIFY_CLIENT_SECRET', 'wrong_secret')
fake_response = MagicMock()
fake_response.raise_for_status = MagicMock(
side_effect=requests.HTTPError('401 Client Error: Unauthorized')
)
client = SpotifyClient()
with patch('spotify_client.requests.post', return_value=fake_response):
with pytest.raises(requests.HTTPError, match='401'):
client.exchange_code_for_token(code='x', redirect_uri='y')
Run them:
pytest tests/test_spotify_client.py -v
tests/test_spotify_client.py::test_exchange_code_returns_token_payload PASSED [ 33%]
tests/test_spotify_client.py::test_refresh_token_handles_missing_refresh_in_response PASSED [ 66%]
tests/test_spotify_client.py::test_exchange_code_propagates_401 PASSED [100%]
========================== 3 passed in 0.06s ===========================
Three takeaways from these tests. monkeypatch.setenv is pytest's built-in fixture for overriding environment variables per test; the override is reverted automatically when the test finishes, so other tests don't inherit fake credentials. The patch('spotify_client.requests.post', ...) form patches at the module-import boundary, which is why Chapter 17 ships spotify_client.py as a separate file in the first place: the boundary is stable. And raise_for_status is the surface we mock to simulate non-2xx responses, because that's the method Chapter 17's SpotifyClient actually calls.
Reusable mock fixtures in conftest.py
Authoring full mock setup in every test is tedious and error-prone. The pytest fixture mechanism fixes that: define the setup once in conftest.py, then accept the fixture name as a test argument. The tests/conftest.py file is special; pytest loads it automatically and shares its fixtures across every test file in the directory.
# tests/conftest.py
import pytest
from unittest.mock import MagicMock
@pytest.fixture
def mock_spotify_top_tracks():
"""A pre-configured Spotipy mock returning two known top tracks."""
sp = MagicMock()
sp.current_user_top_tracks.return_value = {
'items': [
{
'id': 'track_a',
'name': 'Karma Police',
'artists': [{'name': 'Radiohead'}],
'album': {
'name': 'OK Computer',
'images': [{'url': 'https://i.scdn.co/image/track_a'}],
},
'duration_ms': 261000,
'external_urls': {'spotify': 'https://open.spotify.com/track/track_a'},
},
{
'id': 'track_b',
'name': 'Paranoid Android',
'artists': [{'name': 'Radiohead'}],
'album': {
'name': 'OK Computer',
'images': [{'url': 'https://i.scdn.co/image/track_b'}],
},
'duration_ms': 383000,
'external_urls': {'spotify': 'https://open.spotify.com/track/track_b'},
},
]
}
return sp
@pytest.fixture
def mock_token_response():
"""A pre-configured fake token-exchange response."""
fake_response = MagicMock()
fake_response.json.return_value = {
'access_token': 'AT_xxx',
'refresh_token': 'RT_xxx',
'expires_in': 3600,
'token_type': 'Bearer',
}
fake_response.raise_for_status = MagicMock()
return fake_response
Now the snapshot test from earlier shrinks: accept mock_spotify_top_tracks as a parameter and skip the per-test mock setup.
def test_create_monthly_snapshot_writes_rows(mock_spotify_top_tracks, seeded_db):
"""Same test as before, half the setup."""
cursor = seeded_db.cursor()
cursor.execute("SELECT COUNT(*) FROM snapshots")
rows_before = cursor.fetchone()[0]
create_monthly_snapshot(mock_spotify_top_tracks, seeded_db)
cursor.execute("SELECT COUNT(*) FROM snapshots WHERE track_id IN ('track_a', 'track_b')")
assert cursor.fetchone()[0] == 2
cursor.execute("SELECT COUNT(*) FROM snapshots")
assert cursor.fetchone()[0] == rows_before + 2
Two fixtures, one cleaner test. The seeded_db fixture comes from the next page (database testing); the snapshot test sits at the boundary between mock-Spotify and real-SQLite, which is exactly the kind of place fixtures pay off.
What mocks don't fix
Mocks are the right tool for the Spotify boundary, but they're the wrong tool for the database. Mocking sqlite3.connect and asserting that the right SQL gets passed in tests the wrong thing -- you'd be verifying that your code constructs the string you expected, not that the SQL actually does what you think. Database tests need a real database; the next page covers in-memory SQLite for that.