Home/Learn/CI/CD & GitHub Actions/Testing in CI — Unit, Integration & E2E

Testing in CI — Unit, Integration & E2E

Intermediate
Testing

Effective CI testing runs fast unit tests first, then slower integration tests with real dependencies (Docker), then E2E tests. Parallelisation, test splitting, and smart caching keep pipelines fast.

Overview

The test pyramid defines the optimal balance: many fast unit tests (milliseconds each), fewer integration tests (seconds each, need real DBs), even fewer E2E tests (minutes each, full browser/API flows). In CI, the ordering matters: run the cheapest tests first to fail fast. Docker Compose services in CI spin up real dependencies (PostgreSQL, Redis) for integration tests, matching production more closely than mocks. Parallelisation (splitting test files across runners) and intelligent caching (storing test results) keep pipelines under 10 minutes even for large codebases.

Test Pyramid in CI

Structure your pipeline stages to match the test pyramid: fast at the bottom, slow at the top. Fail the pipeline at the cheapest stage possible.

Test pyramid pipeline stages
# .github/workflows/test.yml

name: Test Suite



on: [push, pull_request]



jobs:

  # Stage 1: Fast checks (< 1 minute) — fail fast

  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             # ESLint, Prettier

      - run: npm run typecheck        # TypeScript tsc --noEmit



  # Stage 2: Unit tests (< 3 minutes) — run if lint passes

  unit-tests:

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

      - name: Upload coverage to Codecov

        uses: codecov/codecov-action@v4

        with:

          token: ${{ secrets.CODECOV_TOKEN }}



  # Stage 3: Integration tests (< 5 minutes) — real DB/Redis

  integration-tests:

    needs: unit-tests

    runs-on: ubuntu-latest

    services:                         # GitHub-managed Docker services

      postgres:

        image: postgres:16-alpine

        env:

          POSTGRES_PASSWORD: test

          POSTGRES_DB: testdb

        ports: ['5432:5432']

        options: >-

          --health-cmd pg_isready

          --health-interval 10s

          --health-timeout 5s

          --health-retries 5

      redis:

        image: redis:7-alpine

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

        env:

          DATABASE_URL: postgres://postgres:test@localhost:5432/testdb

          REDIS_URL: redis://localhost:6379

Parallel Test Splitting

Large test suites can be split across multiple runner instances using matrix strategy or test splitting tools. This turns a 15-minute suite into 3 minutes across 5 parallel runners.

Parallel test sharding with Jest and Playwright
jobs:

  # Split unit tests across 4 parallel shards

  unit-tests:

    strategy:

      matrix:

        shard: [1, 2, 3, 4]

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4

        with: { node-version: '20', cache: 'npm' }

      - run: npm ci

      # Jest built-in shard support

      - run: npx jest --shard=${{ matrix.shard }}/4 --coverage

      - uses: actions/upload-artifact@v4

        with:

          name: coverage-shard-${{ matrix.shard }}

          path: coverage/



  # Merge coverage reports from all shards

  coverage-report:

    needs: unit-tests

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4

        with:

          pattern: coverage-shard-*

          merge-multiple: true

          path: coverage/

      - run: npx nyc merge coverage coverage/merged.json

      - run: npx nyc report --reporter=lcov



# Playwright E2E split across browsers + shards:

  e2e:

    strategy:

      matrix:

        browser: [chromium, firefox]

        shard: [1, 2]

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4

        with: { node-version: '20', cache: 'npm' }

      - run: npm ci && npx playwright install --with-deps ${{ matrix.browser }}

      - run: npx playwright test --shard=${{ matrix.shard }}/2 --project=${{ matrix.browser }}

Test Results, PR Comments & Branch Protection

Publishing test results as PR comments and enforcing coverage thresholds as branch protection rules creates a quality gate that prevents regressions.

Test reporting and branch protection rules
jobs:

  test:

    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4

        with: { node-version: '20', cache: 'npm' }

      - run: npm ci

      # Jest with JUnit reporter for structured results

      - run: npm test -- --reporters=default --reporters=jest-junit

        env:

          JEST_JUNIT_OUTPUT_FILE: junit.xml

        continue-on-error: true       # don't fail yet — post results first

        id: tests



      # Post test results as PR comment

      - name: Test Report

        uses: dorny/test-reporter@v1

        if: always()

        with:

          name: Jest Test Results

          path: junit.xml

          reporter: jest-junit



      # Enforce coverage threshold

      - name: Check coverage

        run: |

          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')

          echo "Coverage: $COVERAGE%"

          if (( $(echo "$COVERAGE < 80" | bc -l) )); then

            echo "Coverage $COVERAGE% is below 80% threshold"

            exit 1

          fi



      # Fail job if tests failed

      - name: Fail if tests failed

        if: steps.tests.outcome == 'failure'

        run: exit 1



# Branch Protection Rules (GitHub Settings):

# Require status checks to pass before merging:

# - lint-and-typecheck

# - unit-tests

# - integration-tests

# Require branches to be up to date

# Require pull request reviews: 1 approver

# This enforces quality gates at the repo level, not just in workflows

Key Points to Remember

  • 1Test pyramid: unit tests first (fast, many), integration tests second, E2E last (slow, few).
  • 2GitHub services: block in jobs spins up Docker containers (Postgres, Redis) as sidecar services for integration tests.
  • 3Jest sharding (--shard=N/TOTAL) splits test files across parallel runners — linear speedup.
  • 4dorny/test-reporter posts JUnit results as PR comments — no external service needed.
  • 5Branch protection rules enforce required status checks — cannot merge without passing CI.
  • 6continue-on-error: true + explicit failure step lets you collect test artifacts before failing the job.

Interview Questions

Sign in to ask Aria
1

How do you run tests against a real database in GitHub Actions?

2

How do you speed up a slow test suite in CI?

Ask Aria about Testing in CI — Unit, Integration & E2E

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…