TestingCI/CD Integration

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: true

Multi-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 test

Advanced 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:frontend

Use 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
    - develop

Parallel Testing

# .gitlab-ci.yml
test:parallel:
  stage: test
  parallel: 4
  script:
    - npm run test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

Multi-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:22

CircleCI

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:
      - test

Advanced 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-test

Parallel 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-results

Pre-commit Hooks with Husky

Husky enables Git hooks to run tests before commits and pushes.

Installation

npm install --save-dev husky
npx husky init

Pre-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:staged

Pre-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:coverage

Commit 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-staged

Configuration

// .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-staged

Automated 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: true

Coveralls

# .github/workflows/coverage.yml
- name: Upload to Coveralls
  uses: coverallsapp/github-action@v2
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    path-to-lcov: ./coverage/lcov.info

Test 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-junit

Status 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 -- $TESTFILES

Complete 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 here

Best 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:e2e

2. 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=3

Timeouts

# Increase timeout
- run: npm test
  timeout-minutes: 30

Memory Issues

# Increase Node memory
- run: NODE_OPTIONS=--max_old_space_size=4096 npm test


Additional Resources