5. Testing database operations
Database tests need a real database, not a mock. You're verifying that SQL behaves correctly, which mocking can't tell you. SQLite's :memory: mode gives you real query parsing, joins, constraints, and transactions, with no disk I/O and no file to clean up.
Why in-memory SQLite, not mocks
Mocking sqlite3 would test your Python code's structure, not its SQL semantics. On a real database, a bad column name, broken join, or missing constraint fails exactly the way it would fail in the app. Each test gets a fresh in-memory connection, so isolation is automatic.
The Music Time Machine now has two core Chapter 16 tables: tracks and snapshots. The track row also stores raw_json, which preserves the original Spotify payload for later JSON work. Every database test needs those tables present, and most need a few rows seeded for realism.
Two fixtures: in_memory_db and seeded_db
Add these to tests/conftest.py alongside the mock fixtures from the previous page:
import pytest
import sqlite3
@pytest.fixture
def in_memory_db():
"""Empty in-memory SQLite with the supported Chapter 16 schema."""
conn = sqlite3.connect(':memory:')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.executescript("""
CREATE TABLE tracks (
track_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT,
duration_ms INTEGER,
spotify_url TEXT,
raw_json TEXT
);
CREATE TABLE snapshots (
track_id TEXT NOT NULL,
snapshot_date TEXT NOT NULL,
time_range TEXT NOT NULL,
rank INTEGER NOT NULL,
PRIMARY KEY (track_id, snapshot_date, time_range),
FOREIGN KEY (track_id) REFERENCES tracks(track_id)
);
""")
conn.commit()
yield conn
conn.close()
@pytest.fixture
def seeded_db(in_memory_db):
"""In-memory db pre-populated with three months of snapshots."""
conn = in_memory_db
cursor = conn.cursor()
tracks = [
('track_1', 'Karma Police', 'Radiohead', 'OK Computer', 261000, None, '{}'),
('track_2', 'Paranoid Android', 'Radiohead', 'OK Computer', 383000, None, '{}'),
('track_3', 'Bohemian Rhapsody', 'Queen', 'A Night at the Opera', 355000, None, '{}'),
('track_4', 'Yesterday', 'The Beatles', 'Help!', 125000, None, '{}'),
('track_5', 'Mr. Brightside', 'The Killers', 'Hot Fuss', 222000, None, '{}'),
]
cursor.executemany(
"INSERT INTO tracks VALUES (?, ?, ?, ?, ?, ?, ?)", tracks
)
snapshots = []
for date, ids in [
('2026-01-15', ['track_1', 'track_2', 'track_3']),
('2026-02-15', ['track_1', 'track_3', 'track_4']),
('2026-03-15', ['track_1', 'track_4', 'track_5']),
]:
for rank, tid in enumerate(ids, start=1):
snapshots.append((tid, date, 'short_term', rank))
cursor.executemany(
"""INSERT INTO snapshots
(track_id, snapshot_date, time_range, rank)
VALUES (?, ?, ?, ?)""",
snapshots,
)
conn.commit()
return conn
Two fixtures, two responsibilities. in_memory_db sets up the schema and yields the connection; the yield form runs cleanup after the test finishes. seeded_db composes that fixture and adds realistic snapshot history on top.
Test target: calculate_taste_stats
Chapter 18's calculate_taste_stats returns dashboard-card values. The interesting branch is the empty-state behaviour: when fewer than two snapshots exist, turnover_pct should be None rather than a misleading zero.
from unittest.mock import patch
from app import calculate_taste_stats
def test_taste_stats_empty_db_returns_zero_tracks(in_memory_db):
with patch('app.get_db_connection', return_value=in_memory_db):
stats = calculate_taste_stats()
assert stats['tracks_tracked'] == 0
assert stats['turnover_pct'] is None
assert stats['consistent_artist'] is None
def test_taste_stats_seeded_db_returns_track_count(seeded_db):
with patch('app.get_db_connection', return_value=seeded_db):
stats = calculate_taste_stats()
assert stats['tracks_tracked'] == 5
assert stats['turnover_pct'] is not None
assert stats['consistent_artist'] == 'Radiohead'
def test_taste_stats_one_snapshot_returns_none_turnover(in_memory_db):
cursor = in_memory_db.cursor()
cursor.execute(
"INSERT INTO tracks VALUES ('only', 'A Track', 'An Artist', 'An Album', 180000, NULL, '{}')"
)
cursor.execute(
"""INSERT INTO snapshots
(track_id, snapshot_date, time_range, rank)
VALUES ('only', '2026-03-15', 'short_term', 1)"""
)
in_memory_db.commit()
with patch('app.get_db_connection', return_value=in_memory_db):
stats = calculate_taste_stats()
assert stats['tracks_tracked'] == 1
assert stats['turnover_pct'] is None
Test target: get_taste_chart_data
The home dashboard chart now uses the supported snapshot schema only. It returns discovery counts: tracks whose first-ever snapshot appearance happened in each month.
from app import get_taste_chart_data
def test_chart_data_returns_discovery_mode(seeded_db):
with patch('app.get_db_connection', return_value=seeded_db):
data = get_taste_chart_data()
assert data['mode'] == 'discoveries'
assert len(data['datasets']) == 1
assert data['datasets'][0]['label'] == 'New tracks'
assert data['labels'] == ['Jan 2026', 'Feb 2026', 'Mar 2026']
def test_chart_data_empty_db_returns_discovery_mode_with_no_points(in_memory_db):
with patch('app.get_db_connection', return_value=in_memory_db):
data = get_taste_chart_data()
assert data['mode'] == 'discoveries'
assert data['labels'] == []
assert data['datasets'][0]['data'] == []
Test target: _get_database_status
Chapter 18's _get_database_status reads row counts for the Settings page. It should report the supported tables and tolerate an empty database.
from app import _get_database_status
def test_status_seeded_db_reports_real_counts(seeded_db):
with patch('app.get_db_connection', return_value=seeded_db):
with patch('app.session', {'access_token': 'fake'}):
status = _get_database_status()
assert status['tracks_count'] == 5
assert status['snapshots_count'] == 9
assert status['is_connected'] is True
def test_status_empty_db_reports_zero_counts(in_memory_db):
with patch('app.get_db_connection', return_value=in_memory_db):
with patch('app.session', {}):
status = _get_database_status()
assert status['tracks_count'] == 0
assert status['snapshots_count'] == 0
assert status['is_connected'] is False
Test target: find_forgotten_gems
Chapter 16's find_forgotten_gems applies three date windows: tracks last seen between 90 and 365 days ago, excluding tracks seen in the last 30 days. The useful tests pin those boundaries with frozen dates.
from freezegun import freeze_time
from forgotten_gems import find_forgotten_gems
@freeze_time("2026-06-01")
def test_forgotten_gems_finds_old_but_not_recent_tracks(in_memory_db):
cursor = in_memory_db.cursor()
cursor.execute(
"INSERT INTO tracks VALUES ('gem', 'Old Favourite', 'Artist', 'Album', 180000, NULL, '{}')"
)
cursor.execute(
"INSERT INTO tracks VALUES ('recent', 'Still Around', 'Artist', 'Album', 180000, NULL, '{}')"
)
cursor.executemany(
"""INSERT INTO snapshots
(track_id, snapshot_date, time_range, rank)
VALUES (?, ?, 'short_term', 1)""",
[
('gem', '2026-01-15'),
('recent', '2026-01-15'),
('recent', '2026-05-20'),
],
)
in_memory_db.commit()
gems = find_forgotten_gems(in_memory_db, limit=10)
ids = {row['track_id'] for row in gems}
assert ids == {'gem'}
These tests exercise real SQL while keeping the suite fast. That is the sweet spot: realistic enough to catch schema/query mistakes, isolated enough to run on every save.