⚙️ CI Integration and Test Automation
A test suite only earns its keep when it runs automatically, on every change, before anything ships. In this lesson you'll build a staged CI/CD pipeline that runs your Cypress tests in GitHub Actions — with caching, parallelisation, artifacts, reporting, and a plan for flaky tests.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Describe the stages of a CI/CD pipeline and where each kind of test belongs
- Use a staged test strategy (pre-merge, post-merge, pre-deploy, post-deploy) to balance speed and coverage
- Write a GitHub Actions workflow that builds the app and runs Cypress E2E tests
- Speed pipelines up with dependency caching and parallel execution
- Handle secrets, artifacts, reporting, and flaky tests in CI
Estimated Time: 45–60 minutes • Difficulty: Intermediate
Hands-on: Author a multi-job GitHub Actions pipeline that gates deployment on your test results.
In This Lesson
Why Automate Testing?
Continuous Integration (CI) is the practice of merging every developer's work frequently and verifying each change automatically. Continuous Delivery/Deployment (CD) extends that to releasing. Automated tests are the quality gate that makes both safe: they catch regressions before code reaches users, and they do it without anyone remembering to click "run."
💡 A useful analogy: A CI/CD pipeline is a factory assembly line with inspection stations along the way. Unit tests are quick checks at each workstation; E2E tests are the final inspection of the finished product before it's boxed and shipped. Automating them means nothing leaves the factory unchecked.
📖 Key Terms
Workflow / pipeline: the automated sequence of steps triggered by an event like a push or pull request.
Job: a group of steps that runs on one machine (runner); jobs can depend on other jobs.
Quality gate: a check that must pass before the pipeline continues — a failing gate blocks the merge or deploy.
Pipeline Fundamentals
A typical pipeline moves a change from commit to production through a fixed set of stages, with tests interleaved so failures surface as early — and as cheaply — as possible.
- Trigger — a push, pull request, or schedule starts the run.
- Build — install dependencies and compile artifacts.
- Test — run unit, integration, and E2E suites (often in that order).
- Deploy — ship to staging and/or production.
- Verify — smoke-test the live deployment.
The guiding principle is fail fast: run the cheapest, fastest tests first so a broken build is rejected in seconds, not after a twenty-minute E2E run. Popular platforms that run these pipelines include GitHub Actions, GitLab CI/CD, CircleCI, Jenkins, and Azure DevOps — the concepts transfer between them, and this lesson uses GitHub Actions.
A Staged Test Strategy
Running every test on every change is slow and wasteful. Instead, run different tests at different points, widening coverage as a change gets closer to production.
Test selection strategies
- Change-based — run only the tests affected by the files that changed.
- Risk-based — always run tests covering critical, high-impact areas.
- Staged — different suites at different pipeline stages (the diagram above).
💡 Gate merges, not just deploys
Configure your critical suite as a required check on pull requests. A red check should block the merge button — that's what turns a test suite from documentation into a safety net.
Cypress in GitHub Actions
A GitHub Actions workflow is a YAML file in .github/workflows/. Here is a complete, modern workflow that builds the app, starts it, and runs Cypress using the official action (which installs Cypress, caches the binary, and waits for the server to be ready).
# .github/workflows/e2e.yml
name: End-to-End Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Run Cypress
uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm start
wait-on: 'http://localhost:3000'
wait-on-timeout: 120
browser: chrome
- name: Upload artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: cypress-artifacts
path: |
cypress/screenshots
cypress/videos
Two details do a lot of work here. wait-on polls the server URL so tests don't start before the app is up. The if: failure() condition uploads screenshots and videos only when something breaks, giving you a visual record to debug from without bloating every run.
✅ Let the official action do the heavy lifting
The cypress-io/github-action handles installing Cypress, caching its binary, running the build, starting the server, and waiting — replacing a dozen hand-written steps. Reach for manual steps only when you outgrow it.
Caching & Parallelisation
As a suite grows, two techniques keep feedback fast: caching what doesn't change, and splitting what does across machines.
Dependency caching
Re-downloading node_modules and the Cypress binary on every run wastes minutes. The setup-node action's cache: 'npm' handles npm, and you can cache Cypress explicitly too:
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.npm
~/.cache/Cypress
key: ${{ runner.os }}-deps-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-deps-
Parallel execution with a matrix
A build matrix runs the same job across several containers at once. Split your specs among them so a 20-minute suite finishes in ~5:
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# four containers run in parallel
containers: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
start: npm start
wait-on: 'http://localhost:3000'
record: true
parallel: true
env:
# Cypress Cloud balances specs across the containers
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
With record: true and parallel: true, Cypress Cloud distributes specs across the four containers by their historical duration, so each machine finishes at roughly the same time. Without Cypress Cloud you can still split manually — for example with the cypress-split plugin — using the matrix index.
⚠️ Parallel needs isolation
Tests running at the same time must not share mutable data. Give each container its own database/schema or use isolated test accounts, or one job will corrupt another's state and produce phantom failures.
Test Data & Secrets
Reliable pipelines need reproducible data and safely handled credentials.
Provisioning a database in CI
GitHub Actions can spin up a real database as a service container for the job, which you then migrate and seed:
jobs:
e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm run db:migrate && npm run db:seed
env:
DATABASE_URL: postgres://test:test@localhost:5432/testdb
Secrets, never in the repo
Store API keys, test-user credentials, and record keys in the repository's encrypted Secrets, and read them as environment variables. Never commit them.
jobs:
e2e:
runs-on: ubuntu-latest
env:
CYPRESS_TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
CYPRESS_TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
steps:
- uses: actions/checkout@v4
- run: npm run test:e2e
// in a Cypress test, read them via Cypress.env
cy.visit('/login');
cy.get('[data-testid="email"]').type(Cypress.env('TEST_EMAIL'));
cy.get('[data-testid="password"]').type(Cypress.env('TEST_PASSWORD'), { log: false });
cy.get('[data-testid="submit"]').click();
Reporting & Flaky Tests
A failing pipeline is only useful if the team can see why it failed and trust that a red result means a real problem.
Readable reports
Beyond raw logs, generate an HTML report and notify the team on failure. A common setup pairs Cypress with the mochawesome reporter; on failure a step can post to Slack:
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
text: "E2E tests failed on ${{ github.ref_name }} — ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
Taming flaky tests
Flaky tests erode trust in the whole suite. Handle them on two fronts: reduce flakiness at the source, and contain the ones you can't fix yet.
| Tactic | What it does |
|---|---|
CI retries (runMode: 2) | Re-runs a failed test up to N times; absorbs rare infra hiccups |
| Quarantine | Move known-flaky specs to a non-blocking job while you fix them |
| Stub externals | Removes third-party instability from the test path |
| Track flakiness rate | Measures the problem so it can't quietly grow |
// cypress.config.js — retry in CI, never in local open mode
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
retries: { runMode: 2, openMode: 0 },
video: true,
screenshotOnRunFailure: true,
},
});
⚠️ Retries are a bandage, not a cure
A test that only passes on the second try is still broken. Use retries to keep the pipeline moving, but treat a high retry rate as a bug queue to work down — not a setting to turn up.
Beyond the merge: post-deploy checks
After deploying, run a small set of smoke tests against production to confirm the critical paths actually work in the live environment. Pair them with synthetic monitoring so you learn about breakage before your users do.
Hands-on Exercise
🏋️ Build a staged CI/CD pipeline
Objective: Turn the concepts into a working multi-job workflow for a React app with Cypress tests.
Instructions:
- Create
.github/workflows/ci-cd.ymltriggered on pushes and pull requests tomain. - Add a unit-tests job that runs on every trigger and caches npm.
- Add a critical-e2e job that
needs: unit-testsand runs a tagged subset of specs. - Add a full-e2e job that runs only on
main(use anif:condition) with a 4-container parallel matrix. - Add a deploy job that
needs: full-e2e, then a smoke-tests job that runs after deploy. - Upload Cypress screenshots/videos as artifacts
if: failure().
💡 Hint
Gate a job to the main branch with if: github.ref == 'refs/heads/main'. Chain jobs with needs: so a later job only starts when its dependency succeeds — that's what makes deployment wait for green tests.
✅ Example skeleton
name: CI/CD
on:
push: { branches: [main] }
pull_request: { branches: [main] }
jobs:
unit-tests:
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 run test:unit
critical-e2e:
needs: unit-tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
start: npm start
wait-on: 'http://localhost:3000'
spec: cypress/e2e/critical/**/*.cy.js
full-e2e:
needs: critical-e2e
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: { containers: [1, 2, 3, 4] }
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
start: npm start
wait-on: 'http://localhost:3000'
record: true
parallel: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
deploy:
needs: full-e2e
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
smoke-tests:
needs: deploy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
config: baseUrl=https://app.example.com
spec: cypress/e2e/smoke/**/*.cy.js
🎯 Quick Quiz
Question 1: Why do pipelines run unit tests before E2E tests?
Question 2: What is the purpose of a build matrix in a workflow?
Question 3: How should sensitive test credentials be handled in CI?
Summary & Quiz
🎉 Key Takeaways
- CI/CD pipelines run your tests automatically on every change, turning the suite into a real quality gate.
- Order tests to fail fast, and use a staged strategy — critical checks on PRs, full suites before deploy, smoke tests after.
- The Cypress GitHub Action builds, starts, waits, and runs your tests in a few lines of YAML.
- Caching and a parallel matrix keep feedback fast as the suite grows.
- Handle data with service containers, secrets encrypted, and flaky tests with retries, quarantine, and stubbing — while tracking the flakiness rate.
📚 Further Reading
- GitHub Actions — official documentation
- cypress-io/github-action — usage & examples
- Cypress — Parallelization guide
🚀 What's Next?
You've now built the full testing story — principles, a real framework, and an automated pipeline. In the weekend project you'll put it all together: write E2E tests for an application and wire them into a CI pipeline of your own.
🎉 Nice work!
Your tests now run themselves. Time to prove it on a real project.