Continuous Testing in CI/CD Pipelines
A practical guide to integrating tests into CI/CD pipelines with GitHub Actions, GitLab CI, and Jenkins covering test stages, parallelization, caching, and failure handling.
Continuous Testing in CI/CD Pipelines
Running tests locally is optional discipline. Running tests in CI is enforced discipline. The difference between a team that ships reliable software and one that does not is almost always the CI pipeline.
Continuous testing means every commit triggers automated tests. No code reaches production without passing unit tests, integration tests, linting, and whatever quality gates your team defines. This guide covers how to build testing pipelines that are fast, reliable, and actually useful — not just green checkmarks that nobody trusts.
Prerequisites
- A Git repository with tests (Jest, Mocha, or similar)
- GitHub, GitLab, or similar CI platform
- Understanding of testing fundamentals
Test Pipeline Architecture
A well-structured pipeline runs tests in stages from fastest to slowest:
Stage 1: Lint + Type Check (10-30 seconds)
Stage 2: Unit Tests (30-120 seconds)
Stage 3: Integration Tests (1-5 minutes)
Stage 4: E2E Tests (5-15 minutes)
Stage 5: Performance Tests (5-30 minutes, optional)
Each stage is a gate. If linting fails, there is no reason to run unit tests. If unit tests fail, integration tests will likely fail too. This saves CI minutes and gives developers fast feedback.
GitHub Actions: Complete Pipeline
Basic Test Workflow
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run lint
unit-tests:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
integration-tests:
needs: unit-tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
Matrix Testing Across Node Versions
unit-tests:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm test
Parallel Test Splitting
Split tests across multiple runners for faster feedback:
unit-tests:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/4
GitLab CI: Complete Pipeline
# .gitlab-ci.yml
stages:
- lint
- test
- integration
- deploy
variables:
NODE_VERSION: "20"
.node-cache:
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
policy: pull
install:
stage: .pre
image: node:${NODE_VERSION}
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
policy: push
script:
- npm ci
lint:
stage: lint
image: node:${NODE_VERSION}
extends: .node-cache
script:
- npm run lint
allow_failure: false
unit-tests:
stage: test
image: node:${NODE_VERSION}
extends: .node-cache
script:
- npm test -- --coverage
artifacts:
reports:
junit: junit.xml
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
integration-tests:
stage: integration
image: node:${NODE_VERSION}
extends: .node-cache
services:
- postgres:15
variables:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
DATABASE_URL: postgresql://test:test@postgres:5432/testdb
script:
- npm run test:integration
artifacts:
reports:
junit: integration-junit.xml
Dependency Caching
Caching node_modules or the npm cache dramatically speeds up pipelines.
GitHub Actions Caching
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm" # Built-in caching
# Or manual caching for more control
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
Caching Test Results
Cache expensive test setup (database migrations, compiled assets):
- uses: actions/cache@v4
with:
path: .jest-cache
key: jest-${{ hashFiles('src/**/*.js') }}
- run: npm test -- --cacheDirectory=.jest-cache
Test Reporting
JUnit XML Reports
Most CI platforms parse JUnit XML for rich test reporting.
Jest:
npm install --save-dev jest-junit
{
"scripts": {
"test:ci": "jest --ci --reporters=default --reporters=jest-junit"
},
"jest-junit": {
"outputDirectory": ".",
"outputName": "junit.xml"
}
}
Mocha:
npm install --save-dev mocha-junit-reporter
{
"scripts": {
"test:ci": "mocha --reporter mocha-junit-reporter --reporter-options mochaFile=junit.xml"
}
}
GitHub Actions Test Summary
- run: npm run test:ci
- name: Test Report
uses: dorny/test-reporter@v1
if: success() || failure()
with:
name: Jest Tests
path: junit.xml
reporter: jest-junit
Coverage Reporting
- run: npm test -- --coverage
- name: Coverage Summary
if: github.event_name == 'pull_request'
run: |
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat coverage/coverage-summary.json | node -e "
var data = '';
process.stdin.on('data', function(c) { data += c; });
process.stdin.on('end', function() {
var report = JSON.parse(data);
var total = report.total;
console.log('Lines: ' + total.lines.pct + '%');
console.log('Branches: ' + total.branches.pct + '%');
console.log('Functions: ' + total.functions.pct + '%');
console.log('Statements: ' + total.statements.pct + '%');
});
" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
Quality Gates
Required Status Checks
Configure GitHub branch protection to require passing checks:
- Settings > Branches > Branch protection rules
- Require status checks to pass before merging
- Select the test jobs that must pass
Coverage Thresholds
Fail the build when coverage drops:
{
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
Lint as a Gate
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run lint
# If lint fails, all downstream jobs are skipped
Handling Flaky Tests
Flaky tests — tests that sometimes pass and sometimes fail without code changes — erode trust in the pipeline.
Retry Strategy
# GitHub Actions — retry the entire job
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: Run tests with retry
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: npm test
Jest Retry Per-Test
// jest.config.js
module.exports = {
// Retry failed tests up to 2 times
// Only use this for known-flaky tests while fixing them
retryTimes: process.env.CI ? 2 : 0
};
Quarantining Flaky Tests
// Tag flaky tests so they can be tracked and fixed
var flakyDescribe = process.env.SKIP_FLAKY ? describe.skip : describe;
flakyDescribe("WebSocket reconnection", function() {
// This test is flaky due to timing issues
// Tracked in issue #234
test("reconnects after disconnect", function() {
// ...
});
});
# Run stable tests as a gate, flaky tests as informational
stable-tests:
runs-on: ubuntu-latest
steps:
- run: SKIP_FLAKY=1 npm test
flaky-tests:
runs-on: ubuntu-latest
continue-on-error: true # Do not block the pipeline
steps:
- run: npm test -- --testPathPattern="flaky"
Optimizing Pipeline Speed
Run Only Affected Tests
# Only test changed files
- name: Get changed files
id: changed
run: |
echo "files=$(git diff --name-only ${{ github.event.before }} HEAD -- 'src/**/*.js' | tr '\n' ' ')" >> $GITHUB_OUTPUT
- name: Run affected tests
run: npx jest --findRelatedTests ${{ steps.changed.outputs.files }}
Skip Tests for Documentation-Only Changes
on:
push:
paths-ignore:
- "**.md"
- "docs/**"
- ".gitignore"
- "LICENSE"
Conditional E2E Tests
Run expensive tests only when relevant code changes:
e2e-tests:
needs: unit-tests
if: |
contains(github.event.head_commit.message, '[e2e]') ||
github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e
Database Testing in CI
PostgreSQL Service
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: app_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- run: npm ci
- name: Run migrations
run: npm run db:migrate
env:
DATABASE_URL: postgresql://test:test@localhost:5432/app_test
- name: Run tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/app_test
MongoDB Service
services:
mongo:
image: mongo:7
ports:
- 27017:27017
steps:
- run: npm ci
- run: npm run test:integration
env:
MONGO_URL: mongodb://localhost:27017/test
Redis Service
services:
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
Complete Pipeline Example
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Stage 1: Fast checks
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run typecheck
# Stage 2: Unit tests (parallel shards)
unit-tests:
needs: lint-and-typecheck
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx jest --shard=${{ matrix.shard }}/3 --ci --coverage --reporters=default --reporters=jest-junit
env:
JEST_JUNIT_OUTPUT_DIR: ./reports
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-shard-${{ matrix.shard }}
path: |
reports/
coverage/
# Stage 3: Integration tests
integration-tests:
needs: unit-tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: app_test
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run db:migrate
env:
DATABASE_URL: postgresql://test:test@localhost:5432/app_test
- run: npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/app_test
REDIS_URL: redis://localhost:6379
# Stage 4: E2E tests (only on main or when requested)
e2e-tests:
needs: integration-tests
if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'run-e2e')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: e2e-screenshots
path: test-results/
# Summary job — required for branch protection
test-summary:
needs: [lint-and-typecheck, unit-tests, integration-tests]
if: always()
runs-on: ubuntu-latest
steps:
- name: Check results
run: |
if [ "${{ needs.lint-and-typecheck.result }}" != "success" ] ||
[ "${{ needs.unit-tests.result }}" != "success" ] ||
[ "${{ needs.integration-tests.result }}" != "success" ]; then
echo "One or more required jobs failed"
exit 1
fi
echo "All required checks passed"
Common Issues and Troubleshooting
Tests pass locally but fail in CI
Environment differences: different Node version, missing environment variables, or different OS:
Fix: Match Node version in CI to local development. List all required environment variables in your pipeline config. Use npm ci instead of npm install in CI for deterministic installs.
Pipeline is too slow
Running all tests sequentially takes too long:
Fix: Split tests into parallel shards. Cache node_modules between runs. Skip expensive tests on non-critical branches. Use paths-ignore to skip pipelines for documentation changes.
Database tests fail intermittently in CI
The database service is not ready when tests start:
Fix: Add health checks to service containers. Wait for the database to be ready before running migrations. Add retry logic for initial database connections.
CI minutes are expensive
Testing every commit on every branch consumes resources:
Fix: Use concurrency groups to cancel redundant runs. Limit matrix testing to main branch pushes. Run E2E tests only on main or when explicitly requested. Use paths filters to skip unrelated changes.
Best Practices
- Fail fast. Run linting and type checking before tests. If the code does not compile or has style issues, there is no need to run the test suite.
- Cache aggressively.
node_modules, test results, and build artifacts should be cached between pipeline runs. The first run is slow; subsequent runs should reuse cached data. - Use
npm ciin CI, notnpm install.npm ciinstalls frompackage-lock.jsonexactly, ensuring deterministic builds.npm installcan resolve to different versions. - Report test results in CI-native format. JUnit XML, Cobertura coverage, and test summaries give developers rich feedback directly in the CI interface.
- Cancel redundant pipeline runs. When a developer pushes multiple commits quickly, cancel the older runs. Only the latest commit matters.
- Keep the main branch green. If tests fail on main, fixing them is the team's top priority. A broken main branch means nobody can deploy.
- Separate required and optional checks. Unit tests and linting should be required. Performance tests and E2E tests can be informational. Do not block merges on tests that are flaky or slow.
- Monitor pipeline duration. Track how long your pipeline takes over time. If it grows from 5 minutes to 15 minutes, investigate and optimize before developers start ignoring it.
- Test the pipeline itself. When you change CI configuration, verify the changes work. A broken pipeline that silently passes is worse than no pipeline at all.