CI/CD Integration
Learn how to integrate testing into your continuous integration and continuous deployment pipelines using GitHub Actions, GitLab CI, CircleCI, and pre-commit hooks.
Why CI/CD for Testing?
Automated testing in CI/CD pipelines ensures code quality by:
- Catching bugs early: Before code reaches production
- Enforcing standards: Automated quality gates
- Fast feedback: Developers know immediately if tests fail
- Preventing regressions: Tests run on every change
- Parallel execution: Tests run faster in CI
- Consistent environment: Same results every time
Running tests in CI/CD is essential for maintaining code quality in team environments. It prevents broken code from being merged.
GitHub Actions
GitHub Actions is a CI/CD platform integrated directly into GitHub repositories.
Basic Test Workflow
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
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 linter
run: npm run lint
- name: Run tests
run: npm test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
fail_ci_if_error: trueMulti-Node Version Testing
# .github/workflows/test-matrix.yml
name: Test Matrix
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm testAdvanced Workflow with Caching
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: ESLint
run: npm run lint
- name: TypeScript check
run: npm run type-check
unit-test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: Run unit tests
run: npm run test:unit -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-unit
path: coverage/
integration-test:
name: Integration Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: Run migrations
run: npm run db:migrate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
REDIS_URL: redis://localhost:6379
e2e-test:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
build:
name: Build
needs: [lint, unit-test, integration-test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/Conditional Execution
# .github/workflows/test-conditional.yml
name: Test Conditional
on: [push, pull_request]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'src/server/**'
- 'src/api/**'
frontend:
- 'src/components/**'
- 'src/pages/**'
test-backend:
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:backend
test-frontend:
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:frontendUse matrix builds to test across multiple Node versions and operating systems. Use conditional execution to skip unnecessary tests.
GitLab CI
GitLab CI/CD is built into GitLab with powerful features for testing.
Basic Pipeline
# .gitlab-ci.yml
image: node:20
stages:
- install
- lint
- test
- build
cache:
paths:
- node_modules/
- .npm/
install:
stage: install
script:
- npm ci --cache .npm --prefer-offline
artifacts:
paths:
- node_modules/
expire_in: 1 hour
lint:
stage: lint
dependencies:
- install
script:
- npm run lint
- npm run type-check
test:unit:
stage: test
dependencies:
- install
script:
- npm run test:unit -- --coverage
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
test:integration:
stage: test
services:
- postgres:14
- redis:7
variables:
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/test_db
REDIS_URL: redis://redis:6379
dependencies:
- install
script:
- npm run db:migrate
- npm run test:integration
test:e2e:
stage: test
dependencies:
- install
script:
- npx playwright install --with-deps
- npm run test:e2e
artifacts:
when: always
paths:
- playwright-report/
expire_in: 1 week
build:
stage: build
dependencies:
- install
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 week
only:
- main
- developParallel Testing
# .gitlab-ci.yml
test:parallel:
stage: test
parallel: 4
script:
- npm run test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTALMulti-Environment Testing
# .gitlab-ci.yml
.test_template:
stage: test
script:
- npm ci
- npm test
test:node18:
extends: .test_template
image: node:18
test:node20:
extends: .test_template
image: node:20
test:node22:
extends: .test_template
image: node:22CircleCI
CircleCI is a popular CI/CD platform with advanced caching and parallelization.
Basic Configuration
# .circleci/config.yml
version: 2.1
orbs:
node: circleci/node@5.1.0
jobs:
test:
docker:
- image: cimg/node:20.11
steps:
- checkout
- node/install-packages:
pkg-manager: npm
- run:
name: Run tests
command: npm test
- store_test_results:
path: test-results
- store_artifacts:
path: coverage
workflows:
test-workflow:
jobs:
- testAdvanced Pipeline
# .circleci/config.yml
version: 2.1
orbs:
node: circleci/node@5.1.0
executors:
node-executor:
docker:
- image: cimg/node:20.11
jobs:
install-dependencies:
executor: node-executor
steps:
- checkout
- restore_cache:
keys:
- v1-deps-{{ checksum "package-lock.json" }}
- v1-deps-
- run: npm ci
- save_cache:
key: v1-deps-{{ checksum "package-lock.json" }}
paths:
- node_modules
- persist_to_workspace:
root: .
paths:
- node_modules
lint:
executor: node-executor
steps:
- checkout
- attach_workspace:
at: .
- run: npm run lint
unit-test:
executor: node-executor
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Run unit tests
command: npm run test:unit -- --coverage
- store_test_results:
path: test-results
- store_artifacts:
path: coverage
- persist_to_workspace:
root: .
paths:
- coverage
integration-test:
executor: node-executor
docker:
- image: cimg/node:20.11
- image: cimg/postgres:14.0
environment:
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
- image: cimg/redis:7.0
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Wait for Postgres
command: dockerize -wait tcp://localhost:5432 -timeout 1m
- run:
name: Run migrations
command: npm run db:migrate
environment:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
- run:
name: Run integration tests
command: npm run test:integration
environment:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
REDIS_URL: redis://localhost:6379
e2e-test:
executor: node-executor
steps:
- checkout
- attach_workspace:
at: .
- run: npx playwright install --with-deps
- run: npm run test:e2e
- store_artifacts:
path: playwright-report
build:
executor: node-executor
steps:
- checkout
- attach_workspace:
at: .
- run: npm run build
- persist_to_workspace:
root: .
paths:
- dist
workflows:
version: 2
test-and-build:
jobs:
- install-dependencies
- lint:
requires:
- install-dependencies
- unit-test:
requires:
- install-dependencies
- integration-test:
requires:
- install-dependencies
- e2e-test:
requires:
- install-dependencies
- build:
requires:
- lint
- unit-test
- integration-testParallel Testing
# .circleci/config.yml
test:
parallelism: 4
executor: node-executor
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Run tests in parallel
command: |
TESTFILES=$(circleci tests glob "src/**/*.test.ts" | circleci tests split --split-by=timings)
npm test -- $TESTFILES
- store_test_results:
path: test-resultsPre-commit Hooks with Husky
Husky enables Git hooks to run tests before commits and pushes.
Installation
npm install --save-dev husky
npx husky initPre-commit Hook
# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run linter
npm run lint
# Run type check
npm run type-check
# Run tests on staged files
npm run test:stagedPre-push Hook
# .husky/pre-push
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run full test suite
npm test
# Check coverage thresholds
npm run test:coverageCommit Message Validation
# .husky/commit-msg
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit $1// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [
2,
'always',
['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore']
]
}
};Pre-commit hooks should be fast (< 10 seconds). Only run essential checks to avoid frustrating developers.
Lint-staged Integration
Lint-staged runs commands only on staged files, making pre-commit hooks faster.
Installation
npm install --save-dev lint-stagedConfiguration
// .lintstagedrc.js
module.exports = {
'*.{ts,tsx}': [
'eslint --fix',
'prettier --write',
() => 'tsc --noEmit' // Type check entire project
],
'*.{js,jsx}': ['eslint --fix', 'prettier --write'],
'*.{json,md,yml,yaml}': ['prettier --write'],
'*.{ts,tsx,js,jsx}': ['jest --bail --findRelatedTests --passWithNoTests']
};Package.json Scripts
{
"scripts": {
"test:staged": "jest --bail --findRelatedTests --passWithNoTests",
"prepare": "husky install"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write",
"jest --bail --findRelatedTests"
]
}
}Husky Integration
# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-stagedAutomated Test Reporting
Coverage Reports
Codecov
# .github/workflows/coverage.yml
- name: Upload to Codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
fail_ci_if_error: trueCoveralls
# .github/workflows/coverage.yml
- name: Upload to Coveralls
uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
path-to-lcov: ./coverage/lcov.infoTest Results
# .github/workflows/test.yml
- name: Publish Test Results
uses: dorny/test-reporter@v1
if: always()
with:
name: Jest Tests
path: 'test-results/jest-*.xml'
reporter: jest-junitStatus Checks
# .github/workflows/status.yml
- name: Update commit status
uses: actions/github-script@v7
with:
script: |
github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.sha,
state: 'success',
description: 'All tests passed',
context: 'CI Tests'
})Parallel Test Execution
Jest Parallel Execution
{
"scripts": {
"test": "jest --maxWorkers=4",
"test:ci": "jest --maxWorkers=50%"
}
}Playwright Sharding
# .github/workflows/playwright.yml
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}Test Splitting by Timing
# .circleci/config.yml
test:
parallelism: 4
steps:
- run:
command: |
TESTFILES=$(circleci tests glob "**/*.test.ts" | circleci tests split --split-by=timings)
npm test -- $TESTFILESComplete CI/CD Example
# .github/workflows/complete-ci-cd.yml
name: Complete CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
COVERAGE_THRESHOLD: 80
jobs:
setup:
name: Setup
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-keys.outputs.node-modules }}
steps:
- uses: actions/checkout@v4
- id: cache-keys
run: echo "node-modules=${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache node modules
id: cache
uses: actions/cache@v3
with:
path: node_modules
key: ${{ steps.cache-keys.outputs.node-modules }}
- name: Install dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: npm ci
lint:
name: Lint & Type Check
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Restore cache
uses: actions/cache@v3
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- run: npm run lint
- run: npm run type-check
unit-tests:
name: Unit Tests
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- run: npm run test:unit -- --coverage
- name: Check coverage threshold
run: |
COVERAGE=$(node -p "require('./coverage/coverage-summary.json').total.lines.pct")
if (( $(echo "$COVERAGE < $COVERAGE_THRESHOLD" | bc -l) )); then
echo "Coverage $COVERAGE% is below threshold $COVERAGE_THRESHOLD%"
exit 1
fi
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
integration-tests:
name: Integration Tests
needs: setup
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- run: npm run db:migrate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
- run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
e2e-tests:
name: E2E Tests
needs: setup
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2]
shardTotal: [2]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.shardIndex }}
path: playwright-report/
build:
name: Build
needs: [lint, unit-tests, integration-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- uses: actions/cache@v3
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
name: Deploy
needs: [build, e2e-tests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to production
run: echo "Deploy to production"
# Add your deployment steps hereBest Practices
1. Fast Feedback
# Run fast tests first
jobs:
quick-checks:
runs-on: ubuntu-latest
steps:
- run: npm run lint # Fast
- run: npm run type-check # Fast
unit-tests: # Medium speed
needs: quick-checks
steps:
- run: npm run test:unit
e2e-tests: # Slow
needs: unit-tests
steps:
- run: npm run test:e2e2. Fail Fast
strategy:
fail-fast: true # Stop all jobs if one fails
matrix:
node: [18, 20, 22]3. Cache Dependencies
- uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-4. Conditional Runs
# Only run on specific branches
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
# Only run for specific paths
on:
push:
paths:
- 'src/**'
- 'tests/**'Troubleshooting Common Issues
Flaky Tests
# Retry flaky tests
- run: npm test -- --maxRetries=3Timeouts
# Increase timeout
- run: npm test
timeout-minutes: 30Memory Issues
# Increase Node memory
- run: NODE_OPTIONS=--max_old_space_size=4096 npm testRelated Topics
- Testing Fundamentals - Core concepts
- Code Coverage - Measuring coverage
- Best Practices - Testing guidelines
- E2E Testing - End-to-end tests