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 |
PLACEMENT PRO TIP**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 THIS**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.
COMMON MISTAKE / WARNING**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 / WARNING**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.