6. Tests
607 Jest tests in 21 suites, all passing in about 45 seconds, plus a Bash suite for the deploy script. In a codebase of about 3,400 lines, that's a lot of tests, and it's the main reason the project survived a dozen production bugs without breaking existing behaviour.
The workflow: strict TDD
The project follows one rule (CLAUDE.md, implementation plan): tests first, committed separately. You can see it in the git history:
test: tombstones must be keyed by URI, not identity (BUG-012)
fix: detect removals by URI so live tracks are not tombstoned (BUG-012)
The cycle is Red → Green → Refactor:
- Red: write a test that describes the behaviour, run it, and watch it fail. A test that never failed might not be testing anything.
- Green: write the least code that makes it pass.
- Refactor: clean up with the tests as a safety net.
Committing the test on its own means anyone can check out that commit and see the red. It's proof that the test actually catches the bug.
Where tests come from: Gherkin acceptance criteria
Every GitHub issue has acceptance criteria in Gherkin (Given / When / Then):
Scenario: Missing production state blocks deploy
Given shared/config/settings.json does not exist
When the deploy runs
Then it should refuse before touching the running app
Each scenario becomes one or more it(...) blocks. Look at the names in
weeklyPlaylistUpdater.test.ts:
"should NOT tombstone when the Spotify title differs from the BBC title"
and "should abort rather than write to a stale playlist when no creator is
configured". The test names read as a specification. That's
BDD-flavoured TDD: behaviour-driven names, test-first discipline.
Three layers
| Layer | Where | Count | What's real, what's fake |
|---|---|---|---|
| Unit | tests/unit/** |
~570 | One class is real. Its dependencies are Jest mocks |
| Integration | tests/integration/** |
~38 | All our services are real and wired together, using a temp data/ dir. Only the outside world (Spotify, BBC, SMTP) is mocked "at the boundary" |
| Deploy | tests/deploy/release.test.sh |
11 scenarios | The real release.sh against a temp folder tree, with pm2 and npm replaced by stub scripts |
The tests never touch real Spotify or the real BBC. That's deliberate: tests
that hit the network are slow, flaky, and would add songs to your real
playlist. The manual scripts in scripts/ (test-matcher.ts,
inspect-playlist.ts, compare-playlists.ts) cover the live side, run by
hand.
Techniques worth naming
- Fixtures: saved real BBC pages in
tests/fixtures/. The parser is tested against reality, not against HTML you made up. - Test doubles:
jest.fn()mocks for the logger, the Spotify client and nodemailer.clearMocksandresetMocksin jest.config.js mean no test leaks state into the next. - Controlled time: jobs take
now: DateTimeas a parameter instead of callingDateTime.now()inside. That's why a test can say "it is Tuesday 1 December 2026" and check the #11 race. Passing the clock in is one of the most useful testability habits there is. - Contract tests: the deploy script decides a release is healthy by
grepping for
Application startup complete. A unit test in index.test.ts checks that exact log line, so renaming it breaks a test, not a deploy. Likewise,ecosystem.test.tschecks PM2'skill_timeoutis longer than the scheduler's shutdown wait. These tests pin agreements between parts that live in different files. - Stubbing the shell: the Bash tests put fake
pm2andnpmscripts first onPATH. The fakepm2 startwrites the startup line through the release'slogssymlink, so a wrong folder layout fails the health check, just as it would on the server.
What the tests didn't catch, and why
This is the most interesting part to discuss in an interview. Every one of these bugs reached real use or review with a green test suite:
| Bug | Why the tests missed it |
|---|---|
| #16, token expiry | The Spotify client was mocked, and mocks don't expire. The scripts authenticated right before running. Time-dependent behaviour needs a test that moves the clock |
| #13, Pavarotti | The tests used plausible search results. Nobody wrote "Spotify returns 19 wrong results". Mocks only contain what you imagine |
| #9, the E2E script | test-add-to-playlist.ts reimplemented dedup instead of calling WeeklyPlaylistUpdater. It tested a copy of the logic. Test the real code path |
| #10, manual edits | Every test assumed only our system wrote to the playlist. Your model of the world was wrong, so your tests were wrong in the same way |
| #30, locale | Tests ran on one machine with one locale. Now the dates are pinned to a locale, and CI runs on a Linux machine whose defaults differ from the dev PC |
| #17, logs | Nothing tested "runs for a month". Operational properties need their own tests (the size cap is tested now) |
The lesson: tests prove the code does what you thought. They can't prove you thought of everything. Real-world runs, logs and review emails found the gaps. Each gap then became a test, so it can't come back.
Running tests
npm test # everything
npm test -- tests/unit/services/songMatcher.test.ts
npm test -- -t "tombstone" # tests whose name matches
npm run test:coverage # coverage report in coverage/
bash tests/deploy/release.test.sh # needs Linux/WSL (GNU coreutils)
Questions you might get asked
"Do you do TDD?"
Yes, strictly here: the test commit lands before the implementation commit,
and an issue's Gherkin acceptance criteria become the test names. You can see
the red-then-green pairs in git log.
"How do you test something that talks to Spotify?" I mock at the boundary. Unit tests mock the Spotify client, and integration tests run all our services for real, mocking only Spotify, the BBC and SMTP. Live behaviour is checked with manual scripts, never in CI, because CI shouldn't write to my real playlists.
"Isn't 607 tests overkill for a hobby project?" It's what made fast change safe. There were twelve bug fixes in the matcher and the state logic, each touching code the others relied on, and none of them broke an earlier fix. Each test is also small and specific. The count is high because the behaviour has a lot of edge cases, not because the tests are padded.
"What wouldn't your tests catch?" Anything I didn't model: Spotify returning garbage, time passing, a person editing the playlist. All three happened. The answer is to listen to production (logs, review emails) and turn each surprise into a test.
"How do you test a Bash deploy script?"
With a plain Bash harness that builds a fake server folder in a temp
directory and puts stub pm2 and npm scripts on the PATH. It covers
refusals, a successful deploy, rollback on a failed start, keeping 5
releases, and stale startup lines. Shellcheck lints it in CI.