Skip to main content

⚙️ 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.

flowchart LR A[Code commit] --> B[Build] B --> C[Unit tests] C --> D[Integration tests] D --> E[E2E tests] E --> F[Deploy] F --> G[Post-deploy smoke]
  • 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.

Which tests run at which stage Four columns: on a pull request run unit and critical E2E tests; after merge run full integration and E2E; before deploy run the full E2E suite; after deploy run smoke tests. Pull request After merge Before deploy After deploy Unit tests Critical E2E All integration Core E2E Full E2E suite Performance Smoke tests Monitoring seconds minutes
Figure 1 — Fast, focused checks on every pull request; broader and slower suites as the change nears 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();
flowchart LR A[Empty database] -->|migrate| B[Schema ready] B -->|seed| C[Known test data] C -->|run tests| D[Execution] D -->|reset| A

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.

TacticWhat it does
CI retries (runMode: 2)Re-runs a failed test up to N times; absorbs rare infra hiccups
QuarantineMove known-flaky specs to a non-blocking job while you fix them
Stub externalsRemoves third-party instability from the test path
Track flakiness rateMeasures 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:

  1. Create .github/workflows/ci-cd.yml triggered on pushes and pull requests to main.
  2. Add a unit-tests job that runs on every trigger and caches npm.
  3. Add a critical-e2e job that needs: unit-tests and runs a tagged subset of specs.
  4. Add a full-e2e job that runs only on main (use an if: condition) with a 4-container parallel matrix.
  5. Add a deploy job that needs: full-e2e, then a smoke-tests job that runs after deploy.
  6. 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

🚀 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.