CI Test Pipelines — Service Containers, Coverage, and Headless Chrome

Running tests in CI is the core value proposition of continuous integration — every change is automatically verified against the full test suite before it can be merged. But tests that work locally often fail in CI for preventable reasons: missing environment variables, port conflicts, timezone differences, and race conditions with services that haven’t finished starting. This lesson builds rock-solid CI test configurations for both the Express API (with a real MongoDB and Redis via Docker service containers) and the Angular frontend (with headless Chrome), producing coverage reports that gate merges.

CI Test Environment Considerations

Problem Cause Solution
Tests pass locally, fail in CI Missing env vars, wrong timezone, missing system libraries Set all required env: in workflow, pin timezone
MongoDB connection refused Service container not ready when tests start Use health checks on service containers
Port conflicts Multiple test files start servers on same port Use random ports or test without starting a server (Supertest)
Flaky tests Race conditions, time-dependent assertions Use Jest fake timers, --forceExit, deterministic test data
Slow test suite No parallelism, no caching Jest --runInBand for integration, --maxWorkers for unit
Angular tests fail (no display) Chrome needs a display server Use ChromeHeadless browser in karma/jest config
Note: GitHub Actions provides service containers — Docker containers that run alongside your job’s steps. You can spin up MongoDB, Redis, or PostgreSQL without installing them on the runner. Service containers are available via localhost with the mapped port. They are pulled and started before your steps run, and automatically stopped when the job completes. This is cleaner than npm install -g mongodb and guarantees the exact version you specify.
Tip: Use Jest’s --ci flag in GitHub Actions. This flag disables the interactive watch mode (which hangs in CI), treats snapshot updates as failures (preventing accidental snapshot bypasses), and fails immediately on test failures rather than continuing. Combined with --coverage --coverageReporters=json-summary, you can parse the coverage output to post PR comments or enforce thresholds.
Warning: Run integration tests with --runInBand (serial execution) even though it’s slower than parallel. Integration tests that share a database will have race conditions when run in parallel — Test A deletes a record while Test B is querying it, causing flaky failures that are hard to debug. Unit tests run in parallel fine (they have no shared state), but database tests should be serial. Use separate Jest projects or config files for unit vs integration.

Complete Test Workflows

# .github/workflows/test.yml — Comprehensive test suite with services

name: Tests

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

jobs:

    # ── API Unit Tests (fast, no services needed) ─────────────────────────
    api-unit:
        name:    API Unit Tests
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4

            - uses: actions/setup-node@v4
              with:
                  node-version: '20'
                  cache: npm
                  cache-dependency-path: api/package-lock.json

            - run: npm ci
              working-directory: api

            - name: Run unit tests
              run: npx jest --testPathPattern=unit --ci --coverage --coverageReporters=json-summary lcov
              working-directory: api
              env:
                  NODE_ENV:       test
                  JWT_SECRET:     ${{ secrets.JWT_SECRET_TEST }}
                  REFRESH_SECRET: ${{ secrets.REFRESH_SECRET_TEST }}

            - name: Upload unit test coverage
              uses: actions/upload-artifact@v4
              with:
                  name: unit-coverage
                  path: api/coverage/

    # ── API Integration Tests (needs real MongoDB + Redis) ─────────────────
    api-integration:
        name:    API Integration Tests
        runs-on: ubuntu-latest

        # Service containers — available at localhost with the mapped ports
        services:
            mongodb:
                image:   mongo:7
                env:
                    MONGO_INITDB_ROOT_USERNAME: admin
                    MONGO_INITDB_ROOT_PASSWORD: testpass
                    MONGO_INITDB_DATABASE:      taskmanager_test
                ports:
                    - 27017:27017
                options: >-
                    --health-cmd "mongosh --eval 'db.runCommand({ping:1})' --quiet"
                    --health-interval 10s
                    --health-timeout 5s
                    --health-retries 5
                    --health-start-period 30s

            redis:
                image:   redis:7-alpine
                ports:
                    - 6379:6379
                options: >-
                    --health-cmd "redis-cli ping"
                    --health-interval 10s
                    --health-timeout 3s
                    --health-retries 3

        steps:
            - uses: actions/checkout@v4

            - uses: actions/setup-node@v4
              with:
                  node-version: '20'
                  cache: npm
                  cache-dependency-path: api/package-lock.json

            - run: npm ci
              working-directory: api

            - name: Run integration tests
              run: npx jest --testPathPattern=integration --runInBand --ci --forceExit
              working-directory: api
              env:
                  NODE_ENV:       test
                  MONGO_URI:      mongodb://admin:testpass@localhost:27017/taskmanager_test?authSource=admin
                  REDIS_URL:      redis://localhost:6379
                  JWT_SECRET:     ${{ secrets.JWT_SECRET_TEST }}
                  REFRESH_SECRET: ${{ secrets.REFRESH_SECRET_TEST }}

            - name: Upload integration test results
              uses: actions/upload-artifact@v4
              if: always()   # upload even if tests failed — for debugging
              with:
                  name: integration-results
                  path: api/test-results/
                  retention-days: 3

    # ── Angular Tests (headless Chrome) ───────────────────────────────────
    angular-tests:
        name:    Angular Tests
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4

            - uses: actions/setup-node@v4
              with:
                  node-version: '20'
                  cache: npm
                  cache-dependency-path: client/package-lock.json

            - run: npm ci
              working-directory: client

            - name: Run Angular tests
              run: npm run test:ci
              working-directory: client
              # package.json: "test:ci": "ng test --watch=false --browsers=ChromeHeadless --code-coverage"

            - name: Upload Angular coverage
              uses: actions/upload-artifact@v4
              with:
                  name: angular-coverage
                  path: client/coverage/

    # ── Coverage Report PR Comment ────────────────────────────────────────
    coverage-comment:
        name:    Coverage Report
        runs-on: ubuntu-latest
        needs:   [api-unit, angular-tests]
        if:      github.event_name == 'pull_request'
        steps:
            - uses: actions/download-artifact@v4
              with:
                  name: unit-coverage
                  path: api-coverage/

            - uses: actions/download-artifact@v4
              with:
                  name: angular-coverage
                  path: angular-coverage/

            - name: Post coverage comment on PR
              uses: actions/github-script@v7
              with:
                  script: |
                      const fs = require('fs');
                      const apiSummary = JSON.parse(
                          fs.readFileSync('api-coverage/coverage-summary.json', 'utf8')
                      );
                      const lines   = apiSummary.total.lines.pct;
                      const fns     = apiSummary.total.functions.pct;
                      const status  = lines >= 80 ? '✅' : '❌';
                      const comment = `## Coverage Report\n\n| Metric | Pct | Status |\n|---|---|---|\n| Lines | ${lines}% | ${status} |\n| Functions | ${fns}% | ${fns >= 80 ? '✅' : '❌'} |`;

                      github.rest.issues.createComment({
                          issue_number: context.issue.number,
                          owner:        context.repo.owner,
                          repo:         context.repo.repo,
                          body:         comment,
                      });

How It Works

Step 1 — Service Containers Provide Real Infrastructure in CI

The services: block under a job definition instructs the GitHub Actions runner to pull and start Docker containers before executing the job’s steps. These containers run alongside the job and are accessible at localhost on their mapped ports. The options field configures Docker health checks — the runner waits for the health check to pass before starting steps, preventing the race condition where the test step starts before MongoDB is ready.

Step 2 — –runInBand Prevents Database Race Conditions

Jest runs test files in parallel worker processes by default. For integration tests that share a database, parallel execution causes race conditions: file A creates a user, file B counts users expecting 0 — but file A’s user is already there. --runInBand runs all test files serially in the main process, eliminating inter-file state sharing. The performance penalty is acceptable for integration tests (typically fewer and already slower).

Step 3 — –forceExit Prevents Hanging Tests

If any open async operation (HTTP server, database connection, timer) does not close after tests complete, Jest waits indefinitely. In CI, this causes the job to hang until the timeout kills it. --forceExit makes Jest forcibly exit after all tests complete, even if async operations are still pending. This is appropriate for CI (you want test results quickly) but should be investigated and fixed in development.

Step 4 — Separate Jobs for Unit and Integration Tests Optimises Speed

Unit tests do not need service containers and run in parallel — completing in 10–30 seconds. Integration tests need MongoDB and Redis but run serially — taking 1–2 minutes. Separating them into different jobs means: unit tests start and finish faster (with parallelism), integration tests start in parallel with unit tests (not waiting for them), and overall CI time is reduced to max(unit_time, integration_time) rather than unit_time + integration_time.

Step 5 — github-script Posts Coverage Comments on PRs

The actions/github-script action lets you write JavaScript that calls the GitHub API directly in the workflow. By downloading coverage JSON artifacts from previous jobs and parsing them, the step posts a formatted comment on the PR with coverage percentages and pass/fail status. This surfaces test coverage information directly where developers review code — the PR — rather than requiring them to dig through CI logs.

Common Mistakes

Mistake 1 — Not waiting for service container health checks

❌ Wrong — tests start before MongoDB is ready:

services:
    mongodb:
        image: mongo:7
        # No health check options — container starts but MongoDB may not be ready
        # Tests fail with "connection refused" intermittently

✅ Correct — use health check options:

options: >-
    --health-cmd "mongosh --eval 'db.runCommand({ping:1})' --quiet"
    --health-interval 10s
    --health-retries 5

Mistake 2 — Running integration tests in parallel (race conditions)

❌ Wrong — parallel execution causes flaky failures:

npx jest --testPathPattern=integration  # parallel by default — race conditions!

✅ Correct — serial execution for database tests:

npx jest --testPathPattern=integration --runInBand  # serial — deterministic

Mistake 3 — Using production secrets for tests

❌ Wrong — tests run against production data or with production JWT secrets:

env:
    JWT_SECRET: ${{ secrets.JWT_SECRET }}  # production secret in test environment!
    MONGO_URI:  ${{ secrets.MONGO_URI }}   # production database!

✅ Correct — dedicated test secrets and service containers:

env:
    JWT_SECRET: ${{ secrets.JWT_SECRET_TEST }}  # separate test-only secret
    MONGO_URI:  mongodb://admin:pass@localhost:27017/test_db  # service container

Quick Reference

Task YAML / Command
Add MongoDB service services: mongodb: image: mongo:7, ports: ["27017:27017"]
Health check service options: --health-cmd "mongosh --eval 'db.runCommand({ping:1})'"
Run Jest in CI mode jest --ci --coverage
Run integration tests serially jest --runInBand --testPathPattern=integration
Force exit after tests jest --forceExit
Angular headless tests ng test --watch=false --browsers=ChromeHeadless
Upload even on failure if: always() on upload-artifact step
Parse coverage JSON JSON.parse(fs.readFileSync('coverage-summary.json'))

🧠 Test Yourself

Integration tests pass locally but randomly fail in CI with “document not found” errors in tests that run after other test files. What is the most likely cause and fix?