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 |
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.--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.--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')) |