Project: tests that run on every push
A test suite that only runs on your laptop is a suggestion. Wire Vitest into GitHub Actions so every push and pull request runs it automatically.

Kabir runs the suite before every commit, religiously. Diya, on the same team, forgets half the time because she's mid-flow and just wants the branch pushed. Neither approach is a policy. A test suite that depends on someone remembering to run it locally is a suggestion with extra steps. This lesson makes it a gate: every push, every pull request, the suite runs on a server neither of them controls, and the result shows up right on the PR before anyone can merge.
The workflow file
GitHub Actions reads YAML from .github/workflows/. Here's a minimal one for a Vitest project like Linkstash:
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- run: npm test -- --runCommit that file and push. GitHub picks it up automatically, no dashboard configuration needed. From here on, every push to main and every pull request targeting it triggers this job on a fresh Ubuntu machine.
What each step is actually for
on.push and on.pull_request are the triggers. Scoping both to branches: [main] means direct pushes to main and any PR aimed at main kick off a run. Pushes to a feature branch you haven't opened a PR for yet don't, which keeps the action minutes down while you're still mid-experiment.
actions/checkout@v4 clones your repo onto the runner. Without it, there's no code to test, just an empty machine.
actions/setup-node@v4 installs Node 22 and, with cache: "npm", caches node_modules between runs keyed on your lockfile. The first run pays the full install cost. Every run after that, until dependencies change, is fast.
npm ci installs exactly what's in package-lock.json, versions and all. Unlike npm install, it refuses to silently update anything, and it deletes node_modules first. That's what you want in CI: the exact dependency tree your lockfile promises, every time, not whatever the resolver feels like today.
npm test -- --run runs the suite once and exits with a status code. Recall from the first lesson on Vitest that plain vitest defaults to watch mode, which never exits on its own. --run is what turns it into a single pass/fail check a CI runner can actually act on.
Watch mode will hang your pipeline
Forget --run (or the equivalent vitest run invocation) and the job will sit there indefinitely, watching for file changes that will never come, until GitHub kills it for timing out. This is one of the most common first mistakes when wiring Vitest into CI.
What a red run actually blocks
Push a commit that breaks a test and the Actions tab shows a red X within a couple of minutes. On a pull request, that status check appears right in the PR, the same place a reviewer looks anyway, echoing the review-before-merge habit from pull requests and collaborating on GitHub. Turn on "Require status checks to pass before merging" in the repo's branch protection settings, and the merge button becomes physically disabled until the suite goes green. Diya forgetting to run tests locally no longer matters, because the merge simply can't happen with a failing suite attached to it.
Where the pipeline can grow from here
Add npm test -- --run --coverage to get a coverage report on every run, and remember from the coverage lesson that the percentage it prints is a floor, not a guarantee. Add a second job that runs npm run typecheck alongside the tests, since a type error and a failing test are both things you want caught before merge, for the same reason. Some teams add a matrix to test against two Node versions at once. None of that changes the core shape: one file, triggered automatically, blocking the merge until it's green.
What this series actually taught
Back in why write tests, the argument was that tests exist so you can change code later without holding your breath. Everything since has been about making that true in practice: a real assertion, a shape that fails informatively, a red-green-refactor loop, the edge cases that actually ship as bugs, mocks for the things you don't control, async code that doesn't flake, a real API tested end to end, a database that resets itself, an honest look at what coverage does and doesn't prove, and components tested the way a person actually uses them. This pipeline is the last piece: the suite now runs whether or not anyone remembers to run it, which is the only way "tests pass" becomes something a whole team can trust instead of something one person checks by hand.
That's the series. For more on hardening the app these tests protect, the Web Security series picks up right where this one leaves off, or head back to the Testing & TDD series page for the full lesson list.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


