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:
- A Beginner's Guide to Cypress: End-to-End Testing Made Easy
- Understanding Cypress Basics: Core Features and Syntax Explained
- Cypress Stubs, Spies, and Clocks: Take Control of Your Tests
- Mocking Network Requests in Cypress with cy.intercept()
- Why Your Cypress Tests Break: Selectors and Test Design Best Practices
- Cypress Custom Commands: Write Less, Test More
- Stop Logging In a Hundred Times: Cypress Sessions, Tokens, and Protected Pages
- One Piece at a Time: Component Testing with Cypress
- 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 runNo 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 |
|---|---|---|
| Picks the browser (Electron by default) |
|
| Runs only some files |
|
| Runs component tests instead of E2E |
|
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: chromeRead it top to bottom like a story:
onsays when to run: on every push tomain, and on every pull request.runs-on: ubuntu-latestasks GitHub for a fresh Linux computer. It's brand new every time, with nothing left over from the last run.actions/checkoutcopies your code onto that computer.actions/setup-nodeinstalls Node.js 22, which our Vite project needs.cypress-io/github-actionis the official Cypress action, and it does a lot in one step. It installs your packages, caches them for next time, starts your app withnpm run dev, waits untilhttp://localhost:5173responds, and then runscypress runin 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 pushOpen 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: ignoreif: always()runs this step even when tests fail. That's exactly when you need the evidence most.if-no-files-found: ignorestops 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.specshas two entries, so GitHub starts two jobs side by side.spec: ${{ matrix.specs }}hands each job its own group.fail-fast: falselets 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: trueNotice 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-onaddress. 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!
