7. Testing with mocks
The Music Time Machine works end-to-end, but every change to the codebase risks breaking something three features over, and manual re-testing means hitting Spotify's API for every check. This section automates the regression net with mocks: replace every Spotify call with a fake that returns controlled data, run the suite in milliseconds, and verify the edge cases (empty results, malformed track payloads, rate-limit responses, time-dependent logic) you can't reliably trigger against the live API. By the end you'll have a unittest suite that runs without internet, without OAuth tokens, and without consuming a single rate-limit quota point.
Three reasons mocks are mandatory for API-dependent code. Speed: a real API call takes hundreds of milliseconds; a mock returns instantly, so a 50-test suite finishes in under a second. Isolation: tests pass or fail based on your code, not Spotify's uptime or your network connection. Determinism: mocks let you control what "Spotify returned this," which is the only way to write a test for "what happens when Spotify returns no tracks?" without waiting for the real API to misbehave.
The trick is to keep the feature code unchanged and swap only the objects it receives. In production, the function gets a real Spotify client and a real database connection. In tests, those same parameter slots receive controlled replacements:
Both runs pass objects into the same sp and conn parameters.
The function under test does not know whether they are real or fake.
Why testing matters for this project
The Music Time Machine accumulates valuable data over months. A bug that corrupts your database or deletes snapshots loses irreplaceable history. Automated tests prevent these disasters by catching bugs before they reach production.
- Regression Prevention. When you add a new feature (like a date-filtered analytics view), tests ensure existing features still work. Without tests, you might accidentally break Forgotten Gems while adjusting snapshot queries, and not discover the bug until months later when you try to generate a forgotten gems playlist.
- Edge Case Coverage. Manual testing hits the happy path (everything works). Tests cover edge cases: what happens when Spotify returns empty results? When track metadata is missing? When the database is locked? Tests verify your error handling actually works.
- Refactoring Confidence. Want to optimise database queries or restructure code? Tests let you refactor fearlessly. If tests pass after changes, behaviour is preserved. If tests fail, you know exactly what broke.
- Documentation Through Examples. Tests document how code should behave. Reading
test_forgotten_gems_with_no_recent_plays()shows exactly what the forgotten gems feature should return when you have old snapshots but no recent ones.
Professional developers write tests not because they enjoy testing, but because untested code breaks in production and causes real problems. The time investment in writing tests pays off the first time tests catch a bug before users encounter it.
Test setup and structure
Python's unittest framework provides everything you need for testing. Create a tests/ directory alongside your main code and organise tests by feature.
music_time_machine/
├── music_time_machine.py # Main application (the Section 5 menu)
├── quick_start.py # Section 2 quick win
├── forgotten_gems.py # Forgotten Gems feature
├── monthly_snapshots.py # Snapshots + save_track
├── analytics.py # Musical evolution analytics
├── playlist_utils.py # Shared publish_playlist() helper
├── errors.py # Custom exceptions (Section 6)
├── diagnose.py # Database diagnostics (Section 6)
├── init_db.py # One-time DB setup (runs schema.sql)
├── schema.sql # Database schema
├── .env # Credentials (gitignored)
├── tests/
│ ├── __init__.py
│ ├── test_forgotten_gems.py
│ ├── test_monthly_snapshots.py
│ └── test_analytics.py
└── requirements.txt
The first test file is the longest because it shows the full shape: create a clean database, arrange known snapshot data, call the feature function, and assert the result. Read it for that pattern first; the later test files reuse the same idea with different dependencies.
import unittest
import sqlite3
from datetime import date, timedelta
from unittest.mock import Mock, patch
import sys
import os
# Add parent directory to path so we can import our modules
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from forgotten_gems import find_forgotten_gems, create_forgotten_gems_playlist
class TestForgottenGems(unittest.TestCase):
"""Test suite for Forgotten Gems feature"""
def setUp(self):
"""Run before each test - create in-memory database"""
# Use in-memory database for test isolation
self.conn = sqlite3.connect(':memory:')
# Create schema
self.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
);
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)
);
""")
def tearDown(self):
"""Run after each test - cleanup"""
self.conn.close()
def test_finds_tracks_from_old_snapshots_not_in_recent(self):
"""Should return tracks from 90-365 days ago that aren't in last 30 days"""
# Arrange: Insert test data
today = date.today()
old_date = (today - timedelta(days=180)).isoformat()
recent_date = (today - timedelta(days=10)).isoformat()
# Track that appears in old snapshot only
self.conn.execute(
"INSERT INTO tracks VALUES (?, ?, ?, ?, ?)",
('track1', 'Old Favourite', 'Artist A', 'Album A', 240000)
)
self.conn.execute(
"INSERT INTO snapshots VALUES (?, ?, ?, ?)",
('track1', old_date, 'short_term', 1)
)
# Track that appears in both (should be excluded)
self.conn.execute(
"INSERT INTO tracks VALUES (?, ?, ?, ?, ?)",
('track2', 'Still Playing', 'Artist B', 'Album B', 200000)
)
self.conn.execute(
"INSERT INTO snapshots VALUES (?, ?, ?, ?)",
('track2', old_date, 'short_term', 2)
)
self.conn.execute(
"INSERT INTO snapshots VALUES (?, ?, ?, ?)",
('track2', recent_date, 'short_term', 1)
)
self.conn.commit()
# Act: Call function
forgotten = find_forgotten_gems(self.conn, limit=10, now=today)
# Assert: Check results
self.assertEqual(len(forgotten), 1)
self.assertEqual(forgotten[0][1], 'Old Favourite') # Check track name
self.assertEqual(forgotten[0][2], 'Artist A') # Check artist
def test_returns_empty_list_when_no_old_snapshots(self):
"""Should return empty list when no snapshots exist from 90-365 days ago"""
# Arrange: Only recent snapshot
today = date.today()
recent_date = (today - timedelta(days=5)).isoformat()
self.conn.execute(
"INSERT INTO tracks VALUES (?, ?, ?, ?, ?)",
('track1', 'Recent Track', 'Artist A', 'Album A', 240000)
)
self.conn.execute(
"INSERT INTO snapshots VALUES (?, ?, ?, ?)",
('track1', recent_date, 'short_term', 1)
)
self.conn.commit()
# Act
forgotten = find_forgotten_gems(self.conn, limit=10, now=today)
# Assert
self.assertEqual(len(forgotten), 0)
def test_respects_limit_parameter(self):
"""Should return at most 'limit' tracks even if more qualify"""
# Arrange: Insert 10 forgotten tracks
today = date.today()
old_date = (today - timedelta(days=180)).isoformat()
for i in range(10):
track_id = f'track{i}'
self.conn.execute(
"INSERT INTO tracks VALUES (?, ?, ?, ?, ?)",
(track_id, f'Track {i}', 'Artist', 'Album', 240000)
)
self.conn.execute(
"INSERT INTO snapshots VALUES (?, ?, ?, ?)",
(track_id, old_date, 'short_term', i+1)
)
self.conn.commit()
# Act: Request only 5
forgotten = find_forgotten_gems(self.conn, limit=5, now=today)
# Assert
self.assertEqual(len(forgotten), 5)
if __name__ == '__main__':
unittest.main()
What just happened: test structure
Each test follows the Arrange-Act-Assert pattern. Arrange: Set up test data (insert tracks and snapshots with specific dates). Act: Call the function you're testing. Assert: Verify results match expectations.
The setUp() method runs before each test and creates a fresh in-memory database. This ensures tests don't interfere with each other. Each test gets its own clean database with the schema created but no data. The tearDown() method cleans up after each test.
These chapter tests create just enough schema for the behaviour under test, so the table definitions are intentionally small and local. Chapter 19 replaces these ad-hoc setup blocks with shared fixtures that load the project schema consistently.
Test names describe what they verify: test_finds_tracks_from_old_snapshots_not_in_recent documents the expected behaviour. Someone reading tests understands what forgotten gems should do without reading implementation code.
# Run all tests
python -m unittest discover tests/
# Run specific test file
python -m unittest tests.test_forgotten_gems
# Run specific test
python -m unittest tests.test_forgotten_gems.TestForgottenGems.test_respects_limit_parameter
# Run with verbose output
python -m unittest discover tests/ -v
...
----------------------------------------------------------------------
Ran 3 tests in 0.012s
OK
Three dots mean three tests passed. If a test fails, you see detailed output showing which assertion failed and what the actual vs expected values were.
Mocking Spotify API calls
Testing functions that call Spotify's API requires mocking. You replace the real Spotipy client with a mock object that returns fake data you control. This lets tests run without network calls, authentication, or rate limits.
import unittest
from unittest.mock import Mock, patch
import sqlite3
from datetime import date
from monthly_snapshots import create_monthly_snapshot, save_track
class TestMonthlySnapshots(unittest.TestCase):
def setUp(self):
"""Create in-memory database that mirrors the production schema."""
self.conn = sqlite3.connect(':memory:')
self.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 CHECK(time_range IN ('short_term', 'medium_term', 'long_term')),
rank INTEGER NOT NULL CHECK(rank >= 1 AND rank <= 50),
PRIMARY KEY (track_id, snapshot_date, time_range),
FOREIGN KEY (track_id) REFERENCES tracks(track_id) ON DELETE CASCADE
);
""")
# Foreign keys are per-connection and off by default; tests must mirror
# production, where every connection enables them.
self.conn.execute("PRAGMA foreign_keys = ON")
def tearDown(self):
self.conn.close()
def test_creates_snapshot_with_mocked_spotify(self):
"""Should fetch tracks from Spotify, save to database, and create playlist"""
# Arrange: Create mock Spotify client
mock_sp = Mock()
# Mock the API responses
mock_sp.current_user_top_tracks.return_value = {
'items': [
{
'id': 'track1',
'name': 'Test Track 1',
'artists': [{'name': 'Artist A'}],
'album': {
'name': 'Album A',
'images': [{'url': 'http://example.com/image1.jpg'}]
},
'duration_ms': 240000,
'external_urls': {'spotify': 'https://open.spotify.com/track/track1'}
},
{
'id': 'track2',
'name': 'Test Track 2',
'artists': [{'name': 'Artist B'}],
'album': {
'name': 'Album B',
'images': [{'url': 'http://example.com/image2.jpg'}]
},
'duration_ms': 200000,
'external_urls': {'spotify': 'https://open.spotify.com/track/track2'}
}
]
}
mock_sp.current_user_playlist_create.return_value = {
'id': 'playlist123',
'external_urls': {'spotify': 'https://open.spotify.com/playlist/playlist123'}
}
# Act: Call function with mock
playlist_url, track_count = create_monthly_snapshot(
mock_sp,
self.conn,
time_range='short_term'
)
# Assert: Verify tracks were saved to database
cursor = self.conn.execute("SELECT COUNT(*) FROM tracks")
self.assertEqual(cursor.fetchone()[0], 2)
cursor = self.conn.execute("SELECT COUNT(*) FROM snapshots")
self.assertEqual(cursor.fetchone()[0], 2)
# Assert: Verify Spotify methods were called correctly
mock_sp.current_user_top_tracks.assert_called_once_with(
limit=50,
time_range='short_term'
)
mock_sp.current_user_playlist_create.assert_called_once()
mock_sp.playlist_add_items.assert_called_once()
# Assert: Verify return values
self.assertIsNotNone(playlist_url)
self.assertEqual(track_count, 2)
def test_prevents_duplicate_snapshots_same_day(self):
"""Should refuse to create second snapshot on same day"""
# Arrange: Create existing snapshot
today = date.today().isoformat()
self.conn.execute(
"INSERT INTO tracks (track_id, name, artist_name, album_name, duration_ms) "
"VALUES (?, ?, ?, ?, ?)",
('track1', 'Track', 'Artist', 'Album', 240000)
)
self.conn.execute(
"INSERT INTO snapshots VALUES (?, ?, ?, ?)",
('track1', today, 'short_term', 1)
)
self.conn.commit()
mock_sp = Mock()
# Act: Try to create another snapshot
playlist_url, track_count = create_monthly_snapshot(
mock_sp,
self.conn,
time_range='short_term'
)
# Assert: Should return None (refused to create duplicate)
self.assertIsNone(playlist_url)
self.assertEqual(track_count, 0)
# Assert: Spotify API should not have been called
mock_sp.current_user_top_tracks.assert_not_called()
def test_propagates_spotify_api_error_before_hardening(self):
"""Before Section 6 hardening, Spotify API failures bubble up."""
# Arrange: Mock that raises exception
mock_sp = Mock()
mock_sp.current_user_top_tracks.side_effect = Exception("Spotify API error")
# Act & Assert: The happy-path version does not handle this yet.
with self.assertRaises(Exception):
create_monthly_snapshot(mock_sp, self.conn)
def test_resaving_track_preserves_old_snapshots(self):
"""Re-saving an existing track must NOT delete its historical snapshots."""
# A track with one historical snapshot.
self.conn.execute(
"INSERT INTO tracks (track_id, name, artist_name, album_name, duration_ms) "
"VALUES ('abc', 'Old Song', 'Artist', 'Album', 200000)"
)
self.conn.execute(
"INSERT INTO snapshots (track_id, snapshot_date, time_range, rank) "
"VALUES ('abc', '2026-05-01', 'short_term', 1)"
)
self.conn.commit()
# The same track shows up again later; its metadata gets refreshed.
save_track(self.conn, {
'id': 'abc',
'name': 'Old Song (Remaster)',
'artists': [{'name': 'Artist'}],
'album': {'name': 'Album', 'images': []},
'duration_ms': 200000,
'external_urls': {'spotify': 'https://open.spotify.com/track/abc'},
})
self.conn.commit()
# INSERT OR REPLACE would have cascade-deleted this row; UPSERT keeps it.
count = self.conn.execute(
"SELECT COUNT(*) FROM snapshots WHERE track_id = 'abc'"
).fetchone()[0]
self.assertEqual(count, 1, "Old snapshot was deleted -- REPLACE cascade regression")
name = self.conn.execute(
"SELECT name FROM tracks WHERE track_id = 'abc'"
).fetchone()[0]
self.assertEqual(name, 'Old Song (Remaster)', "Metadata was not refreshed")
if __name__ == '__main__':
unittest.main()
What just happened: creating mocks
The Mock() object from unittest.mock creates a fake object that records how it's called. When you write mock_sp.current_user_top_tracks.return_value = {...}, you're telling the mock "when this method is called, return this data."
The mock acts like a real Spotipy client. Your function calls sp.current_user_top_tracks(), the mock returns your fake data, and your function processes it normally. The test verifies your function saved the data correctly and called Spotify methods with the right parameters.
assert_called_once: Verifies the method was called exactly once. assert_called_once_with: Verifies the method was called with specific arguments. assert_not_called: Verifies the method was never called. These assertions ensure your function interacts with the API correctly.
Testing analytics queries
Analytics tests verify that the summary queries behave correctly against controlled snapshot history. You do not need the live Spotify API for this: the interesting work is in SQLite, so the tests use an in-memory database with known tracks, artists, and snapshot dates.
import unittest
import sqlite3
from analytics import get_monthly_trends, get_consistent_artists
class TestAnalytics(unittest.TestCase):
def setUp(self):
"""Create database with tracks and dated snapshots."""
self.conn = sqlite3.connect(':memory:')
self.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,
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)
);
""")
tracks = [
('track1', 'January Song', 'Artist A', 'Album A'),
('track2', 'Still Here', 'Artist A', 'Album B'),
('track3', 'New Discovery', 'Artist B', 'Album C'),
]
for track_id, name, artist, album in tracks:
self.conn.execute(
"""INSERT INTO tracks
(track_id, name, artist_name, album_name, duration_ms, spotify_url, raw_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(track_id, name, artist, album, 240000, None, '{}')
)
snapshots = [
('track1', '2026-01-01', 'short_term', 1),
('track2', '2026-01-01', 'short_term', 2),
('track2', '2026-02-01', 'short_term', 1),
('track3', '2026-02-01', 'short_term', 2),
]
self.conn.executemany(
"""INSERT INTO snapshots
(track_id, snapshot_date, time_range, rank)
VALUES (?, ?, ?, ?)""",
snapshots
)
self.conn.commit()
def tearDown(self):
self.conn.close()
def test_monthly_trends_count_tracks_and_artists(self):
"""Should aggregate distinct tracks and artists per month."""
trends = get_monthly_trends(self.conn)
self.assertEqual(len(trends), 2)
self.assertEqual(trends[0]['month'], '2026-01')
self.assertEqual(trends[0]['unique_tracks'], 2)
self.assertEqual(trends[0]['unique_artists'], 1)
self.assertEqual(trends[1]['month'], '2026-02')
self.assertEqual(trends[1]['unique_tracks'], 2)
self.assertEqual(trends[1]['unique_artists'], 2)
def test_consistent_artists_rank_by_snapshot_appearances(self):
"""Should identify artists that recur across snapshots."""
artists = get_consistent_artists(self.conn, limit=5)
self.assertEqual(artists[0]['artist_name'], 'Artist A')
self.assertEqual(artists[0]['appearances'], 2)
self.assertEqual(artists[1]['artist_name'], 'Artist B')
self.assertEqual(artists[1]['appearances'], 1)
def test_empty_database_returns_empty_lists(self):
"""Should not crash when no snapshot history exists."""
empty = sqlite3.connect(':memory:')
empty.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
);
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)
);
""")
self.assertEqual(get_monthly_trends(empty), [])
self.assertEqual(get_consistent_artists(empty), [])
empty.close()
if __name__ == '__main__':
unittest.main()
What just happened: testing SQL logic
These tests verify the SQL queries that turn snapshot history into user-facing summaries. You insert tracks with known artists and dates, then verify the analytics helpers return the exact counts and ordering the dashboard depends on.
The test data is deliberately small but meaningful: one artist appears across multiple rows, another appears once, and two months have different artist diversity. That gives you enough signal to catch broken joins, missing DISTINCT clauses, and accidental changes to date grouping.
The empty-database test matters as much as the populated one. A brand-new Music Time Machine should show an empty state, not crash because the first snapshot has not been captured yet.
Testing time-dependent logic
The forgotten gems feature depends on dates: it finds tracks from 90-365 days ago that aren't in the last 30 days. Testing date logic is tricky because dates change every day. A test that passes today might fail tomorrow when dates shift.
The solution is controlling time in tests. You explicitly set snapshot dates to known values (180 days ago, 10 days ago) rather than using relative dates. This makes tests deterministic regardless of when they run.
import sqlite3
from datetime import date, timedelta
import unittest
class TestTimeDependentLogic(unittest.TestCase):
def test_boundary_conditions_for_dates(self):
"""Test edge cases at date boundaries"""
conn = sqlite3.connect(':memory:')
conn.executescript("""
CREATE TABLE tracks (track_id TEXT PRIMARY KEY, name TEXT,
artist_name TEXT, album_name TEXT, duration_ms INTEGER);
CREATE TABLE snapshots (track_id TEXT, snapshot_date DATE,
time_range TEXT, rank INTEGER);
""")
today = date.today()
# Test track exactly 90 days ago (should be included)
exactly_90_days = (today - timedelta(days=90)).isoformat()
conn.execute("INSERT INTO tracks VALUES ('track1', 'Name', 'Artist', 'Album', 240000)")
conn.execute("INSERT INTO snapshots VALUES ('track1', ?, 'short_term', 1)", (exactly_90_days,))
# Test track exactly 365 days ago (should be included)
exactly_365_days = (today - timedelta(days=365)).isoformat()
conn.execute("INSERT INTO tracks VALUES ('track2', 'Name2', 'Artist', 'Album', 240000)")
conn.execute("INSERT INTO snapshots VALUES ('track2', ?, 'short_term', 1)", (exactly_365_days,))
# Test track 89 days ago (too recent, should be excluded)
days_89_ago = (today - timedelta(days=89)).isoformat()
conn.execute("INSERT INTO tracks VALUES ('track3', 'Name3', 'Artist', 'Album', 240000)")
conn.execute("INSERT INTO snapshots VALUES ('track3', ?, 'short_term', 1)", (days_89_ago,))
# Test track 366 days ago (too old, should be excluded)
days_366_ago = (today - timedelta(days=366)).isoformat()
conn.execute("INSERT INTO tracks VALUES ('track4', 'Name4', 'Artist', 'Album', 240000)")
conn.execute("INSERT INTO snapshots VALUES ('track4', ?, 'short_term', 1)", (days_366_ago,))
conn.commit()
# Act: Find forgotten gems (pin `now` so the window matches our seeded dates)
from forgotten_gems import find_forgotten_gems
forgotten = find_forgotten_gems(conn, limit=10, now=today)
# Assert: Should include tracks at exactly 90 and 365 days, exclude 89 and 366
self.assertEqual(len(forgotten), 2)
track_names = [track[1] for track in forgotten]
self.assertIn('Name', track_names) # 90 days
self.assertIn('Name2', track_names) # 365 days
self.assertNotIn('Name3', track_names) # 89 days (too recent)
self.assertNotIn('Name4', track_names) # 366 days (too old)
conn.close()
Why boundary testing matters
Date boundaries are easy to get wrong. Is "90 days ago" inclusive or exclusive? What about "365 days ago"? Boundary tests verify that tracks at exactly 90 days and exactly 365 days are handled correctly.
SQLite's BETWEEN operator is inclusive on both ends. find_forgotten_gems binds window_start and window_end as parameters and the BETWEEN matches both endpoints, so a snapshot dated exactly 90 days before now qualifies, and so does one dated exactly 365 days before. The test verifies this by inserting tracks at exact boundaries and checking they appear in results.
These tests are deterministic. They run identically today, tomorrow, and next year because they use explicit dates calculated from date.today() rather than hardcoded strings.
Measuring test coverage
Test coverage measures what percentage of your code is executed during tests. High coverage doesn't guarantee good tests (you can execute code without verifying it works correctly), but low coverage guarantees untested code. Use Python's coverage tool to measure coverage.
# Install coverage tool
pip install coverage
# Run tests with coverage tracking
coverage run -m unittest discover tests/
# Generate coverage report
coverage report
# Generate detailed HTML report
coverage html
# Open htmlcov/index.html in browser to see line-by-line coverage
Name Stmts Miss Cover
----------------------------------------------
forgotten_gems.py 52 4 92%
monthly_snapshots.py 63 7 89%
analytics.py 48 6 88%
playlist_utils.py 11 0 100%
music_time_machine.py 89 25 72%
----------------------------------------------
TOTAL 263 42 84%
This report shows forgotten_gems.py has 92% test coverage (52 statements, only 4 untested). The untested lines are usually error-handling edge cases or rarely-used code paths. The lower number on music_time_machine.py (the interactive menu) is expected: input loops and print formatting are not worth exhaustive tests. Aim for 80-90% coverage on core logic. Don't obsess over 100% coverage.
What to test vs what to skip
Test thoroughly: Core logic (date calculations, snapshot comparisons, data transformations), database queries (CRUD operations, complex queries), edge cases (empty results, missing data, boundary conditions).
Test lightly: Error messages (verify they exist, don't verify exact wording), UI code (print statements, menu displays), trivial getters/setters.
Don't test: External libraries (Spotipy, SQLite), Python standard library functions, configuration files, generated code.
Running tests automatically
Professional projects run tests automatically on every code change. When you push code to GitHub, automated systems (GitHub Actions, GitLab CI, CircleCI) run your test suite and report failures immediately. This catches bugs before they reach production.
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install coverage
- name: Run tests
run: |
coverage run -m unittest discover tests/
coverage report
coverage xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
Save this file in your repository. Every time you push code, GitHub Actions runs your tests automatically and reports results. The Codecov upload step requires a CODECOV_TOKEN repository secret; if you are not using Codecov, remove that final step and keep the local coverage report. Failed tests block pull requests from merging, preventing broken code from reaching production.