CI/CD for Testers
The CI/CD vocabulary, GitHub Actions patterns, and parallelisation tricks you'll use when wiring tests into a real pipeline.
CI/CD Concepts
| Term | Meaning |
|---|---|
| CI (Continuous Integration) | Build and test automatically on every push / PR. |
| Continuous Delivery | Every passing build is deployable to production. Deploy is one click away. |
| Continuous Deployment | Every passing build goes to production automatically. No human in the loop. |
| Pipeline | The end-to-end sequence: lint → build → test → deploy. |
| Stage / Job | A logical step in the pipeline. Jobs can run in parallel; stages run sequentially. |
| Trigger | What starts the pipeline — push, PR, schedule (cron), manual dispatch. |
| Artifact | A file produced by a job — test reports, screenshots, build outputs. |
| Environment | A target deployment — dev / staging / production. May have its own secrets and approval gates. |
GitHub Actions Basics
A workflow lives at .github/workflows/<name>.yml and runs as one or more jobs, each made of steps.
Minimal example
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm testTriggers
on:
push:
branches: [main]
paths-ignore: ["docs/**", "*.md"]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * *" # daily at 06:00 UTC
workflow_dispatch: # manual button in the Actions tab
inputs:
environment:
type: choice
options: [staging, production]
default: stagingRunners
runs-on: ubuntu-latest # fastest, cheapest
runs-on: macos-latest # for iOS/Safari/Xcode
runs-on: windows-latest # for Edge/Windows-only tooling
runs-on: self-hosted # your own machine — for hardware needsSetting up languages
# Node
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
# Java
- uses: actions/setup-java@v4
with: { java-version: 21, distribution: temurin, cache: maven }
# Python
- uses: actions/setup-python@v5
with: { python-version: "3.12", cache: pip }Caching
actions/setup-node (and similar) handles dependency caching automatically when you pass cache: npm. For arbitrary caches, use actions/cache:
- uses: actions/cache@v4
with:
path: |
~/.cache/Cypress
node_modules
key: ${{ runner.os }}-deps-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-deps-Environment variables and secrets
jobs:
e2e:
env:
NODE_ENV: test # job-level
steps:
- run: npm test
env:
API_TOKEN: ${{ secrets.API_TOKEN }} # step-level — secrets only injected here
BASE_URL: https://staging.example.comDefine secrets at Settings → Secrets and variables → Actions in the repo. Treat them as write-only — they don't appear in logs.
Running Tests in CI
# Unit tests (Jest / Vitest)
- run: npm test
- run: npx vitest run --coverage
# Cypress headless
- run: npx cypress run
# Playwright
- run: npx playwright install --with-deps # browsers + system deps
- run: npx playwright test
# pytest
- run: python -m pytest tests/ --junitxml=results.xml -v
# JVM (Maven / Gradle)
- run: mvn test
- run: ./gradlew testAlways prefer npm ci over npm install in CI — it's faster, deterministic, and respects the lockfile.
Cypress in GitHub Actions
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
browser: chrome
start: npm start
wait-on: "http://localhost:3000"
wait-on-timeout: 120
spec: cypress/e2e/smoke/**/*.cy.ts
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}The cypress-io/github-action handles install, cache, browser binary, and the run itself. start: boots the dev server, wait-on: blocks until the URL responds.
Cypress Cloud (parallel + dashboard)
strategy:
matrix:
containers: [1, 2, 3, 4]
steps:
- uses: cypress-io/github-action@v6
with:
record: true
parallel: true
group: "smoke"
tag: "${{ github.event_name }}"
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}record: true requires a Cypress Cloud project. parallel: true lets the cloud orchestrator distribute specs across the matrix containers.
Playwright in GitHub Actions
jobs:
test:
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 playwright install --with-deps
- run: npx playwright test --reporter=html
- if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
retention-days: 7Sharding for parallel runs
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}
- if: always()
uses: actions/upload-artifact@v4
with:
name: blob-report-${{ strategy.job-index }}
path: blob-reportThen merge in a separate job:
merge-reports:
if: always()
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- run: npx playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@v4
with:
name: html-report
path: playwright-reportCross-browser matrix
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- run: npx playwright test --project=${{ matrix.browser }}Test Reports & Artifacts
Uploading
- if: always() # run even if previous step failed
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
cypress/screenshots
cypress/videos
coverage
playwright-report
retention-days: 14
if-no-files-found: ignoreif: always() is critical — by default failed steps cancel the rest of the job.
JUnit XML for the test summary
Most runners can emit JUnit XML; GitHub's UI and many third-party actions parse it.
- run: pytest --junitxml=results.xml
- if: always()
uses: dorny/test-reporter@v1
with:
name: pytest results
path: results.xml
reporter: java-junitHTML reports via GitHub Pages
For browseable reports (Allure, Playwright HTML), publish to GitHub Pages on main:
- if: github.ref == 'refs/heads/main'
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./playwright-report
destination_dir: reports/${{ github.run_id }}Comment results on the PR
- uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const passed = ${{ steps.test.outputs.passed }};
const failed = ${{ steps.test.outputs.failed }};
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `✅ ${passed} passed · ❌ ${failed} failed`,
});Parallel Execution
Matrix — many configurations of the same job
strategy:
fail-fast: false # don't cancel other matrix entries on first fail
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
exclude:
- { node-version: 18, os: windows-latest } # skip a combo
jobs:
test:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with: { node-version: ${{ matrix.node-version }} }
- run: npm testSplitting by spec / shard
The two universal options:
- By file — divide spec files across N runners. Works with any framework. Requires a deterministic file list.
- By shard — Playwright's built-in
--shard=i/Nand Cypress Cloud's parallelisation handle this for you.
Merging artifacts from many shards
Each shard uploads its own artifact (e.g. blob-report-1, blob-report-2, …). A final job downloads them all (pattern: blob-report-*, merge-multiple: true) and merges into one report.
Pipeline Stages Pattern
A typical fast → slow flow:
lint → build → unit → integration → e2e → deploy
~30s ~1m ~30s ~2m ~10m+ ~1m
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint
build:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with: { name: build, path: dist }
unit:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
e2e:
needs: [build, unit]
runs-on: ubuntu-latest
strategy:
matrix: { shard: [1/4, 2/4, 3/4, 4/4] }
steps:
- uses: actions/download-artifact@v4
with: { name: build, path: dist }
- run: npx playwright test --shard=${{ matrix.shard }}
deploy:
needs: e2e
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- run: ./scripts/deploy.shneeds: makes a job wait for the previous one. Independent fast jobs (lint, unit) run in parallel; later jobs gate on the ones that actually depend on them.
Environment Management
Per-environment secrets and protection rules
deploy-staging:
environment: staging
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
deploy-prod:
needs: deploy-staging
environment: production # GitHub will require manual approval
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}Configure environments at Settings → Environments: required reviewers, wait timers, deployment branches, and per-environment secrets.
Preview deployments
Vercel, Netlify, Cloudflare Pages, and Render all create a preview URL per PR automatically. Make your tests target it:
- name: Wait for Vercel preview
uses: patrickedqvist/wait-for-vercel-preview@v1.3.1
id: wait
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 600
- run: npx playwright test
env:
BASE_URL: ${{ steps.wait.outputs.url }}Test data / DB seeding
Run a migration / seed step before tests, parameterised per environment:
- run: npm run db:migrate
env: { DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} }
- run: npm run db:seed:test
env: { DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} }
- run: npm testCleanup
- name: Clean up test data
if: always()
run: npm run db:reset:test
env: { DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }} }Notifications & Monitoring
Slack on failure
- name: Notify Slack on failure
if: failure()
uses: 8398a7/action-slack@v3
with:
status: failure
fields: repo,message,commit,author,ref,workflow,job
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Status badges

Branch protection — require tests before merge
Settings → Branches → Branch protection rules → main:
- Require status checks to pass — pick the workflow names that gate merges.
- Require branches to be up to date — re-run CI on the latest base.
- Require linear history (optional).
- Restrict who can push.
Re-run failed jobs only
In the Actions UI for any failed run: Re-run failed jobs — runs only the failed ones, reuses successful artifacts. Saves time on flaky integration tests.
Manual dispatch with inputs
on:
workflow_dispatch:
inputs:
filter:
description: "Test name pattern"
type: string
default: ""
env:
type: choice
options: [staging, production]
default: staging
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- run: |
npx playwright test \
--grep="${{ inputs.filter }}" \
--project=${{ inputs.env }}Lets you re-run the suite against a specific environment with a click — no code change needed.