Skip to content
DevOps devops cicd 5 min read

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:

CheckWhat it catchesSpeed
LintingStyle issues, unused variables, likely mistakesSeconds
Unit testsBroken logic in individual functionsSeconds to minutes
CoverageCode that no test actually exercisesRuns 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. testpass is 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 in git.

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.txt with 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.
Last updated June 15, 2026
Was this helpful?