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:
- 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.
- 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.
- 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.
- 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).
- 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.
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
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:
# 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)
- 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
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.
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
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:
- 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
- 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:
// 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:
[](https://codecov.io/gh/YOUR-ORG/YOUR-REPO)
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
# 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:
- 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:
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:
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
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
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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
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.
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.
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.
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
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
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
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
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
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:
- Lints all three services in parallel (ESLint for JS, Flake8 for Python)
- Runs unit tests with a PostgreSQL service container for the API
- Enforces 80% coverage on the API and payments services
- Builds Docker images only for services with changed files
- Pushes images to GHCR tagged with the commit SHA
- Only pushes on
mainβ PRs build but don't push
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
- Lint first, fail fast. Linting catches style and syntax issues in seconds β run it before expensive tests and builds
- Service containers provide databases and caches alongside CI jobs. Always use health checks to avoid race conditions
- Coverage thresholds prevent untested code from merging. Enforce at the framework level and report to Codecov
- Docker builds use
docker/build-push-actionwith Buildx caching. Tag withgithub.shafor traceability - Multi-stage Dockerfiles keep production images small by separating build tools from runtime
- Use
needs:to chain jobs into stages: lint β test β build β push - Only push images on
mainβ PRs should build but not push to the registry - Use path filters in monorepos so only changed services run through CI
cache-from: type=ghaenables GitHub Actions layer caching, drastically reducing Docker build times- Test reporting actions surface failures as PR annotations β no need to dig through logs