Testing¶
The test suite lives under tests/ and is split into two layers:
| Layer | Location | Runner | Needs a browser? |
|---|---|---|---|
| Unit (pure logic + cache/DB I/O) | tests/test_*.py |
pytest |
No |
| Frontend e2e (dashboard) | tests/frontend/ |
pytest + Playwright |
Yes (Chromium) |
Both run automatically in CI and via ./precommit.sh.
Quick Reference¶
# Unit tests (fast, no browser, no network)
.venv/bin/python -m pytest --ignore=tests/frontend
# Unit tests with coverage (matches CI)
.venv/bin/python -m pytest --ignore=tests/frontend --cov --cov-report=term
# Frontend e2e (requires the web + web-docs extras + a browser)
pip install -e ".[dev,web,web-docs]"
python -m playwright install chromium
.venv/bin/python -m pytest tests/frontend
Unit tests¶
Unit tests are pure and deterministic - no network, no real YouTube Music / Last.fm calls. File-backed components (caches, the history DB, the .env parser) are tested against pytest's tmp_path fixture, and the few helpers that take a YTMusic client use a small in-test fake. They run in a couple of seconds.
| Area | Test file | Covers |
|---|---|---|
| Search matching | test_normalization.py, test_queries.py, test_scoring.py, test_similarity.py |
Text normalization, query building, candidate scoring, fuzzy similarity |
| Search resolution | test_search_resolver.py |
Three-tier priority (override → cache → API), negative caching, blacklist skip, duplicate collapse (stubbed find_on_ytm) |
| Recency | test_weighting.py |
Exponential-decay weighting and collapse |
| Tags | test_tag_filter.py, test_tag_cache.py, test_tags_resolver.py |
Tag filtering, TagCache TTL, TagOverrides add/replace merge, cache-first tag resolution (stubbed fetch_track_tags) |
| Observability | test_failure_log.py, test_history_recording.py |
Failure/run-log writes + hint mapping, miss classification into the history DB |
| Weekly | test_weekly.py |
Weekly playlist naming and prefix derivation |
| HTTP status | test_http_status.py |
Upstream error classification (retryable / rate-limit / terminal) |
| Config | test_config.py |
Env parsers, Settings.from_env(), load_custom_playlists |
| Cache | test_json_cache.py, test_search_cache.py, test_playlist_cache.py |
Atomic writes, TTL eviction, the template_changed sync gate |
| Playlist sync | test_sync_helpers.py |
Retry/backoff, video-ID validation, reorder, substitution detection |
| History DB | test_history_db.py |
Tracks/syncs/actions CRUD, stats, backfill, prune, export/import |
| Web backend (services) | test_env_file.py, test_theme_overrides.py, test_update_check.py, test_web_data.py, test_notifications_store.py |
.env round-tripping, theme sanitising, version parsing, dashboard data-shaping (web/services/data.py), notification store (add/dedup/prune/delete/clear/mark-read) |
| Web backend (routes) | test_web_routes.py |
Flask endpoints exercised through test_client: read APIs (/api/stats, /api/cache-stats, /api/overrides, /api/settings, /api/scheduler/status, /api/cache/summary, …), validation branches (settings cron/start-time, custom-playlist cleaning, cache-bulk key checks, /api/panel/<unknown> 404), and form actions (/blacklist, /override, /tag_override, /export+/import), plus the notification routes |
Web-backend tests guard on Flask
Every web/-touching test file (tests/test_env_file.py, tests/test_theme_overrides.py, tests/test_update_check.py, tests/test_web_data.py, tests/test_web_routes.py, tests/test_notifications_store.py) starts with pytest.importorskip("flask"), because the CI unit job installs only .[dev] (no web extra). They skip cleanly there and run locally / in the tests job where the web extra is present - the same pattern the frontend suite uses for Playwright.
Shared web fixtures
tests/conftest.py provides web_paths (redirects every cache/config/.env/notification file into tmp_path and forces the settings fallback), flask_app (the dashboard app with a stub FLASK_SECRET_KEY so it never writes a real .env), and client (a test_client). Flask is imported lazily inside those fixtures, so the dependency-light unit run is unaffected.
What the web tests deliberately skip¶
The route/service tests cover only the file-backed, offline-deterministic logic. The following are intentionally left to manual runs and the frontend e2e layer because mocking a live YouTube Music session, outbound HTTP, or a subprocess costs more than it's worth:
/api/now-playing,/api/image-proxy- live Last.fm / image-CDN HTTP./api/webhook/test- outbound webhook POST./api/restart- sends process signals / exits the worker./api/track-detailand the history routes - only meaningful with a populated history DB (covered bytest_history_db.py).auth_bp/sync_bp- require a YouTube Music session or spawn the sync subprocess./api/setup/init,/api/setup/lastfm- copy/write real example files outside the patched paths; the underlyingenvhelpers are unit-tested intest_env_file.py.delete_custom_playlist_data(..., delete_from_ytm=True)- instantiatesytmusicapi.YTMusic.- The SSE stream endpoint (
events_bp) - needs a long-lived streaming client; the broadcast call is exercised indirectly by the notification-store tests.
Frontend e2e tests¶
tests/frontend/test_dashboard.py drives the real dashboard with Playwright. It reuses the Flask fixture server and stubbed API routes from tests/screenshots/generate.py, so the page renders with deterministic demo data and never touches real credentials or the live APIs. The whole module is skipped automatically when Playwright (or the web/web-docs extra) is missing, keeping the default unit run dependency-light.
These cover DOM wiring - tab switching, modal/drawer opening, and a no-uncaught-error smoke check on load. There is no JavaScript unit-test framework; the pure JS logic that exists (date formatting, filter predicates) is small and is exercised through this e2e layer.
Coverage¶
Coverage is configured in pyproject.toml under [tool.coverage.run] with source = ["src", "web"] and branch = true. Both the core (src/) and the web backend (web/) are measured, so the web-backend tests above move the reported number.
The deliberately-untested remainder is the API / network / orchestration glue (src/workflows/, src/lastfm/fetch.py, src/ytm/operations.py, src/search/executor.py, src/tags/sync.py) plus the web layers that need a live session, outbound HTTP, or a subprocess (web/routes/auth.py, web/routes/sync.py, web/services/scheduler.py, web/services/teleporter.py, web/services/update_check.py's network paths - see What the web tests deliberately skip). These are thin wrappers over external services where the mocking cost outweighs the value; they are verified manually via python run.py or the dashboard. The pure logic inside the resolver/observability layers (src/search/resolver.py, src/tags/resolver.py, src/observability/failure_log.py, src/observability/history_recording.py) and the dashboard data/route layers (web/services/data.py, web/routes/api.py, web/routes/actions.py, web/services/notifications.py) is unit-tested directly by stubbing or redirecting the I/O each performs.
Writing tests¶
- Put pure-logic tests in
tests/test_<module>.py. No docstrings or magic-number assertions are required there (DandPLR2004are ignored fortests/test_*.pyinpyproject.toml). - Use
tmp_pathfor anything that touches the filesystem - never write into the repo's realcache/orconfig/. - Keep tests offline. If a function calls YouTube Music or Last.fm, either test a pure helper it delegates to or pass a small fake client.
- If a test imports from
web/, guard the module withpytest.importorskip("flask"). For route/service tests, reuse theweb_paths/flask_app/clientfixtures fromtests/conftest.pyinstead of touching real files. - Run
./precommit.shbefore pushing; it runs Ruff, both pytest layers, and the rest of the checks.