Bhavik Bamania

Programming

Tests on Autopilot: Running Cypress in GitHub Actions

Bhavik Bamania

9 min read

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X
Cypress Tests on Autopilot
Tests on Autopilot: Running Cypress in GitHub Actions

Run Cypress in GitHub Actions: headless runs, retries, JUnit reports, saved screenshots and videos, and parallel jobs, step by step for beginners.

We've built a good set of tests over this series. But there's a quiet problem with all of them: they only run when you remember to run them. Forget once, push a broken login form on a Friday evening, and nobody finds out until Monday.

Tests are like smoke alarms. They're only useful if they're switched on all the time. So today we'll hand our tests to a machine that never forgets. Every time code is pushed, GitHub will start a fresh computer, run every Cypress test, and tell us loudly if anything broke. That's CI, short for Continuous Integration.

Catching Up? Here's the Road So Far

This is part 10 of the Cypress Tutorial Series. If you're new, start with these:

  1. A Beginner's Guide to Cypress: End-to-End Testing Made Easy
  2. Understanding Cypress Basics: Core Features and Syntax Explained
  3. Cypress Stubs, Spies, and Clocks: Take Control of Your Tests
  4. Mocking Network Requests in Cypress with cy.intercept()
  5. Why Your Cypress Tests Break: Selectors and Test Design Best Practices
  6. Cypress Custom Commands: Write Less, Test More
  7. Stop Logging In a Hundred Times: Cypress Sessions, Tokens, and Protected Pages
  8. One Piece at a Time: Component Testing with Cypress
  9. Detective Mode: Finding Out Why a Cypress Test Failed

You'll need the demo project pushed to a GitHub repository. If it isn't there yet, create an empty repo on GitHub and push your code to it before we begin.

Rehearse on Your Own Machine

A CI server has no screen and no mouse. It can't click around the Cypress runner. So before we go anywhere near GitHub, let's make sure our tests can run the way a server runs them: headless, straight from the terminal.

With the dev server running in one terminal, run this in another:

npx cypress run

No window opens. Cypress runs every spec in cypress/e2e, prints the results, and ends with a summary table showing passed and failed tests for each file.

A few flags you'll use often:

Flag

What it does

Example

--browser

Picks the browser (Electron by default)

npx cypress run --browser chrome

--spec

Runs only some files

npx cypress run --spec "cypress/e2e/auth.cy.ts"

--component

Runs component tests instead of E2E

npx cypress run --component

The Number That Matters Most

When cypress run finishes, it hands back an exit code. If every test passed, it's 0. If anything failed, it's not 0. You never see it, but CI watches it closely. That single number is how GitHub decides between a green tick and a red cross.

If npx cypress run passes on your machine, you're ready for the cloud.

Your First Workflow

GitHub Actions reads instructions from YAML files in a special folder. Each file is a workflow: a recipe that says when to run and what to do.

Step 1: Create the Workflow File

Create .github/workflows/cypress.yml in the root of your project (note the dot in front of github):

name: Cypress Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22

      - name: Run Cypress E2E tests
        uses: cypress-io/github-action@v7
        with:
          start: npm run dev
          wait-on: 'http://localhost:5173'
          browser: chrome

Read it top to bottom like a story:

  • on says when to run: on every push to main, and on every pull request.
  • runs-on: ubuntu-latest asks GitHub for a fresh Linux computer. It's brand new every time, with nothing left over from the last run.
  • actions/checkout copies your code onto that computer.
  • actions/setup-node installs Node.js 22, which our Vite project needs.
  • cypress-io/github-action is the official Cypress action, and it does a lot in one step. It installs your packages, caches them for next time, starts your app with npm run dev, waits until http://localhost:5173 responds, and then runs cypress run in Chrome.

Step 2: Push and Watch

Commit the file and push it:

git add .github/workflows/cypress.yml
git commit -m "Run Cypress tests in GitHub Actions"
git push

Open your repository on GitHub and click the Actions tab. You'll see your workflow running. Click it to follow along. When it finishes, scroll down the run page. The Cypress action adds a summary table of passed and failed tests, right there on the page.

Break a test on purpose, push again, and watch it turn red. Then fix it. That red cross is your smoke alarm working.

Tip: Our cy.login() reads the password from cy.env() (part 6). For a real test account, store the password under Settings → Secrets and variables → Actions in your repo, then pass it in with env: CYPRESS_password: ${{ secrets.TEST_PASSWORD }} on the Cypress step.

A Safety Net and a Paper Trail

Retries: Already Done

Remember the retries setting from part 5? We set runMode: 2, which means "in headless runs, try a failed test up to two more times". CI uses headless runs, so this is already working. If a test fails once and then passes, the summary marks it as flaky instead of failed.

That's a useful signal, not a reason to relax. A flaky test is still a bug waiting for a bad day. Fix it when you see it.

Reports: A Record Machines Can Read

The summary table is perfect for people. But many tools, like test dashboards and pull request checks, want results in a standard file format called JUnit XML. Cypress can write it with no extra packages. Add this to cypress.config.ts:

export default defineConfig({
  reporter: 'junit',
  reporterOptions: {
    mochaFile: 'cypress/results/results-[hash].xml',
  },
  // ...the rest of your config stays the same
});

The [hash] part gives each spec its own file, so results from different specs never overwrite each other.

Keep the Evidence

That fresh CI computer is thrown away the moment the run ends, along with any screenshots and videos from part 9. To keep them, upload them as artifacts. Add this step after the Cypress step:

      - name: Upload test evidence
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: cypress-evidence
          path: |
            cypress/results
            cypress/screenshots
            cypress/videos
          if-no-files-found: ignore
  • if: always() runs this step even when tests fail. That's exactly when you need the evidence most.
  • if-no-files-found: ignore stops the step complaining on a clean run with no screenshots.

After the next run, open it on GitHub and scroll to Artifacts. Download cypress-evidence, and you'll have the reports, screenshots, and videos on your own machine.

More Hands, Shorter Wait

One computer runs specs one after another. With seven specs that's fine. With seventy, you're waiting a long time for every pull request. The fix is simple: split the work across several computers that run at the same time. There are two ways to do it.

Option 1: Split It Yourself (Free)

GitHub's matrix feature starts one job for every value in a list. We give each job its own group of specs:

  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        specs:
          - 'cypress/e2e/login.cy.ts,cypress/e2e/stubs-spies-clocks.cy.ts,cypress/e2e/network-requests.cy.ts'
          - 'cypress/e2e/selectors.cy.ts,cypress/e2e/custom-commands.cy.ts,cypress/e2e/auth.cy.ts,cypress/e2e/debugging.cy.ts'
    steps:
      # ...checkout and setup-node as before
      - name: Run Cypress E2E tests
        uses: cypress-io/github-action@v7
        with:
          start: npm run dev
          wait-on: 'http://localhost:5173'
          browser: chrome
          spec: ${{ matrix.specs }}
  • matrix.specs has two entries, so GitHub starts two jobs side by side.
  • spec: ${{ matrix.specs }} hands each job its own group.
  • fail-fast: false lets every group finish even if one fails, so you see all the problems at once.

It's free and easy. The catch is that you decide the groups. As specs are added, you'll need to rebalance them by hand.

Option 2: Let Cypress Cloud Balance It

Cypress Cloud is Cypress's paid online service, with a limited free plan. It records your runs and, with parallel mode on, hands out specs to whichever machine is free next, so the groups balance themselves. After you connect your project in Cypress Cloud, it gives you a projectId for your config and a record key to store as a GitHub secret. The workflow then looks like this:

    strategy:
      fail-fast: false
      matrix:
        containers: [1, 2, 3]
    steps:
      # ...checkout and setup-node as before
      - name: Run Cypress E2E tests
        uses: cypress-io/github-action@v7
        with:
          start: npm run dev
          wait-on: 'http://localhost:5173'
          record: true
          parallel: true
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

Here containers: [1, 2, 3] simply means "start three machines". Cypress Cloud decides what each one runs.

Tip: Start with Option 1. Move to Cypress Cloud when splitting by hand starts to feel like a chore.

The Finished Workflow

Here's the complete .github/workflows/cypress.yml. It runs the E2E tests in two parallel groups, runs the component tests from part 8 in their own job, and keeps the evidence from every run:

name: Cypress Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        specs:
          - 'cypress/e2e/login.cy.ts,cypress/e2e/stubs-spies-clocks.cy.ts,cypress/e2e/network-requests.cy.ts'
          - 'cypress/e2e/selectors.cy.ts,cypress/e2e/custom-commands.cy.ts,cypress/e2e/auth.cy.ts,cypress/e2e/debugging.cy.ts'
    steps:
      - name: Check out the code
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22

      - name: Run Cypress E2E tests
        uses: cypress-io/github-action@v7
        with:
          start: npm run dev
          wait-on: 'http://localhost:5173'
          browser: chrome
          spec: ${{ matrix.specs }}

      - name: Upload test evidence
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: e2e-evidence-${{ strategy.job-index }}
          path: |
            cypress/results
            cypress/screenshots
            cypress/videos
          if-no-files-found: ignore

  component:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22

      - name: Run Cypress component tests
        uses: cypress-io/github-action@v7
        with:
          component: true

Notice the component job has no start or wait-on. Component tests don't need the app running, which is one more reason they're fast (part 8).

Where First Pipelines Usually Stumble

  • Two uploads with the same artifact name. Parallel jobs can't share one name, so the upload fails. That's why we add ${{ strategy.job-index }} to make each name unique.
  • A wrong wait-on address. If the port doesn't match your dev server, the action waits, times out, and fails before a single test runs. Match it to the URL Vite prints.
  • Missing the old terminal output. The JUnit reporter replaces Cypress's usual per-test lines in the log. The run summary and the job page table still show everything you need.
  • Old version numbers in copied workflows. Actions get new major versions over time. When you copy a workflow from an older blog post, check each uses: line against the action's GitHub page.

The Alarm Is On

Our tests no longer depend on anyone's memory. We rehearsed a headless run on our own machine, handed it to GitHub Actions with the official Cypress action, let retries catch flaky moments, saved JUnit reports and screenshots as artifacts, and split the work across parallel jobs. Push a bug now, and the smoke alarm goes off before anyone else even notices.

We've covered a lot of ground across ten parts. In the final chapter, we'll bring it all together on one small but real app with login, protected pages, mocked APIs, custom commands, and a CI pipeline, built and tested from scratch. Stay tuned, and happy testing!

CypressGitHub ActionsCI/CDEnd-to-End TestingCypress TutorialTest Automation

Share On:

Copy linkShare on FacebookShare on LinkedInShare on ThreadsShare on X

Written by Bhavik Bamania

Bhavik Bamania is a software engineer and writer exploring frontend engineering, web architecture, technology, history, psychology, astrology, and culture.

This site uses cookies for basic analytics (page views, no ads). You can accept or decline.