Skip to main content

Automated Testing in CI — Unit, Integration, and E2E Gates

Build reliable automated testing gates in CI — unit tests with coverage enforcement, integration tests with real dependencies, and E2E smoke tests post-deployment.

22 Terms

Overview and What You Will Learn

A pipeline without tests is a deployment button. Tests are what give CI/CD its value -- they are the automated quality gates that let engineers deploy multiple times a day with confidence. Without them, every deployment is a risk. With them, every deployment is verified.

By the end of this lab you will:

  • Structure a test suite following the test pyramid for CI efficiency
  • Run unit tests with coverage enforcement in GitHub Actions
  • Configure integration tests with real database and cache services
  • Set up E2E smoke tests that run post-deployment
  • Detect and quarantine flaky tests before they destroy pipeline trust
  • Parallelise test suites across multiple runners for speed

Why This Matters in Production

A CRED backend service has 1,200 unit tests that run in 3 minutes and 80 integration tests that run in 8 minutes. Without CI, a developer breaking a payment flow might not discover it until QA finds it three days later. With CI, the failure appears on the PR within 4 minutes of the commit. The cost of the bug drops from hours of debugging to a one-line fix.

Core Principles

The test pyramid mapped to CI stages:

◈ DIAGRAM
/\
/ \
/ E2E \
/ (slow) \
/ 5-10 tests\
+--------------+
/ \
/ Integration Tests \
/ (medium speed) \
/ 50-200 tests \
+------------------------+
/ \
/ Unit Tests \
/ (fast) \
/ 500-2000 tests \
+------------------------------------+
In CI pipeline:
Unit tests: Stage 2, parallel, 2-4 minutes
Integration tests: Stage 3, sequential, 5-10 minutes
E2E smoke tests: After deploy, verify deployment succeeded

Test types and their CI placement:

◈ DIAGRAM
+------------------------------------------+
| Unit Tests |
| What: test one function in isolation |
| Speed: milliseconds each |
| When: every commit, every PR |
| Gate: fail pipeline on any failure |
+------------------------------------------+
| Integration Tests |
| What: test service with real database |
| Speed: seconds each |
| When: every commit to main, every PR |
| Gate: fail pipeline on any failure |
+------------------------------------------+
| E2E Smoke Tests |
| What: verify deployed service works |
| Speed: seconds per test |
| When: after each deployment |
| Gate: rollback deployment on failure |
+------------------------------------------+

Detailed Step-by-Step Practical Lab

Milestone 1 -- Unit tests with coverage gate

YAML
## GitHub Actions: unit tests with coverage enforcement
unit-test:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run unit tests with coverage
run: |
npm test -- \\
--coverage \\
--coverageReporters=text-summary \\
--coverageReporters=json-summary \\
--testPathPattern='unit'
- name: Enforce coverage threshold
run: |
LINES=$(cat coverage/coverage-summary.json | \\
jq '.total.lines.pct')
BRANCHES=$(cat coverage/coverage-summary.json | \\
jq '.total.branches.pct')
echo "Lines: $LINES%, Branches: $BRANCHES%"
fail=false
if (( $(echo "$LINES < 80" | bc -l) )); then
echo "::error::Line coverage $LINES% below 80%"
fail=true
fi
if (( $(echo "$BRANCHES < 70" | bc -l) )); then
echo "::error::Branch coverage $BRANCHES% below 70%"
fail=true
fi
if [ "$fail" = 'true' ]; then exit 1; fi
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: unit-coverage
path: coverage/
retention-days: 7

Milestone 2 -- Integration tests with real services

YAML
integration-test:
runs-on: ubuntu-latest
needs: [unit-test, lint]
## Service containers: real dependencies for integration tests
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_DB: payment_test
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
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
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-retries 5
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/payment_test
REDIS_URL: redis://localhost:6379
NODE_ENV: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Run database migrations
run: npm run db:migrate
- name: Seed test data
run: npm run db:seed:test
- name: Run integration tests
run: |
npm test -- \\
--testPathPattern='integration' \\
--forceExit \\
--runInBand ## run sequentially to avoid DB conflicts
- name: Upload integration test results
uses: actions/upload-artifact@v4
if: always()
with:
name: integration-results
path: test-results/integration.xml

Milestone 3 -- E2E smoke tests post-deployment

YAML
## Run after staging deploy to verify the deployment worked
smoke-test-staging:
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- uses: actions/checkout@v4
- name: Wait for deployment to stabilise
run: sleep 15
- name: Run smoke tests against staging
env:
BASE_URL: https://staging.payment.internal
API_KEY: ${{ secrets.STAGING_API_KEY }}
run: |
## Test 1: health check
STATUS=$(curl -sf -o /dev/null -w '%{http_code}' \\
$BASE_URL/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed: $STATUS"
exit 1
fi
## Test 2: payment flow end-to-end
npm run test:smoke -- \\
--testURL=$BASE_URL \\
--testPathPattern='smoke'
- name: Rollback on smoke test failure
if: failure()
run: |
echo "Smoke tests failed -- triggering rollback"
kubectl rollout undo deployment/payment-api -n payment-api-staging
exit 1

Milestone 4 -- Test result reporting

YAML
- name: Run tests with JUnit output
run: |
npm test -- \\
--reporter=jest-junit \\
--testResultsProcessor jest-junit
env:
JEST_JUNIT_OUTPUT_DIR: ./test-results
JEST_JUNIT_OUTPUT_NAME: junit.xml
## GitHub Actions: annotate PR with test failures
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: test-results/junit.xml
## Posts a comment on the PR with test summary
comment_title: 'Test Results'
## GitHub: post coverage as PR comment
- name: Post coverage comment
uses: MishaKav/jest-coverage-comment@main
with:
coverage-summary-path: coverage/coverage-summary.json
title: 'Coverage Report'

Milestone 5 -- Detecting and handling flaky tests

Bash
## Identify flaky tests: tests that sometimes pass, sometimes fail
## Look for tests that fail in CI but pass locally
## Or tests that fail intermittently across pipeline runs
## Strategy 1: retry flaky tests automatically (short term fix)
## In Jest configuration:
module.exports = {
## Retry each failing test up to 2 times before marking as failed
testRunner: '@jest/jasmine2',
## jest-circus supports retries natively:
## jest.retryTimes(2) in the test file
};
## Strategy 2: quarantine known flaky tests
## Mark with a specific tag, run separately, do not block pipeline
jobs:
test:
steps:
- name: Run stable tests (block pipeline on failure)
run: npm test -- --testPathIgnorePatterns='flaky'
- name: Run flaky tests (report but do not block)
run: npm test -- --testPathPattern='flaky'
continue-on-error: true ## never blocks the pipeline
## Strategy 3: track flakiness over time
## Parse test results XML across multiple runs
## Flag tests with >5% failure rate for investigation

Milestone 6 -- Parallel test execution for speed

YAML
## Split test suite across multiple runners
unit-test:
runs-on: ubuntu-latest
needs: build
strategy:
matrix:
## Split tests into 4 shards
shard: [1, 2, 3, 4]
total-shards: [4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Run test shard ${{ matrix.shard }}
run: |
## Jest native sharding:
npx jest \\
--shard=${{ matrix.shard }}/${{ matrix.total-shards }} \\
--coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-shard-${{ matrix.shard }}
path: coverage/
## Merge coverage from all shards
coverage-merge:
needs: unit-test
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: coverage-shard-*
merge-multiple: true
- run: npx nyc merge . merged-coverage.json
- run: npx nyc report --reporter=text-summary

Production Best Practices and Common Pitfalls

Mistake Problem Fix
Testing in production order Slow tests block fast ones Run unit before integration
No coverage gate Coverage erodes over time Enforce minimum in CI, fail on drop
E2E tests as primary gate Too slow, too brittle E2E as post-deploy verification only
Ignoring flaky tests Team stops trusting pipeline Quarantine, investigate, fix or delete
No test timeout Hung test blocks pipeline Set --testTimeout=10000 in Jest config

Quick Reference and Troubleshooting Commands

Task Command
Run only unit tests npm test -- --testPathPattern=unit
Run with coverage npm test -- --coverage
Run single file npm test -- path/to/test.spec.js
Debug test in watch mode npm test -- --watch
Run Jest shards npx jest --shard=1/4
Show coverage per file npm test -- --coverage --verbose
Find slow tests `npm test -- --verbose 2>&1
Tip

Run --testPathPattern to run only the tests relevant to the files you changed. In a monorepo, running all 2000 tests for a change to the payment module wastes 10 minutes. Configure Jest to only run tests related to changed files with --changedSince=origin/main for PR pipelines.

Remember

Test coverage percentage is a floor, not a ceiling. Enforcing 80% coverage means at least 80% of lines are executed by tests -- it says nothing about whether those tests actually assert meaningful behaviour. Coverage gates prevent regression but do not guarantee test quality. Code review catches the rest.

Security

Integration test databases must use separate credentials from production databases. Never point integration tests at a real production database -- even a staging database with production data. Use ephemeral databases spun up by the CI service containers, seeded with synthetic test data that contains no real user information.

Common Mistake

Using continue-on-error: true on the main test job to prevent pipeline failures. This makes the pipeline always green regardless of test outcomes -- which is worse than no tests at all because it creates false confidence. Only use continue-on-error: true for explicitly quarantined flaky tests that you are tracking separately.

Resources

Explore More in CI/CD Fundamentals and Pipeline Design

All 3 Topics

Frequently Asked Questions

Is Automated Testing in CI — Unit, Integration, and E2E Gates free to learn on DevOps Network?

Yes - this topic, like everything on DevOps Network, is 100% free with no paywall or sign-up gate.

What does the Automated Testing in CI — Unit, Integration, and E2E Gates topic cover?

Build reliable automated testing gates in CI — unit tests with coverage enforcement, integration tests with real dependencies, and E2E smoke tests post-deployment.