Intermediate Lesson 5 of 14

Building & Testing

Docker builds, unit & integration tests, code coverage enforcement, and linting β€” the core stages of any CI pipeline.

πŸ§’ Simple Explanation (ELI5)

Imagine a factory assembly line that builds toy cars. Every car goes through stations in order:

  1. Inspection station (Linting): A worker checks the raw materials β€” are the screws the right size? Are the paint colors correct? If something looks wrong, the line stops immediately before wasting effort.
  2. Testing station (Tests): Another worker puts each part on a test rig. Do the wheels spin? Does the door open and close? If a part fails, the car doesn't move forward.
  3. Quality counter (Coverage): A supervisor counts how many parts were tested. "We tested 85 out of 100 parts β€” that's 85% coverage." If it drops below the minimum (say 80%), the batch is rejected.
  4. Assembly station (Build): Only after inspection and testing pass, the car is assembled into a finished box (Docker image) and stamped with a serial number (image tag).
  5. Shipping dock (Push): The boxed car is sent to the warehouse (container registry) where anyone authorized can pick it up and deploy it.

GitHub Actions automates this entire assembly line. Every time you push code, it runs through every station automatically. If any station fails, the line stops and you get a report telling you exactly what broke.

🐳 Docker Builds in CI

Building container images in CI ensures every artifact is reproducible and built from the exact code in the repository β€” no "it works on my machine" surprises.

docker/build-push-action

The official docker/build-push-action is the recommended way to build and push Docker images in GitHub Actions. It wraps Docker Buildx and supports multi-platform builds, layer caching, and registry authentication out of the box.

yaml
name: Build and Push Docker Image
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write          # Required for GHCR push

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
πŸ’‘
Tagging Strategy

Always tag images with the commit SHA (github.sha) for traceability β€” you can tell exactly which commit produced each image. Add :latest for convenience, but never rely solely on it β€” latest is mutable and can point to different images over time. For releases, also tag with the semantic version (v1.2.3).

Multi-Stage Builds

Multi-stage Dockerfiles keep your final image small by separating build dependencies from runtime:

dockerfile
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]

The builder stage has all dev tools (TypeScript compiler, test frameworks, etc.). The final image copies only the compiled output β€” resulting in a much smaller, more secure image.

Pushing to Azure Container Registry (ACR)

yaml
      - name: Log in to ACR
        uses: docker/login-action@v3
        with:
          registry: myregistry.azurecr.io
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      - name: Build and push to ACR
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: myregistry.azurecr.io/myapp:${{ github.sha }}

πŸ§ͺ Running Tests in CI

Automated tests in CI catch regressions before they reach production. GitHub Actions supports every testing framework β€” the runner just executes commands.

Unit Tests

yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci
      - run: npm test

Service Containers

When your tests need external services (databases, caches, message queues), GitHub Actions can spin up service containers alongside your job. They run in Docker on the same network as your steps.

yaml
jobs:
  integration-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd="pg_isready -U testuser"
          --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-timeout=5s
          --health-retries=5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run integration tests
        env:
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379
        run: npm run test:integration
πŸ’‘
Health Checks Are Essential

Always add options with health checks for service containers. Without them, your test steps may start before the database is ready, leading to flaky "connection refused" failures. The job runner waits for all services to be healthy before executing steps.

Test Reporting

Publishing test results as workflow artifacts or PR annotations makes failures visible without digging through logs:

yaml
      - run: npm test -- --reporter=junit --outputFile=test-results.xml
      - name: Publish test results
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: 'Unit Tests'
          path: test-results.xml
          reporter: jest-junit

πŸ“Š Code Coverage

Coverage measures what percentage of your code is exercised by tests. It's not a guarantee of quality, but low coverage is a strong signal of risk β€” untested code is where bugs hide.

Codecov Integration

yaml
      - run: npm test -- --coverage --coverageReporters=lcov
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info
          fail_ci_if_error: true

Coverage Thresholds

Enforce minimum coverage in your test framework config so the CI job fails if coverage drops:

json
// jest.config.js (or package.json "jest" section)
{
  "coverageThreshold": {
    "global": {
      "branches": 80,
      "functions": 80,
      "lines": 80,
      "statements": 80
    }
  }
}

Coverage Badges

Add a coverage badge to your README for visibility. Codecov provides a dynamic badge URL after setup:

markdown
[![codecov](https://codecov.io/gh/YOUR-ORG/YOUR-REPO/branch/main/graph/badge.svg)](https://codecov.io/gh/YOUR-ORG/YOUR-REPO)
⚠️
Coverage β‰  Quality

100% coverage doesn't mean your code is bug-free. You can cover every line with tests that never assert anything meaningful. Focus on meaningful assertions over coverage numbers. That said, dropping below ~80% usually means large areas of your code are completely untested β€” that's a legitimate risk.

πŸ” Linting in CI

Linters catch code style issues, potential bugs, and anti-patterns before tests even run. They're cheap to execute and catch problems early.

Language-Specific Linters

yaml
# ESLint for JavaScript/TypeScript
      - run: npx eslint . --max-warnings 0

# Flake8 for Python
      - run: pip install flake8
      - run: flake8 src/ --max-line-length=120 --statistics

# golangci-lint for Go
      - uses: golangci/golangci-lint-action@v4
        with:
          version: latest

Super-Linter (Multi-Language)

GitHub's super-linter runs dozens of linters in a single action β€” useful for polyglot repos:

yaml
      - name: Super-Linter
        uses: super-linter/super-linter@v6
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          VALIDATE_JAVASCRIPT_ES: true
          VALIDATE_PYTHON_FLAKE8: true
          VALIDATE_DOCKERFILE_HADOLINT: true
          VALIDATE_YAML: true

Fail-Fast Strategy

Lint should be the first job in your pipeline. If code style is broken, there's no point running expensive tests or builds:

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx eslint . --max-warnings 0

  test:
    needs: lint              # Only runs if lint passes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test

  build:
    needs: test              # Only runs if test passes
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t myapp .

πŸš€ Full CI Pipeline β€” Lint β†’ Test β†’ Build β†’ Push

Here's a production-ready pipeline combining everything covered above:

yaml
name: CI Pipeline
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  # ── Stage 1: Lint ──
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npx eslint . --max-warnings 0
      - run: npx prettier --check .

  # ── Stage 2: Test (with service containers) ──
  test:
    needs: lint
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: ci
          POSTGRES_PASSWORD: ci
          POSTGRES_DB: app_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd="pg_isready -U ci"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run tests with coverage
        env:
          DATABASE_URL: postgresql://ci:ci@localhost:5432/app_test
        run: npm test -- --coverage --coverageReporters=lcov
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: ./coverage/lcov.info

  # ── Stage 3: Build ──
  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: ${{ github.event_name == 'push' }}
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
πŸ’‘
Push Only on Main

The push: ${{ github.event_name == 'push' }} conditional ensures images are only pushed to the registry on main branch pushes β€” not on pull requests. PRs still build the image (to catch build errors) but don't push it.

πŸ“ Pipeline Flow Diagram

text
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  git push / pull_request                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage 1: LINT           β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ ESLint / Flake8    β”‚  β”‚
β”‚  β”‚ Prettier / Format  β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚  ❌ Fail β†’ pipeline stopsβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β–Ό (needs: lint)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage 2: TEST           β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ Service: PostgreSQL β”‚  β”‚
β”‚  β”‚ Service: Redis      β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ Unit tests          β”‚  β”‚
β”‚  β”‚ Integration tests   β”‚  β”‚
β”‚  β”‚ Coverage β†’ Codecov  β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚  ❌ Fail β†’ pipeline stopsβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β–Ό (needs: test)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Stage 3: BUILD & PUSH   β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚ Docker Buildx      β”‚  β”‚
β”‚  β”‚ Multi-stage build   β”‚  β”‚
β”‚  β”‚ Tag: sha + latest   β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚  Push to GHCR / ACR     β”‚
β”‚  (only on main branch)  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ› οΈ Hands-on Exercises

πŸ‹οΈ
Exercise 1 β€” Add Linting to an Existing Workflow

Take a workflow that only runs npm test. Add a lint job that runs ESLint before tests. Use needs: lint on the test job so it only runs after linting passes. Push a file with a deliberate lint error and verify the pipeline stops at the lint stage.

πŸ‹οΈ
Exercise 2 β€” Service Containers for Integration Tests

Create a workflow with a PostgreSQL service container. Write a simple test that connects to the database, creates a table, inserts a row, and queries it. Use health checks so the test waits until PostgreSQL is ready. Verify the test passes in CI.

πŸ‹οΈ
Exercise 3 β€” Enforce Coverage Thresholds

Add Jest coverage to your test job with an 80% threshold. Upload results to Codecov. Intentionally comment out tests to drop coverage below 80% and confirm the job fails. Then add the Codecov badge to your README.

πŸ‹οΈ
Exercise 4 β€” Build & Push a Docker Image

Write a multi-stage Dockerfile for a Node.js app. Create a workflow that builds the image using docker/build-push-action, tags it with github.sha, and pushes to GHCR. Use cache-from: type=gha for layer caching. Verify the image appears in your repository's Packages tab.

πŸ› Common Issues & Debugging

Service Container Is Unhealthy

text
Error: Service 'postgres' is unhealthy.

Cause: Missing or incorrect health check, wrong credentials in env, or the image tag doesn't exist.

Fix: Verify the options health-check command matches the image (e.g., pg_isready -U <username>). Ensure the POSTGRES_USER in env matches the user in the health check. Increase --health-retries if the service needs more startup time.

Tests Pass Locally but Fail in CI

Cause: Different Node.js/Python version, missing environment variables, or tests depend on local filesystem state.

Fix: Pin the language version with actions/setup-node or actions/setup-python. Pass all required env vars in the env: block. Avoid hardcoded paths β€” use process.cwd() or os.path equivalents.

Docker Build Fails β€” Layer Cache Miss

text
ERROR: failed to solve: failed to compute cache key: "/package-lock.json" not found

Cause: The build context doesn't include the required files, or .dockerignore excludes them.

Fix: Set context: . in the build action and check your .dockerignore file. Make sure package-lock.json (or equivalent lock file) is committed to the repository.

Docker Push β€” Permission Denied

text
denied: permission_denied: write_package

Cause: Missing permissions: packages: write on the job, or GITHUB_TOKEN lacks push access.

Fix: Add permissions: { contents: read, packages: write } to the job. For organization repos, ensure the package visibility settings allow pushes from Actions.

Coverage Below Threshold

text
Jest: "Coverage for branches (72%) does not meet global threshold (80%)"

Cause: New code was added without tests, or a refactor moved tested code into untested paths.

Fix: Run npm test -- --coverage locally to see which files/branches are uncovered. Add tests for uncovered branches. If the threshold is temporarily unreachable, raise a PR to lower it with justification β€” don't disable coverage checks.

CI Debugging Decision Tree

text
CI pipeline failed β€” where?
β”œβ”€β”€ Lint job failed
β”‚   β”œβ”€β”€ ESLint errors β†’ Fix code style, run `npx eslint . --fix` locally
β”‚   └── Prettier errors β†’ Run `npx prettier --write .` locally
β”‚
β”œβ”€β”€ Test job failed
β”‚   β”œβ”€β”€ Service container unhealthy
β”‚   β”‚   β”œβ”€β”€ Wrong health check β†’ Match user in pg_isready to POSTGRES_USER
β”‚   β”‚   └── Image not found β†’ Check image tag exists (postgres:16, not pg:16)
β”‚   β”œβ”€β”€ Connection refused to service
β”‚   β”‚   β”œβ”€β”€ Port mapping wrong β†’ Use localhost:PORT, not container name
β”‚   β”‚   └── Service still starting β†’ Increase health-retries
β”‚   β”œβ”€β”€ Tests pass locally, fail in CI
β”‚   β”‚   β”œβ”€β”€ Different runtime version β†’ Pin with actions/setup-node
β”‚   β”‚   β”œβ”€β”€ Missing env vars β†’ Add all required vars in env: block
β”‚   β”‚   └── Timezone/locale difference β†’ Set TZ env var if time-sensitive
β”‚   └── Coverage below threshold
β”‚       └── Run coverage locally β†’ Add tests for uncovered branches
β”‚
└── Build job failed
    β”œβ”€β”€ Docker build failed
    β”‚   β”œβ”€β”€ File not found β†’ Check context path and .dockerignore
    β”‚   β”œβ”€β”€ npm ci fails β†’ Ensure package-lock.json is committed
    β”‚   └── Multi-stage COPY fails β†’ Check stage names match
    └── Docker push denied
        β”œβ”€β”€ Not logged in β†’ Add docker/login-action step
        β”œβ”€β”€ Missing permissions β†’ Add packages: write
        └── Org policy β†’ Check repo/org package settings

🎯 Interview Questions

Beginner (B1–B5)

B1: What is the purpose of running tests in a CI pipeline?

CI tests catch regressions automatically on every push. Without CI, bugs slip through when developers forget to run tests locally. CI ensures every change is validated against the full test suite before merging.

B2: What does docker/build-push-action do?

It builds a Docker image using Docker Buildx and optionally pushes it to a container registry. It supports multi-platform builds, build caching, and integrates with docker/login-action for registry authentication.

B3: Why tag Docker images with github.sha?

The commit SHA uniquely identifies which code produced the image. Given any running container, you can trace it back to the exact commit. This is critical for debugging production issues and for audit trails.

B4: What is a linter and why run it in CI?

A linter analyzes code for style violations, potential bugs, and anti-patterns without executing it. Running it in CI enforces consistency across the team β€” no one can merge code that violates the agreed-upon rules, regardless of their local editor setup.

B5: What does code coverage measure?

Code coverage measures the percentage of your codebase exercised by automated tests β€” typically tracked by lines, branches, functions, and statements. It highlights untested areas but doesn't guarantee the tests are meaningful.

Intermediate (I1–I5)

I1: How do service containers work in GitHub Actions?

Service containers are Docker containers started alongside a job. They run on the same Docker network as the runner, accessible via localhost when using port mapping. GitHub waits for health checks to pass before starting job steps. They're defined under the services key in a job.

I2: Explain the needs keyword and how it creates a stage pipeline.

needs defines job dependencies. needs: lint means the job won't start until the lint job succeeds. Chaining needs creates sequential stages: lint β†’ test β†’ build β†’ deploy. Jobs without needs run in parallel by default.

I3: What is the benefit of cache-from: type=gha in Docker builds?

It uses GitHub Actions' built-in cache backend to store and restore Docker build layers. Subsequent builds reuse unchanged layers instead of rebuilding from scratch, dramatically reducing build times β€” often from minutes to seconds for unchanged layers.

I4: How would you enforce that coverage never drops below a threshold?

Two layers: (1) Configure the test framework with coverage thresholds (e.g., Jest's coverageThreshold) so the test command exits with a non-zero code if coverage drops. (2) Use Codecov's fail_ci_if_error and configure target coverage in codecov.yml to add PR status checks.

I5: Why should the lint job run before the test job?

Linting is fast (seconds) compared to tests (minutes). If there are syntax or style errors, there's no point running expensive test suites. Fail-fast saves CI minutes and gives developers quicker feedback. It also prevents noisy test failures caused by syntax issues.

Senior (S1–S5)

S1: You manage a monorepo with 5 services. How would you design the CI pipeline so each service only builds when its code changes?

Use path filtering on each job: paths: ['services/api/**'] on the trigger, or use dorny/paths-filter to create conditional outputs. Each service has its own lint/test/build job chain. Use a matrix strategy if the pipeline structure is identical across services. Cache dependencies per service path.

S2: How do you handle flaky tests in CI without disabling them?

Quarantine known flaky tests in a separate job that's allowed to fail (continue-on-error: true). Use retry plugins (e.g., Jest's --retries, pytest's pytest-rerunfailures). Track flaky test metrics over time. Fix root causes: race conditions, time-dependent logic, or shared mutable state. Never mark them as skipped β€” that hides the problem.

S3: Describe a strategy for reducing Docker build times in CI from 10 minutes to under 2 minutes.

Layer ordering: copy dependency files first, install, then copy source code β€” so dependency layers are cached. Use cache-from: type=gha for layer caching. Multi-stage builds to reduce final image size. Use .dockerignore to exclude unnecessary files. Pin base images to avoid unexpected pulls. For extreme cases, use a pre-built base image with dependencies already installed.

S4: How would you implement test parallelization across multiple runners?

Use a matrix strategy to shard tests: split the test suite into N chunks using test file hashing or a splitting tool (e.g., jest --shard=1/4). Each matrix instance runs a different shard in parallel on separate runners. Aggregate coverage reports using a final job that downloads all coverage artifacts and merges them before uploading to Codecov.

S5: A team complains their CI takes 25 minutes. Walk through your optimization approach.

Audit: identify the slowest jobs (check workflow run timelines). Common wins: (1) Add dependency caching (actions/cache or built-in cache: in setup actions). (2) Parallelize independent jobs. (3) Use paths: filters to skip irrelevant jobs. (4) Shard long test suites across matrix runners. (5) Cache Docker layers. (6) Use larger runners for CPU-bound builds. (7) Replace npm install with npm ci. (8) Move lint to a fast parallel job rather than sequential. Measure before and after each change.

🌍 Real-World Scenario: E-Commerce CI Pipeline

An e-commerce platform has three services: API (Node.js), storefront (React), and payments (Python). Each lives in a monorepo under services/. The team needs a CI pipeline that:

yaml
name: E-Commerce CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      api: ${{ steps.filter.outputs.api }}
      storefront: ${{ steps.filter.outputs.storefront }}
      payments: ${{ steps.filter.outputs.payments }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'services/api/**'
            storefront:
              - 'services/storefront/**'
            payments:
              - 'services/payments/**'

  lint-api:
    needs: changes
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: cd services/api && npm ci && npx eslint .

  lint-payments:
    needs: changes
    if: needs.changes.outputs.payments == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.12' }
      - run: pip install flake8
      - run: cd services/payments && flake8 . --max-line-length=120

  test-api:
    needs: lint-api
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_USER: ci, POSTGRES_PASSWORD: ci, POSTGRES_DB: shop_test }
        ports: ['5432:5432']
        options: >-
          --health-cmd="pg_isready -U ci"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: cd services/api && npm ci
      - run: cd services/api && npm test -- --coverage
        env:
          DATABASE_URL: postgresql://ci:ci@localhost:5432/shop_test
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          flags: api

  build-api:
    needs: test-api
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    permissions: { contents: read, packages: write }
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: services/api
          push: true
          tags: ghcr.io/${{ github.repository }}/api:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The storefront and payments services follow the same pattern. The changes job ensures only affected services run through the pipeline, saving CI minutes on every push.

πŸ“ Summary

← Back to GitHub Actions Course