2. Setting up your test environment
Install pytest plus four supporting libraries, lay out a tests/ folder that mirrors what Chapters 16, 17, and 18 actually shipped (single-file app.py plus per-feature scripts), configure pytest's discovery, and run a sanity-check test to prove the wiring works.
Installing pytest and supporting libraries
Python includes a built-in testing framework called unittest, but the Python community has largely standardised on pytest, which provides cleaner syntax, better error messages, more powerful fixtures, and simpler assertion statements. Most professional Python projects use pytest, so that's what you'll use here.
Install pytest and its ecosystem of useful plugins:
pip install pytest pytest-cov pytest-mock freezegun
These packages provide everything you need for comprehensive testing:
- pytest: The core testing framework with clean syntax and powerful features
- pytest-cov: Measures test coverage to identify untested code paths
- pytest-mock: Simplifies mocking with pytest-specific fixtures and helpers
- freezegun: Freezes time in tests so date-dependent logic produces predictable results
Verify the installation by running pytest's help command:
pytest --version
pytest 8.0.0
Creating your test structure
Create a test directory structure that mirrors your application code. This organization makes it easy to find tests for specific modules and helps pytest discover tests automatically.
mkdir -p tests
touch tests/__init__.py
touch tests/conftest.py
touch tests/test_helpers.py
touch tests/test_database.py
touch tests/test_routes.py
touch tests/test_auth.py
touch tests/test_spotify_client.py
touch tests/test_features.py
Your project structure should now look like this:
music-time-machine/
├── app.py # Chapters 17-18: routes, helpers, all in one file
├── spotify_client.py # Chapter 17: OAuth client (separate module)
├── monthly_snapshots.py # Chapter 16: snapshot script
├── forgotten_gems.py # Chapter 16: forgotten-gems generator
├── analytics.py # Chapter 16: analytics CLI helper
├── retry.py # Chapter 16: retry_with_backoff
├── errors.py # Chapter 16: exception hierarchy
├── tracks_db.py # Chapter 16: save_track + safe_database_operation
├── initialize_db.py # Chapter 16: schema initialiser
├── templates/
├── static/
├── music_time_machine.db
└── tests/
├── __init__.py
├── conftest.py # shared fixtures (in-memory db, app client, authenticated client)
├── test_helpers.py # pure functions: _format_month, _build_playlist_query, normalise_track
├── test_database.py # in-memory db fixtures: calculate_taste_stats, get_taste_chart_data
├── test_routes.py # Flask test client: /analytics, /playlists, /settings/*
├── test_auth.py # @require_auth decorator + OAuth state CSRF
├── test_spotify_client.py # mocked SpotifyClient OAuth methods
└── test_features.py # Chapter 16 scripts: forgotten gems, snapshots, analytics
The directory structure mirrors the application code with a one-test-file-per-concern split. test_helpers.py covers the pure functions; test_database.py covers the SQL-touching helpers with in-memory fixtures; test_routes.py covers Flask's HTTP layer; test_auth.py isolates the auth decorator + OAuth state work because both want fixture support; test_spotify_client.py covers the OAuth client class with mocks; test_features.py covers Chapter 16's per-feature scripts. conftest.py is special -- pytest loads it automatically and makes its fixtures available to every test file in the directory, which is where the in-memory db setup will live.
The framework discovers tests by naming convention: files starting with test_, classes starting with Test, functions starting with test_. The defaults find everything once you run pytest at the project root; the pytest.ini in the next section pins a few settings explicitly so they don't change underneath you.
Keeping your repository clean
After running tests, pytest creates cache directories (.pytest_cache) and Python generates bytecode files (__pycache__). These are local artifacts that shouldn't be committed to version control. Add them to your .gitignore file:
# Python
__pycache__/
*.py[cod]
*$py.class
# Pytest
.pytest_cache/
.coverage
htmlcov/
# Virtual Environment
venv/
env/
# IDE
.vscode/
.idea/
# Environment Variables
.env
This prevents dozens of irrelevant files from appearing in git status and keeps your repository focused on source code, not build artifacts.
Configuring pytest
Create a pytest.ini file in your project root to configure pytest's behavior:
[pytest]
# Test discovery patterns
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Directories to search for tests
testpaths = tests
# Show extra test summary info
addopts =
-v
--strict-markers
--tb=short
--disable-warnings
This tells pytest to search the tests/ directory, show verbose output by default, use short tracebacks for failures, and recognise the test discovery patterns. You can override any of these from the command line when needed.
Coverage configuration goes in a separate file. coverage.py (the engine pytest-cov wraps) does not read pytest.ini; [coverage:run] and [coverage:report] sections placed there are silently ignored. Create .coveragerc in the project root with the coverage settings:
[run]
source = .
omit =
tests/*
venv/*
*/site-packages/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
pass
Two files, two responsibilities: pytest.ini drives discovery and reporting, .coveragerc drives coverage. The section names drop the coverage: prefix because .coveragerc is already coverage-specific. Section 7 (time-dependent logic and coverage) introduces the --cov flag that activates this configuration.
Running your first test
Create a simple test to verify your setup works:
def test_pytest_works():
"""Verify pytest is installed and running correctly."""
assert True
def test_project_imports_resolve():
"""The application's modules import cleanly under the test runner's path."""
import app # noqa: F401 -- import-as-assertion
import forgotten_gems # noqa: F401
import monthly_snapshots # noqa: F401
Run pytest to see these tests pass:
pytest
========================= test session starts ==========================
collected 2 items
tests/test_setup.py::test_pytest_works PASSED [ 50%]
tests/test_setup.py::test_project_imports_resolve PASSED [100%]
========================== 2 passed in 0.01s ===========================
Both tests pass: pytest discovered them, ran them, and reported the results. The verbose output (-v from your config) shows each test individually. The second test catches the most common project-setup mistake on a fresh clone: imports failing because the working directory or sys.path isn't what pytest expects. If app, forgotten_gems, or monthly_snapshots can't import, this test fails with a clear traceback before the rest of the suite gets a chance.
VS Code testing integration
Terminal commands work perfectly, but VS Code offers visual testing integration. Click the flask icon ("Testing") in the sidebar, select "pytest" as your testing framework, and VS Code discovers your tests automatically. The integration shows green checkmarks next to passing tests and red X's next to failures directly in your code; you can run individual tests with the "Play" button that appears next to each test function, and see inline failure messages. The terminal commands and VS Code integration work identically; use whichever you prefer.
Useful pytest commands
pytestruns all tests in the current directory and subdirectories.pytest tests/test_database.pyruns only tests in that specific file.pytest tests/test_database.py::test_save_tracksruns a single test function.pytest -k "spotify"runs all tests with "spotify" in their name.pytest -xstops at the first failure (useful when debugging).pytest --lfreruns only tests that failed last time.