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.
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.
Related articles
- CI/CD GitHub Actions vs Jenkins: CI/CD Compared
Compare GitHub Actions and Jenkins for CI/CD pipelines. Covers setup, configuration, plugins, pricing, and when to choose each tool for your team.
- CI/CD GitHub Actions: Complete CI/CD Guide for Any Project
Set up GitHub Actions CI/CD from scratch — workflows, jobs, caching, secrets, matrix builds, deployment, and reusable patterns for any language or framework.
- CI/CD CI/CD Pipeline Best Practices for Faster Deployments
Build fast, reliable CI/CD pipelines — parallel stages, caching, fast feedback, trunk-based development, and deployment automation patterns.
- CI/CD Automated Deployment Rollbacks with Observability
Build automated rollback systems that use health checks, error rate thresholds, and observability signals to detect failures and revert deployments without human intervention.