Running Automated Tests in CI
Automated tests are only useful if they run on every change, not just when someone remembers. The job of CI (Continuous Integration — a system that automatically builds and checks your code each time it changes) is to run those tests for you on a clean machine and report a clear pass or fail. When you make passing tests a required check that blocks merges, broken code physically cannot reach your main branch. This page shows you how to wire unit tests, linters, and coverage into a pipeline, spin up a real test database as a service, and turn it all into a quality gate tied to pull requests.
The quality-gate mindset
A quality gate is a rule that says “code does not move forward unless it meets this standard.” Instead of trusting humans to remember to run tests, you let the machine enforce it. A red gate is a feature, not an annoyance — it caught a bug before your users did.
A good gate usually checks three things, fast ones first so failures come back quickly:
| Check | What it catches | Speed |
|---|---|---|
| Linting | Style issues, unused variables, likely mistakes | Seconds |
| Unit tests | Broken logic in individual functions | Seconds to minutes |
| Coverage | Code that no test actually exercises | Runs with tests |
When to gate, when not to. Gate on things that are objective and reliable: tests, linting, type-checking, a build that compiles. Do NOT gate on flaky tests (tests that sometimes fail for no reason) — a gate that fails randomly trains your team to ignore it, which is worse than no gate at all. Fix or quarantine flaky tests immediately.
A minimal CI workflow
We will use GitHub Actions (GitHub’s built-in CI system, configured with YAML files). The same ideas map directly to GitLab CI or Jenkins. A workflow file lives in .github/workflows/ in your repository.
Here is a Node.js example that lints, tests, and checks coverage on every pull request and every push to main:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Run tests with coverage
run: npm test -- --coverage
The on: block decides when the gate runs. pull_request runs the checks against the proposed change; push to main is a safety net. npm ci installs exactly the versions in your lockfile, which makes builds reproducible — never use npm install in CI.
Output: when this runs, GitHub shows each step:
✓ Set up Node
✓ Install dependencies
✓ Lint
✓ Run tests with coverage
Test Suites: 14 passed, 14 total
Tests: 86 passed, 86 total
Coverage: 91.4% statements
Adding a test database as a service
Many apps need a real database to test against. CI lets you start one as a service container (a throwaway database that lives only for the length of the job). This is far more honest than mocking the database, because you test the real SQL your app runs.
Here is the same job with a PostgreSQL service added:
jobs:
test:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: appdb_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U testuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- run: npm ci
- name: Run migrations
run: npm run migrate
env:
DATABASE_URL: postgres://testuser:testpass@localhost:5432/appdb_test
- name: Run tests
run: npm test -- --coverage
env:
DATABASE_URL: postgres://testuser:testpass@localhost:5432/appdb_test
A few things matter here. The options: block adds a health check — GitHub waits until pg_isready reports the database is accepting connections before running your steps, so your tests don’t race against a database that isn’t up yet. The DATABASE_URL is passed as an environment variable, exactly the pattern your app should already use, so tests run against the service the same way production runs against a real database.
Never put real credentials here.
testpassis fine because this database is destroyed seconds later and is never reachable from the internet. Real secrets (production database passwords, API keys) belong in encrypted CI secrets, never in the workflow file or ingit.
Turning checks into required gates with branch protection
Running checks is half the job. To make them a true gate you must tell GitHub: “you cannot merge unless these pass.” That is branch protection — rules that lock down a branch.
In the GitHub web UI, go to Settings → Branches → Add branch ruleset (or classic Branch protection rules) for main, then enable:
- Require a pull request before merging — no direct pushes to
main. - Require status checks to pass before merging — then select your CI job (e.g.
test) from the list. - Require branches to be up to date before merging — forces the PR to re-run CI against the latest
main, catching conflicts that only appear when changes combine.
You can do the same from the command line with the gh CLI on Ubuntu:
sudo apt update && sudo apt install -y gh
gh auth login
gh api -X PUT repos/your-org/your-repo/branches/main/protection \
--input - <<'JSON'
{
"required_status_checks": { "strict": true, "contexts": ["test"] },
"enforce_admins": true,
"required_pull_request_reviews": { "required_approving_review_count": 1 },
"restrictions": null
}
JSON
Output:
{
"url": ".../branches/main/protection",
"required_status_checks": { "strict": true, "contexts": ["test"] },
"enforce_admins": { "enabled": true }
}
Now a pull request with failing tests shows a red merge button that cannot be clicked. The gate is real.
Best practices
- Fail fast. Run linting and type-checks before the slow test suite so obvious mistakes come back in seconds.
- Use
npm ci/pip install -r requirements.txtwith a lockfile so CI installs the exact same versions every time. - Test against a real service, not a mock, when correctness depends on the database — use service containers with a health check.
- Set a coverage floor (e.g. fail under 80%) so coverage can only go up, but don’t chase 100% — some code isn’t worth testing.
- Make the check required via branch protection; a gate nobody enforces is just a suggestion.
- Quarantine flaky tests immediately by marking them skipped and filing a bug — never let randomness erode trust in the gate.
- Keep CI fast (under ~10 minutes); cache dependencies and run independent jobs in parallel so developers actually wait for it.