Run the Tests
Unlike the other how-to guides, this one assumes a checkout of the repository rather than a running app, because the tests ship in the repo, not in the installed package. See the Tutorial if you don’t have a checkout yet.
Run the suite
The tests live in tests/ and need pytest, which comes with the dev extra:
pip install -e ".[dev]" # or: uv pip install -e ".[dev]"
pytest # or: uv run pytesttestpaths is set in pyproject.toml, so plain pytest from the repo root collects the right directory. The whole suite runs in well under a second: no network, no disk writes, no Shiny session.
Run one file, or one test, the usual way:
pytest tests/test_core.py
pytest tests/test_core.py::test_on_this_day_single_match
pytest -k on_this_dayTwo different sources of data across the two test files
tests/test_core.py never touches the real workout history. It builds a tiny three-record FIXTURE list by hand and feeds it straight to random_workout() and on_this_day(), so each test controls exactly what it’s asserting against. That means it can force a date with matches in two years, say, and a date with none.
tests/test_data.py does the opposite on purpose. It calls load_workouts() with no fixture, loading the real bundled workouts.json, then checks properties that should hold for the actual 1001-record dataset: exact count, no duplicate dates, every referenced image file present in www/. That makes it a regression guard on scripts/build_workouts_data.py. If a future edit to the parser silently drops or duplicates entries, this is what catches it.
tests/test_build_workouts_data.py tests the parser itself, in isolation, against small in-memory .Rmd fixtures written to tmp_path. It loads scripts/build_workouts_data.py directly with importlib.util, since scripts/ isn’t an installed package.
What the tests cover
tests/test_core.py (5)
Pure logic: picking a random workout and matching on today’s date.
| Test | What it pins down |
|---|---|
test_random_workout_returns_member |
random_workout() always returns one of the workouts it was given |
test_random_workout_is_seedable |
The same seeded random.Random produces the same pick, twice |
test_on_this_day_matches_across_years |
Matches on month/day come back across every year present, newest first |
test_on_this_day_no_match_returns_empty |
A date with nothing logged returns [], not an error |
test_on_this_day_single_match |
A day with one image-kind match still comes back as a one-item list |
tests/test_data.py (4)
The real, bundled dataset: a regression guard on the build script, not on app logic.
| Test | What it pins down |
|---|---|
test_load_workouts_count |
The bundled dataset has exactly 1001 workouts |
test_every_workout_has_a_valid_date_and_kind |
Every record has an ISO date and a kind of "text" or "image" |
test_no_duplicate_dates |
No date appears twice, confirming the text-vs-image dedup actually worked |
test_image_files_exist |
Every image_url the data references exists under the packaged www/ assets |
tests/test_build_workouts_data.py (2)
The parser, exercised against small .Rmd fixtures rather than the real data.
| Test | What it pins down |
|---|---|
test_parse_rmd_splits_indented_date_heading |
A date heading nested under the previous entry’s list still becomes its own entry, not text merged into the entry above it |
test_parse_rmd_raises_on_unparseable_date_heading |
A date-shaped heading DATE_HEADER_RE won’t match (wrong heading level, trailing text, …) raises ValueError instead of being silently dropped into the previous entry |
What the tests do not cover
The Shiny wiring in app.py isn’t unit tested. That’s the two @reactive.event button handlers and the results render function. Exercising it needs a live Shiny session, and it’s a thin enough layer over core.py (see Explanation) that there isn’t much logic left in it to test in isolation.
Those paths were verified by driving a real browser session with Playwright: clicking Random Workout repeatedly until both a text and a photo entry showed up, clicking On This Day and confirming it reflected the actual current date, and confirming the empty-state message renders when a date has no match. If you change the button-click wiring, that’s the check to repeat, because the suite won’t catch it.
What CI runs on every push
Nothing runs the test suite on push yet. .github/workflows/docs.yml builds and deploys the documentation site on every push to main, but there’s no equivalent workflow running pytest, ruff, or mypy. Running those locally before you push, as above, is what stands in for CI today.