Skip to main content

Code Coverage & CI

Writing tests is only half the story. You need to measure how much of your code is actually tested, enforce coverage standards, and automate test execution so that no untested code slips into production.

Understanding Code Coverage

Code coverage measures which lines, branches, functions, and statements your tests execute. Jest has built-in coverage support — no extra tools needed:

npx jest --coverage

This generates a coverage summary in the terminal:

--------------------|---------|----------|---------|---------|
File                | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files           |   87.5  |   75.0   |   90.0  |   88.2  |
 formatPrice.ts     |  100.0  |  100.0   |  100.0  |  100.0  |
 userService.ts     |   80.0  |   60.0   |   85.7  |   81.5  |
 utils.ts           |   72.0  |   55.0   |   66.7  |   73.3  |
--------------------|---------|----------|---------|---------|

The Four Metrics

  • Statements: Percentage of executable statements that ran.
  • Branches: Percentage of if/else, switch, and ternary paths taken.
  • Functions: Percentage of functions that were called at least once.
  • Lines: Percentage of lines that executed.

Branch coverage is typically the hardest to achieve and the most valuable. It catches untested error paths and edge cases.

Configuring Coverage Thresholds

Set minimum coverage requirements in jest.config.js:

module.exports = {
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/index.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 85,
      lines: 85,
      statements: 85,
    },
    './src/utils/': {
      branches: 95,
      functions: 100,
      lines: 95,
      statements: 95,
    },
  },
};

When coverage drops below these thresholds, jest --coverage exits with a non-zero code. The collectCoverageFrom pattern controls which files are included — exclude type definitions and barrel files that do not contain logic.

You can set stricter thresholds for critical paths (like utility functions) and relaxed ones for the overall project.

Reading the HTML Report

Jest generates a detailed HTML report in the coverage/lcov-report/ directory:

npx jest --coverage
open coverage/lcov-report/index.html

The HTML report highlights every file with color-coded lines:

  • Green: Covered by tests.
  • Red: Not covered — no test executed this line.
  • Yellow: Partially covered — some branches taken, others not.

Click into a file to see exactly which if branches are untested. This is the fastest way to identify gaps.

Integrating Jest in GitHub Actions

Automate test execution on every push and pull request:

# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests with coverage
        run: npx jest --coverage --ci

      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/lcov-report/
          retention-days: 7

The --ci flag makes Jest run in continuous integration mode — it fails if no tests are found (instead of passing silently) and disables the watch mode interactive prompts.

Pre-Commit Hooks with Husky

Catch test failures before they even reach CI by running tests on every commit:

npm install -D husky lint-staged
npx husky init

Configure lint-staged in package.json:

{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "jest --bail --findRelatedTests"
    ]
  }
}

Add the hook:

echo "npx lint-staged" > .husky/pre-commit

The --findRelatedTests flag tells Jest to only run tests that are affected by the staged files. The --bail flag stops at the first failure for fast feedback. This means a commit that breaks a test will be rejected locally — no waiting for CI.

Coverage Is Not Quality

High coverage does not guarantee good tests. A test that calls a function without asserting the result adds coverage but catches nothing. Focus on:

  • Meaningful assertions: Every test should verify specific behavior.
  • Edge cases: Zero, null, empty strings, boundary values, error paths.
  • Branch coverage: Untested if/else branches are where bugs hide.
  • Mutation testing: Tools like Stryker modify your code and check if tests catch the change. This measures test quality, not just test quantity.

Use coverage as a tool to find gaps, not as a target to hit blindly.

Key Takeaways

  • Run jest --coverage to generate coverage reports with line-by-line detail.
  • Set coverageThreshold in Jest config to enforce minimums in CI.
  • Use the HTML report to identify uncovered branches and lines.
  • Automate tests in GitHub Actions with --ci and --coverage flags.
  • Add pre-commit hooks with Husky and --findRelatedTests for instant local feedback.
  • Treat coverage as a diagnostic tool, not a metric to game.