Tests are useful only when they run automatically on every change. A suite run by hand and by mood protects nothing: a regression is noticed already in production. CI's job is to make checking inevitable: on every push and pull request the machine runs the same pipeline, and a red result blocks the merge. A local green is a hypothesis; a green in CI is confirmation on a clean, reproducible machine.
The pipeline is built in a clear order, from cheap to expensive. First npm ci - installation strictly by the lockfile, without willful version bumps. Then fast checks: lint and typecheck as a separate step, because tests do not replace tsc --noEmit. Next the tests themselves in CI mode, and only at the end build. This order gives fast failure: an obvious lint error fails the build in seconds, without waiting for the long steps.
Tests in CI are run with an explicit one-shot run, not watch. vitest run executes the suite once and exits with a code by which CI judges success; a forgotten watch mode would hang the job forever. Usually this is wired into a script like test:ci, adding vitest run --coverage, so that in one pass you get both the result and coverage and do not run the suite twice for the report.
When the suite grows, sharding helps. The --shard=1/3 flag splits the tests into parts, and three parallel jobs run a third each, cutting the total time by roughly three. This is horizontal scaling of the run: the suite is the same but executes in parallel on several runners. Coverage is then gathered per shard and merged into a common report, so the split does not lose part of the data.
CI's reproducibility rests on pinning the environment. The Node version is fixed explicitly - the same as the team's and production's - and installation goes by the committed lockfile, so the machine sees exactly the same dependency versions. The package manager's store cache speeds up installation between runs without changing its result. Without this discipline 'works for me' and 'fails in CI' become the norm, and the cause is floating versions, not the code.
Run results are worth saving as artifacts. The coverage report and results in JUnit format are attached to the job so CI shows them in the interface: which tests failed, how coverage changed, where the regression is. This turns the run from a binary 'passed/failed' into diagnostics - from the artifacts you see what exactly broke, without locally reproducing the whole pipeline from scratch.
A separate topic is migrating Vitest versions, and the main pitfall here is coverage. On the move to a new major version the V8 coverage remapping mechanism changed: the same codebase may show a different percentage not because there are fewer tests but because coverage is computed more precisely. The right reaction is to check what is really covered, rather than mechanically fitting thresholds to the new number and hiding the sagged spots.
The typical failures hit reproducibility. Running vitest in CI instead of vitest run and hanging the job in eternal watch. Installing dependencies via install instead of ci and getting versions in CI different from local. Not pinning Node or the timezone - and catching failures that do not exist on the developer's machine. And on migration, tweaking thresholds to the new percentage instead of working out why the number changed.
# .github/workflows/ci.yml (job fragment)
- uses: actions/setup-node@v4
with: { node-version: '24', cache: 'npm' }
- run: npm ci # strictly by the lockfile
- run: npm run lint
- run: npm run typecheck # tests do not replace tsc --noEmit
- run: npm run test:ci # vitest run --coverage
- run: npm run build# Sharding: three parallel jobs, a third of the tests each
vitest run --shard=1/3 --coverage
vitest run --shard=2/3 --coverage
vitest run --shard=3/3 --coverage
# coverage is merged per shard into a common report