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:
/\ / \ / 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 succeededTest types and their CI placement:
+------------------------------------------+| 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
## 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: 7Milestone 2 -- Integration tests with real services
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.xmlMilestone 3 -- E2E smoke tests post-deployment
## 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 1Milestone 4 -- Test result reporting
- 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
## 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 pipelinejobs: 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 investigationMilestone 6 -- Parallel test execution for speed
## 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-summaryProduction 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 |
TipRun
--testPathPatternto 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/mainfor PR pipelines.
RememberTest 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.
SecurityIntegration 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 MistakeUsing
continue-on-error: trueon 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 usecontinue-on-error: truefor explicitly quarantined flaky tests that you are tracking separately.