Testing in pipelines: lint, unit, integration and the gate
The checks that decide what may merge: a linter, fast unit tests with a coverage threshold, integration tests against the real app and a database, and required status checks.
By the end of this lesson you'll be able to
0 of 6 completeFastest checks first
A pipeline's job is to say no as early and cheaply as possible. Order checks by how fast they fail:
| Check | Takes | Catches |
|---|---|---|
| Lint | seconds | typos, undefined names, unused code, risky patterns |
| Unit tests | seconds | wrong logic in a function |
| Integration tests | minutes | parts that don't work together: HTTP, database, config |
| End-to-end tests | many minutes | a real user flow through the whole system |
Many fast unit tests, fewer integration tests, a handful of end-to-end tests: the test pyramid. Upside down, the pipeline gets slow and flaky.
Linting: the cheapest check
A linter reads code without running it. For JavaScript that's ESLint:
$ npm install --save-dev eslint@9 @eslint/js@9 globals
// eslint.config.js import js from '@eslint/js' import globals from 'globals' export default [ js.configs.recommended, { languageOptions: { globals: globals.node } }, ]
On a file with a typo in a variable name, it fails with exactly what's wrong:
$ npx eslint . src/bad.js 3:26 error 'sum' is assigned a value but never used no-unused-vars 4:10 error 'totl' is not defined no-undef ✖ 2 problems (2 errors, 0 warnings) $ echo $? 1
npx eslint ..this folder
echo $?echoprint text
That bug (return totl instead of sum) would have crashed the first request in production. Every
language has its linter: Ruff for Python, golangci-lint for Go, ShellCheck for shell, hadolint for
Dockerfiles, actionlint for workflows (lesson 2). Run formatters (Prettier, Black) in check mode too, so
style never shows up in review.
Unit tests and coverage
Unit tests call one function or module directly and check the result: fast, precise, no network. Coverage measures which lines your tests executed. A threshold makes the build fail if it drops:
$ node --test --experimental-test-coverage --test-coverage-lines=80 'test/*.test.js' ℹ Error: 69.23% line coverage does not meet threshold of 80%. ℹ start of coverage report ℹ app.js | 69.23 | 87.50 | 100.00 | 12-19
The last column lists uncovered lines: 12 to 19, the /price route. One more unit test covers it:
test('GET /price answers with the total, or 400 for a bad amount', async () => { const server = createApp().listen(0) const { port } = server.address() const ok = await fetch(`http://localhost:${port}/price?amount=100`) assert.deepEqual(await ok.json(), { total: 118 }) const bad = await fetch(`http://localhost:${port}/price?amount=-1`) assert.equal(bad.status, 400) server.close() })
$ npm run test:coverage ℹ pass 4 ℹ fail 0 ℹ app.js | 96.15 | 90.91 | 100.00 | 19
Coverage says what ran, not whether it was checked properly: a test with no assertions still counts. Use the threshold to stop coverage sliding down, and code review to judge the tests themselves.
Integration tests: the real thing
Integration tests run the app the way production does and talk to it from outside. This one starts the
real server as a separate process and waits for /health before testing:
// test/integration/api.test.js import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import { spawn } from 'node:child_process' const PORT = 4567 const base = `http://localhost:${PORT}` let server before(async () => { server = spawn(process.execPath, ['src/server.js'], { env: { ...process.env, PORT: String(PORT) } }) for (let i = 0; i < 50; i++) { try { if ((await fetch(`${base}/health`)).ok) return } catch { // not listening yet: wait and retry } await new Promise((r) => setTimeout(r, 100)) } throw new Error('server did not start within 5 seconds') }) after(() => server.kill()) test('GET /price returns the total with tax', async () => { const res = await fetch(`${base}/price?amount=100`) assert.equal(res.status, 200) assert.deepEqual(await res.json(), { total: 118 }) }) test('unknown paths return 404', async () => { assert.equal((await fetch(`${base}/nope`)).status, 404) })
$ node --test 'test/integration/*.test.js' ✔ GET /price returns the total with tax (310.217185ms) ✔ GET /price rejects a bad amount with 400 (31.433313ms) ✔ unknown paths return 404 (15.854698ms)
Note the quotes and the pattern: node --test test/integration/ (a folder) finds nothing and fails. Keep
unit and integration tests in separate scripts so each job runs only its own:
$ npm pkg set scripts.test="node --test 'test/*.test.js'" \ scripts.test:integration="node --test 'test/integration/*.test.js'" \ scripts.test:coverage="node --test --experimental-test-coverage --test-coverage-lines=80 'test/*.test.js'"
Service containers: a real database in CI
When the app needs a database, don't mock it in integration tests: start a real one next to the job with a service container:
integration: needs: [lint, unit] runs-on: ubuntu-latest services: postgres: image: postgres:17 env: POSTGRES_PASSWORD: test ports: ['5432:5432'] options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10 env: DATABASE_URL: postgres://postgres:test@localhost:5432/postgres steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - run: npm ci - run: npm run test:integration
The health check makes GitHub wait until Postgres accepts connections before the first step runs. The database exists only for this job, so every run starts empty and tests can't depend on leftovers. The password is a throwaway for a container that lives a few minutes, which is why it can sit in the file.
Required status checks: the gate
Checks only protect main if they're required. In Settings → Rules → Rulesets, edit the rule for the
default branch, enable Require status checks to pass, and add each job by name: lint, unit,
integration. Turn on Require branches to be up to date too, so a pull request is tested against the
current main, not the one it started from.
Now a pull request with a failing test shows Merging is blocked, and the merge button stays disabled
until every required check is green. Jobs skipped because a needs failed count as not passed.
Put a real gate in front of main
Give shop-api a full test pipeline: ESLint, unit tests with an 80% coverage threshold, and integration tests that start the real server, in that order of cost. Make all three required on main, then prove a pull request with a failing test can't be merged.
A blocked pull request, then a green one that mergesReveal one safe solution
$ npm install --save-dev eslint@9 @eslint/js@9 globals $ nano eslint.config.js # the config from this lesson $ mkdir -p test/integration && nano test/integration/api.test.js $ npm pkg set scripts.lint="eslint ." \ scripts.test="node --test 'test/*.test.js'" \ scripts.test:integration="node --test 'test/integration/*.test.js'" \ scripts.test:coverage="node --test --experimental-test-coverage --test-coverage-lines=80 'test/*.test.js'" $ npm run lint && npm run test:coverage && npm run test:integration $ nano .github/workflows/ci.yml # lint and unit in parallel, integration after both $ actionlint && git add . && git commit -m "ci: lint, unit with coverage, integration" && git push $ git switch -c test/gate && sed -i 's/118)/119)/' test/app.test.js $ git commit -am "test: break a test on purpose" && git push -u origin test/gate && gh pr create --fill $ gh pr checks
mkdir -p test/integration && nano test/integration/api.test.jsmkdirmake a directory-pcreate missing parents, no error if it exists&&run the next command only if this one worked
actionlint && git add . && git commit -m "ci: lint, unit with coverage, integration" && git pushgitversion controladdstage changes.this foldercommitsave staged changespushupload commits
After adding the three jobs as required checks, the pull request shows unit failing and Merging is blocked; integration is skipped because it needs unit. Revert the change, push, and the merge button turns green.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
Why run the linter before the integration tests?
Official reading for this lesson
Or get every quiz answer right and it completes itself.