7. Time-dependent logic and coverage
Three Music Time Machine functions read the wall clock and you don't control their signatures: _cutoff_for (Chapter 18's date-range filter), refresh_token_if_needed (Chapter 17's OAuth token guard), and create_monthly_snapshot (Chapter 16's idempotency-within-day check). Tests against them are non-deterministic by default; tomorrow's run produces tomorrow's output. freezegun makes them deterministic by replacing datetime.now() with a fixed value for the duration of a test, no signature change required.
The problem freezegun solves
Two pages ago, find_forgotten_gems took a now parameter and the test passed now=date(2026, 5, 8). That's the cleanest pattern when you can change the function under test. But you don't always control the function. _cutoff_for already shipped without a now parameter and has live callers in app.py; backfitting one would ripple through Chapter 18's analytics-filtering route. Mocking the clock directly with unittest.mock.patch works but is verbose and breaks when the function under test imports datetime from a different module than you patched. freezegun handles the patching globally for the duration of a test: every datetime.now(), every datetime.utcnow(), every time.time() returns the frozen value. Your code runs unchanged; time just doesn't advance.
The library is already in your test dependencies (pytest setup page covered the install). Use it via the @freeze_time decorator on a test function, or as a context manager inside a test for finer control.
Test target: _cutoff_for (Chapter 18)
_cutoff_for is the cleanest freezegun example. It maps a ?range= parameter to an ISO date string by subtracting the right number of days from datetime.now(). With time frozen, the output is exactly predictable.
from freezegun import freeze_time
from app import _cutoff_for
@freeze_time("2026-05-08")
def test_cutoff_for_6m_returns_180_days_ago():
"""6m -> 180 days before 2026-05-08 -> 2025-11-09."""
assert _cutoff_for('6m') == '2025-11-09'
@freeze_time("2026-05-08")
def test_cutoff_for_12m_returns_365_days_ago():
"""12m -> 365 days before 2026-05-08 -> 2025-05-08."""
assert _cutoff_for('12m') == '2025-05-08'
@freeze_time("2026-05-08")
def test_cutoff_for_all_returns_none():
"""'all' has no cutoff; the helper returns None."""
assert _cutoff_for('all') is None
@freeze_time("2026-05-08")
def test_cutoff_for_unknown_value_falls_back_to_12m_equivalent():
"""Unknown values default to 12m (365 days), not None or an error."""
assert _cutoff_for('garbage') == '2025-05-08'
Four tests, four mapped values, every output exactly predictable. The decorator freezes time before each test runs and unfreezes after; tests stay isolated. A test failing here means either the mapping changed (RANGE_MAP is the canonical source) or the date arithmetic broke -- both worth knowing about.
Test target: refresh_token_if_needed (Chapter 17)
Chapter 17's refresh_token_if_needed reads the wall clock to decide whether the current access token is close enough to expiry to trigger a refresh -- specifically, if token_expires_at minus the current time is less than 300 seconds (the 5-minute buffer). With time frozen, the boundary is testable directly.
from unittest.mock import patch
from freezegun import freeze_time
import time
from app import refresh_token_if_needed, app as flask_app
@freeze_time("2026-05-08 12:00:00")
def test_refresh_called_when_token_expires_within_5_min():
"""token_expires_at = now + 200s -> within the 300s buffer -> refresh fires."""
now_ts = time.time()
with flask_app.test_request_context():
from flask import session
session['access_token'] = 'old_AT'
session['refresh_token'] = 'RT_xxx'
session['token_expires_at'] = now_ts + 200 # Inside the buffer.
with patch('app.SpotifyClient') as MockClient:
MockClient.return_value.refresh_access_token.return_value = {
'access_token': 'new_AT', 'expires_in': 3600,
}
refresh_token_if_needed()
MockClient.return_value.refresh_access_token.assert_called_once()
@freeze_time("2026-05-08 12:00:00")
def test_refresh_skipped_when_token_has_more_than_5_min_left():
"""token_expires_at = now + 1000s -> well outside buffer -> no refresh call."""
now_ts = time.time()
with flask_app.test_request_context():
from flask import session
session['access_token'] = 'AT_xxx'
session['refresh_token'] = 'RT_xxx'
session['token_expires_at'] = now_ts + 1000
with patch('app.SpotifyClient') as MockClient:
refresh_token_if_needed()
MockClient.return_value.refresh_access_token.assert_not_called()
Two tests, two boundaries: inside the 5-minute window (refresh fires) and outside (no refresh). The 5-minute buffer is a Chapter 17 design decision (token_refresh_buffer_seconds = 300); tests pin it. If the buffer ever changes deliberately, both tests update; if it changes accidentally, one of them fails.
Test target: create_monthly_snapshot idempotency-within-day
Chapter 16's create_monthly_snapshot uses a composite-uniqueness constraint to skip duplicate writes when run twice on the same day. With freezegun you can pin "today," call the function twice, and confirm only one write made it through:
@freeze_time("2026-05-08")
def test_snapshot_is_idempotent_within_a_day(mock_spotify_top_tracks, in_memory_db):
"""Two calls on the same frozen day -> only the first writes rows."""
create_monthly_snapshot(mock_spotify_top_tracks, in_memory_db)
create_monthly_snapshot(mock_spotify_top_tracks, in_memory_db)
cursor = in_memory_db.cursor()
cursor.execute("SELECT COUNT(*) FROM snapshots")
assert cursor.fetchone()[0] == 2 # Two tracks from the mock, written once each.
The test exercises three layers at once: the mocked Spotify boundary (no network), the in-memory database (real SQL), and the frozen clock (deterministic snapshot_date). Pure unit tests can't pin this contract because the contract spans both the helper and the database constraint. This is what integration tests are for.
Measuring coverage
Coverage measures what percentage of your code runs during tests. Useful for finding untested paths; not useful as a goal in itself. Run pytest with --cov to see the report:
pytest --cov=. --cov-report=term-missing
========================= test session starts ==========================
collected 47 items
tests/test_auth.py ..... [ 10%]
tests/test_database.py ......... [ 29%]
tests/test_features.py ... [ 36%]
tests/test_helpers.py ................. [ 72%]
tests/test_routes.py .......... [ 93%]
tests/test_spotify_client.py ... [100%]
---------- coverage: platform linux, python 3.11 ----------
Name Stmts Miss Cover Missing
--------------------------------------------------------
app.py 208 21 90% 142-148, 305-318
forgotten_gems.py 45 3 93% 62-64
monthly_snapshots.py 38 4 89% 71-74
retry.py 28 0 100%
spotify_client.py 40 3 92% 55-57
--------------------------------------------------------
TOTAL 359 31 91%
========================== 47 passed in 3.42s ==========================
The Missing column tells you which line numbers weren't executed. app.py:142-148 might be the HTTPError branch in /playlists POST -- testable but currently uncovered. app.py:305-318 might be a Settings action's exception handler. Coverage points; tests fix.
Coverage's limits
High coverage doesn't guarantee good tests. A test that calls every line of code without asserting anything meaningful gets 100% coverage and finds zero bugs. Coverage is necessary but insufficient: it tells you what's untested, not whether what's tested is correct.
Use coverage as a finder for untested paths, not a target. If the report shows 60% coverage, that's a meaningful gap to investigate. If it shows 95%, don't chase the missing 5% unless those lines protect against real failures. Quality assertions matter more than percentage points.
GitHub Actions: testing on every push
The last step is wiring the suite into CI so it runs automatically on every push and pull request. Create .github/workflows/tests.yml:
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.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-cov pytest-mock freezegun
- name: Run tests with coverage
run: pytest --cov=. --cov-report=term-missing
Push this file alongside the test suite and GitHub runs it on every commit. Failing tests show up as red checks on commits and pull requests; passing tests show as green. Add a coverage badge to the README and anyone landing on the repo can see the suite passes before reading any code, a small signal but a real one.
The deployment handoff
The suite you've built proves the code works, but proves it on your machine, in your environment, with your music_time_machine.db. Chapter 20 covers the next step: deploying the application to a public URL where someone else can prove it works for them. The tests don't go away in deployment -- they become the regression-detection layer that catches problems before they reach production. The review page locks in what carries forward.