Skip to content
Codeloom
CI/CD

Automated Testing in CI/CD: Unit, Integration, and E2E

Structure automated tests in your CI/CD pipeline — unit, integration, and end-to-end testing strategies, parallel execution, and failure handling.

·5 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How to structure the testing pyramid in CI
  • Running unit, integration, and E2E tests efficiently
  • Parallelizing test suites for speed
  • Handling flaky tests without blocking pipelines
  • Code coverage gates and quality thresholds

Prerequisites

  • Basic CI/CD pipeline experience
  • Familiarity with at least one test framework

A CI/CD pipeline without automated tests is just automated deployment of bugs. But testing in CI is different from testing locally — you need speed, reliability, and clear feedback. Here is how to structure your test stages for fast, trustworthy pipelines.

The Testing Pyramid in CI

The testing pyramid guides how many tests of each type to write and how to run them:

        /  E2E  \          Few, slow, fragile
       /----------\
      / Integration \      Some, moderate speed
     /----------------\
    /    Unit Tests     \  Many, fast, stable
   /--------------------\

Unit tests (hundreds to thousands): Test individual functions and modules in isolation. Run in seconds. No external dependencies.

Integration tests (dozens to hundreds): Test modules working together with real databases, message queues, or APIs. Run in minutes.

E2E tests (tens): Test complete user workflows through the actual UI or API. Run in minutes to tens of minutes.

Structure Tests as Separate Jobs

Run each test tier in its own job. This gives parallel execution and clear failure signals:

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:unit -- --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage/lcov.info

  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:integration
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run build
      - run: npm run test:e2e

Unit and integration tests run in parallel. E2E tests run in a separate job that can depend on the build step.

Service Containers for Integration Tests

GitHub Actions service containers spin up databases and other services alongside your tests:

services:
  redis:
    image: redis:7
    ports:
      - 6379:6379
    options: --health-cmd "redis-cli ping" --health-interval 10s

  postgres:
    image: postgres:16
    env:
      POSTGRES_DB: app_test
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    ports:
      - 5432:5432

Health checks ensure the service is ready before tests start. This eliminates the common “connection refused” failures where tests run before the database accepts connections.

Parallelize Test Execution

Large test suites benefit from splitting across multiple runners:

test:
  strategy:
    matrix:
      shard: [1, 2, 3, 4]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - run: npm ci
    - run: npx jest --shard=${{ matrix.shard }}/4

Jest, Vitest, and pytest all support sharding. Four shards cut a 12-minute test suite to about 3 minutes.

For Playwright E2E tests:

e2e:
  strategy:
    matrix:
      shard: [1/4, 2/4, 3/4, 4/4]
  steps:
    - run: npx playwright test --shard=${{ matrix.shard }}

Code Coverage Gates

Enforce minimum coverage to prevent test rot:

- run: npm run test:unit -- --coverage
- name: Check coverage threshold
  run: |
    COVERAGE=$(npx istanbul-cobertura-report --summary coverage/lcov.info | grep "Lines" | awk '{print $2}')
    if (( $(echo "$COVERAGE < 80" | bc -l) )); then
      echo "Coverage $COVERAGE% is below 80% threshold"
      exit 1
    fi

Be pragmatic about thresholds. 100% coverage is not the goal — meaningful coverage of critical paths is. Start at your current coverage level and ratchet upward. Never let it decrease.

Handle Flaky Tests

Flaky tests — tests that sometimes pass and sometimes fail without code changes — erode pipeline trust. Strategies:

Automatic retries for known flaky tests (a temporary fix):

- run: npm run test:e2e
  continue-on-error: true
  id: e2e-first

- if: steps.e2e-first.outcome == 'failure'
  run: npm run test:e2e --retries=2

Quarantine flaky tests into a non-blocking job:

e2e-stable:
  runs-on: ubuntu-latest
  steps:
    - run: npx playwright test --grep-invert @flaky

e2e-flaky:
  runs-on: ubuntu-latest
  continue-on-error: true
  steps:
    - run: npx playwright test --grep @flaky

The stable suite blocks the pipeline. The flaky suite reports results without blocking. Fix or delete quarantined tests within a week.

Test Reporting

Raw console output is hard to scan. Use test reporters that create GitHub annotations:

- uses: dorny/test-reporter@v1
  if: always()
  with:
    name: Unit Tests
    path: test-results/junit.xml
    reporter: java-junit

This shows test results directly in the PR checks tab with individual test names, durations, and failure details.

Pre-merge vs Post-merge Testing

Not all tests need to run before merge. Split them:

Pre-merge (on PR): Unit tests, integration tests, lint, type check. Must be fast — under 10 minutes.

Post-merge (on main): Full E2E suite, performance tests, visual regression tests, cross-browser tests. Can be slower — up to 30 minutes.

If post-merge tests fail, create an automatic issue or Slack alert. The main branch should never stay broken for long.

Database Testing Patterns

Integration tests that touch a database need isolation:

import pytest

@pytest.fixture(autouse=True)
def db_transaction(db_connection):
    transaction = db_connection.begin()
    yield db_connection
    transaction.rollback()

Each test runs inside a transaction that rolls back after the test. This is faster than truncating tables and prevents test pollution.

For schema setup, run migrations once before the test suite and use transactions for per-test isolation.

Wrapping Up

Automated testing in CI works when you respect the pyramid — many fast unit tests, fewer integration tests with real services, and a handful of E2E tests. Parallelize execution, enforce coverage gates without obsessing over 100%, and quarantine flaky tests immediately. The pipeline should tell you within ten minutes whether your change is safe to ship.