Scenario
Section titled “Scenario”A Node.js package with a handful of tests. CI was set up once by someone who has since left; it
runs npm install and occasionally fails with a dependency version nobody has locally. The team
wants a pipeline they understand, using nothing more than Node’s built-in test runner.
Objective
Section titled “Objective”Build a CI workflow that installs from the lockfile with npm ci, runs node --test, caches
node_modules correctly, and fails the way you expect when a test breaks — having watched each
of those happen locally first.
Prerequisites
Section titled “Prerequisites”- Node.js CI with GitHub Actions — the reference pipeline
- Node.js 22 or later (
node --version)
Starting state
Section titled “Starting state”mkdir -p /tmp/lab-nodeci/src /tmp/lab-nodeci/test && cd /tmp/lab-nodecigit init -q -b main .git config user.email "lab@example.com"git config user.name "Lab User"
cat > package.json <<'EOF'{ "name": "calc", "version": "0.1.0", "type": "module", "engines": { "node": ">=22" }, "scripts": { "test": "node --test", "lint": "node --check src/*.js" }}EOF
cat > src/calc.js <<'EOF'export function add(a, b) { return a + b;}
export function divide(a, b) { if (b === 0) throw new RangeError('cannot divide by zero'); return a / b;}EOF
cat > test/calc.test.js <<'EOF'import { test } from 'node:test';import assert from 'node:assert/strict';import { add, divide } from '../src/calc.js';
test('add', () => { assert.equal(add(2, 3), 5);});
test('divide', () => { assert.equal(divide(6, 3), 2);});
test('divide by zero throws', () => { assert.throws(() => divide(1, 0), RangeError);});EOF
git add . && git commit -q -m "Node package with tests, no CI yet"No dependencies, no lockfile — deliberately. The first task is to discover why that matters.
-
Try
npm ci. It is the command CI should use. Run it:Terminal window npm ciIt refuses. Read why, then create the lockfile without installing anything:
Terminal window npm install --package-lock-onlygit add package-lock.json && git commit -q -m "Add lockfile"npm ci -
Run the tests locally:
Terminal window npm testThree pass. Note the summary lines —
tests 3,pass 3,fail 0. -
Break one and watch the exit code. CI depends on it:
Terminal window sed -i 's/assert.equal(add(2, 3), 5)/assert.equal(add(2, 3), 6)/' test/calc.test.jsnpm test; echo "exit: $?"sed -i 's/assert.equal(add(2, 3), 6)/assert.equal(add(2, 3), 5)/' test/calc.test.js -
Write the workflow:
Terminal window mkdir -p .github/workflowscat > .github/workflows/ci.yml <<'EOF'name: Node CIon:pull_request:push:branches: [main]permissions:contents: readjobs:test:runs-on: ubuntu-latesttimeout-minutes: 10steps:- uses: actions/checkout@v7- uses: actions/setup-node@v7with:node-version-file: package.jsoncache: npm- run: npm ci- run: npm run lint- run: npm testEOFgit add . && git commit -q -m "Add CI"Two things to notice:
node-version-file: package.jsonreads theenginesfield, so the version is declared once; andcache: npmkeys the cache onpackage-lock.json. -
Push, open a pull request with the broken test from step 3, then fix it. Confirm the failure in the Actions log is the same assertion you saw locally, and that the fixed push goes green.
Validation
Section titled “Validation”npm ci succeeds locally after the lockfile exists. npm test exits 0 with three passing tests
and exits 1 with the broken one. On GitHub the first PR run is red with the same
AssertionError, the second is green, and the second run’s setup-node step reports a cache
hit.
Step 1. npm ci requires package-lock.json and installs exactly what it lists. npm install resolves versions fresh and may update the lockfile — which is the non-reproducibility
the scenario describes.
Step 3. node --test sets a non-zero exit code when any test fails. That exit code is what
turns a red test into a red check; a test runner that always exits 0 makes CI decorative.
Step 4. With no dependencies, npm ci creates no node_modules at all. That is fine here,
and it becomes important in the Docker lab.
Solution
Section titled “Solution”Step 1, before the lockfile — npm ci refuses:
npm error code EUSAGEnpm errornpm error The `npm ci` command can only install with an existing package-lock.json ornpm error npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 ornpm error later to generate a package-lock.json file, then try again.Step 2:
✔ add (3.519251ms)✔ divide (0.670535ms)✔ divide by zero throws (1.650768ms)ℹ tests 3ℹ suites 0ℹ pass 3ℹ fail 0Step 3, broken:
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: actual: 5, expected: 6,exit: 1The workflow in step 4 is complete as written. A version matrix is a three-line addition, exactly as in the Python lab:
strategy: matrix: node-version: [22, 24] steps: - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} cache: npmExplanation
Section titled “Explanation”npm ci is the contract. It installs what the lockfile says, deletes any existing
node_modules first, and never writes the lockfile. Every machine that runs it — every developer,
every CI job — gets identical trees. npm install makes no such promise.
The lockfile is source. It is committed, reviewed and diffed like any other file. A pull request that changes it is a dependency change and should be read as one.
One version declaration. engines.node in package.json is read by setup-node through
node-version-file, so upgrading Node is one edit in one place, and npm warns local developers
running something older.
The built-in runner is enough. node --test needs no dependency, reports in TAP-compatible
output, and exits non-zero on failure. Reach for a framework when you need its features, not by
default.
Troubleshooting
Section titled “Troubleshooting”npm ci fails with a lockfile mismatch. package.json and package-lock.json disagree,
usually because someone edited package.json by hand. Run npm install once locally, commit the
updated lockfile.
Cache never hits. cache: npm hashes package-lock.json. If the file is gitignored or
missing, there is nothing to key on.
Tests pass locally, fail in CI with a module resolution error. "type": "module" and the
.js extensions in imports must match; CI’s Node version may be stricter than yours. The
engines field plus node-version-file keeps them aligned.
Clean up
Section titled “Clean up”cd /tmp && rm -rf lab-nodeciRelated lessons
Section titled “Related lessons”Next lab
Section titled “Next lab”Build a Docker CI pipeline — multi-stage builds, the context you did not mean to send, and a test stage that silently never ran.