What you'll learn
- Write a minimal GitHub Actions workflow that runs your tests on every push and pull request
- Explain why npm ci beats npm install on a build server
- Trace how an exit code becomes a failed check and a blocked merge
- Make the test check required with branch protection so merges wait for green
- Debug the common cases where CI fails but the suite passes locally
on this page
Your tests passed last night. You know that because you ran them on your laptop, against your copy of the code, with your exact Node version and your .env file sitting right there. Then you pushed.
Here’s the uncomfortable part: that run proved nothing about the branch. It proved your laptop was happy at 11:43pm. Whether the tests pass on the commit that actually lands in main, merged with whatever your teammate shipped this morning, on a machine with none of your local conveniences, is a separate question. Right now nobody is answering it.
Tests guard the branch only when they run on every push, automatically, on a machine where “I forgot” is not possible. That’s the job of CI (continuous integration), and if the term itself is fuzzy, what CI/CD actually does covers the big picture. This guide is the practical wiring: vitest into GitHub Actions, one YAML file, then making the result actually block bad merges.
The whole setup is one file
GitHub Actions is the runner built into GitHub: you commit a YAML file describing what to run and when, and GitHub spins up a fresh virtual machine and runs it every time the trigger fires. Nothing to sign up for, nothing to connect. The file just has to live in .github/workflows/, and the filename is yours to pick.
Here’s a complete, real workflow for a Node project using vitest. This is not a skeleton. This is the file.
# .github/workflows/test.yml
name: tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24 # match your local `node --version`
cache: npm
- run: npm ci
- run: npm test
Walking it through:
name is the label that shows up in the pull request checks list and the Actions tab. Lowercase and boring is fine.
on is the trigger list, and there are deliberately two entries. pull_request runs the tests against every PR, which is the gate this whole guide is building toward. push to main covers commits that skip the PR ritual, because someone will eventually push straight to main, and it will probably be you.
runs-on: ubuntu-latest asks for a fresh Linux virtual machine. Fresh is the load-bearing word: it arrives with no node_modules, no .env file, and an empty list of half-remembered global installs. Every run starts from zero, which is exactly the property your laptop can’t offer.
actions/checkout clones your repository onto that empty machine. Skip it and the later steps run in a blank folder, a mistake that confuses everyone exactly once.
actions/setup-node installs the Node version you ask for. The runner image ships with a Node preinstalled, but “whatever was on the image” is how version drift starts, so pin the one you actually develop on. The cache: npm line keeps npm’s download cache between runs, keyed to your lockfile, which cuts install time noticeably once your dependencies settle down.
npm ci installs dependencies. It gets its own section below, because the difference from npm install matters more here than anywhere else.
npm test runs whatever the test script in your package.json says. For a vitest project, that script should be vitest run.
One durability note: the @v6 tags pin each action to a major version, and those majors move on their own schedule, sometimes twice in a single year. If GitHub’s docs show a newer one when you read this, use that. An old major keeps working, but the runner starts nagging you about deprecated Node versions in the log.
npm ci, because a build server should never guess
npm install is a negotiator. It reads the version ranges in package.json, picks versions that satisfy them, and updates package-lock.json if it decides something newer fits. That’s helpful at your desk, where you’re around to notice. In CI it means the machine testing your code might quietly install different versions than the ones you tested.
npm ci (“clean install”) is a bailiff. It deletes node_modules outright, installs the exact versions pinned in package-lock.json, and never writes to the lockfile. If package.json and the lockfile disagree, say you hand-edited a version and forgot to reinstall, it refuses to run at all and fails the build with an error saying so.
That loud failure is the feature. The two worst dependency outcomes in CI are “silently tested something other than what I ship” and “works this week, breaks next week, nothing changed.” Lockfile-exact installs close off both. None of it works unless the lockfile is committed, which is one more reason package-lock.json belongs in git and never in .gitignore.
A red X is just an exit code
It’s worth knowing how little magic is involved here. GitHub Actions has no idea what a test is. It can’t read vitest’s output, doesn’t know what “passed” means, and never will. The entire contract is the process exit code: every command ends by reporting one number, 0 for success, anything else for failure.
npx vitest run Test Files 4 passed (4)
Tests 31 passed (31) echo $? 0
$? holds the previous command's exit code. Zero is the only thing CI ever wanted to hear.
npx vitest run Test Files 1 failed | 3 passed (4)
Tests 2 failed | 29 passed (31) echo $? 1
Nonzero fails the step, a failed step fails the job, and the job turns the check red. That chain is the whole feature.
Two consequences fall out of this. First, anything with an honest exit code can guard your branch the same way: tsc --noEmit for type errors, eslint . for lint, npm run build for “does it even compile.” Add each one as another run: step and the gate gets stricter for free.
Second, anything that lies about its exit code defeats the entire system. The classic self-own is npm test || true, pasted from a forum answer to “make CI stop failing.” It does exactly that, permanently, by reporting success no matter what happened.
Make the green check required
Out of the box, a red X on a pull request is advice. The merge button still works; it just looks disapproving. Sooner or later someone merges through it (“it’s the flaky one, it’s fine”), and from that day the suite stops meaning anything.
Branch protection turns the advice into a rule. In your repo: Settings, then Branches, then add a protection rule for main (newer repos may steer you toward Rulesets instead, which do the same job with different menus). Tick Require status checks to pass before merging and search for your job by name, the test from the jobs: block. From then on, a red check greys out the merge button instead of decorating it. While you’re in there, “Require branches to be up to date before merging” is worth a look too: it forces a PR to re-test against the latest main, closing the gap where two green PRs are individually fine and jointly broken.
Two gotchas. The check only appears in that search box after it has run at least once, so push the workflow file and let it execute before you go hunting for the setting. And if you’re the repository admin, enable the option that applies the rules to administrators as well. Otherwise the rule politely steps aside for the one person most likely to be merging at 1am.
With that switch flipped, “oops, I forgot to run the tests” stops being a failure mode. There’s nothing left to forget. The tests run because a push happened, and the merge waits because the tests said so.
Red in CI, green on your laptop
Eventually you’ll hit the inverted bug report: the suite passes locally and fails in Actions. This isn’t CI being moody. The runner is a fresh machine, and fresh machines surface everything your laptop has been quietly papering over. Four suspects cover most cases.
Node version drift. Your laptop runs one Node, the workflow installs another, and something behaves differently between them: a newer API, a changed default, a dependency that ships different builds. The fix is a single source of truth. Put the version in an .nvmrc file at the repo root and have the workflow read it, so local and CI can’t diverge without a commit:
- uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: npm
A missing environment variable. Your .env file is gitignored, correctly, so the runner never sees it. Code that reads process.env.API_URL gets undefined and blows up somewhere downstream of the actual problem. Non-secret config can go straight into the workflow with an env: block on the step or job. Actual secrets need actual handling, which is the next guide.
An untracked file. A fixture, a helper, a config file that exists on your disk but never made it into a commit. Locally everything resolves; on a fresh clone the import finds nothing. git status shows you what’s untracked. The Linux-only cousin is casing: import Button from "./button" resolves on Windows and macOS, whose filesystems shrug at case, and fails on the case-sensitive Linux runner when the file is really Button.ts.
Test order dependence. Two tests share state, a module-level variable or rows in a shared test database, and only pass when one runs before the other. A different machine runs them in a different order and the hidden coupling snaps. You can hunt these locally by shuffling: vitest accepts sequence.shuffle in its config, and a suite that survives shuffled order has no hidden coupling left to find.
The general-purpose diagnostic for all four: clone your own repo into a temp folder, as if you were the runner, then npm ci and npm test in there. Most “works on my machine” mysteries don’t survive contact with a clean checkout. And when you read the Actions log, scroll to the first error, not the last one. Everything after the first error is usually wreckage, not cause.
Fast enough that nobody routes around it
There’s a social failure mode no YAML can fix: a slow suite. When CI takes twenty-five minutes, people stop waiting for it. They merge and check later, then stop checking at all, and within a month a red X reads as “probably flaky” instead of “probably broken.” The gate is still configured. It just doesn’t gate anything.
So treat speed as part of the setup, with a few minutes as the working target for the per-push run. The workflow above already does the cheap part by caching npm downloads, and vitest runs test files in parallel by default. The next lever is keeping individual tests honest: a “unit test” that talks to a real network or a real database is an integration test in a costume, and those are usually where the minutes go. When you do grow genuinely slow end-to-end tests, browser automation and friends, give them their own workflow that runs nightly or only on PRs into main, instead of strapping them to every push.
The thing to resist is making the suite fast by making it smaller. Skipped tests guard nothing, which is where this guide came in.
The first real catch
For a while the workflow just agrees with you: push, green, merge. Then one night you refactor something “too small to break anything,” skip the local run, push, and the X comes up red because a test caught exactly what you broke. That PR, the one that never reached main broken, is the entire return on a twenty-two-line YAML file.
It compounds from there. Every dependency bump and every future-you in a hurry now goes through the same gate as careful present-you, and that’s a big part of what separates a real project from a pile of code that happened to work once. One loose end remains: the first time your tests need a real database URL or a third-party API key, you’ll be tempted to paste it into the workflow file, where it would sit in your git history forever. Don’t. That’s secrets management, and it’s next.
signal check
Can you answer these without scrolling back up?
-
Why does CI use npm ci instead of npm install?
npm ci installs the exact versions pinned in package-lock.json, never modifies the lockfile, and fails the build outright if package.json and the lockfile disagree. Drift becomes a loud failure instead of a silent version change.
-
How does GitHub Actions know your tests failed?
Exit codes. The test runner exits nonzero, that fails the step, the failed step fails the job, and the check turns red. Actions never reads or understands your test output; the number is the whole protocol.
-
CI is red but the same tests pass on your laptop. What are the first suspects?
Node version drift between your machine and the runner, an environment variable that only exists in your local .env file, a file that exists on your disk but was never committed, or tests that secretly depend on running in a particular order.
progress is stored in this browser only