Setting up Cypress in a CI/CD pipeline sounds intimidating until you try it. GitHub Actions enables building an E2E testing pipeline at no cost * : nor time, nor financial.
In the next few sections, we will set up a CI/CD that runs E2E tests on pull requests, intercepts API requests, and records video of failed test runs.
Agenda
- Setting up a basic Next.js application
- Creating an endpoint in Next.js
- Adding Cypress and writing a test
- Integrating Cypress into GitHub
- Running Cypress via GitHub Actions
Useful links
1. How do you set up a test application?
For this article, I built a simple Next.js application with a form that submits data asynchronously. Cypress is completely agnostic of the front-end technology you use, so you can point it at any existing website. Our main file Form.js looks like this:
// src/components/Form.js
import React from 'react'
import styles from '../../styles/Home.module.css'
// our request to a Next.js endpoint
const sendForm = () =>
fetch('/api/ping', {
method: 'POST',
})
const Form = () => {
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null)
const [data, setData] = React.useState(false)
const submitHandler = async (e) => {
e.preventDefault()
setData(false)
setLoading(true)
try {
const response = await sendForm()
await response.json()
setData(true)
setLoading(false)
setError(null)
} catch (error) {
setData(false)
setLoading(false)
setError(error)
}
}
return (
<form className={styles.form} onSubmit={submitHandler}>
<label>
First Name
<input type="text" name="first_name" />
</label>
<label>
Last Name
<input type="text" name="last_name" />
</label>
<button type="submit">Submit</button>
{loading && <h2>Loading...</h2>}
{error && <h2>Error :(</h2>}
{data && <h2>Success!</h2>}
</form>
)
}
export default Form
The flow: user fills in the fields, clicks submit, waits for a slow server response, and sees a “Success!” heading.
2. How do you create a real API endpoint for testing?
To stay close to a real-world scenario, we use a native Next.js feature called API Routes to build an actual endpoint:
// pages/api/ping.js
export default (req, res) => {
setTimeout(() => {
res.status(200).json({})
}, 3000);
}
This endpoint returns status 200 after a 3-second delay. That is all we need to start testing.
3. How do you write your first Cypress test?
Install Cypress:
yarn add cypress --dev
# or
npm install cypress --save-dev
The first time you run yarn/npm run cypress open, Cypress generates a folder structure with example tests. Keep those examples around; they cover nearly every scenario you will encounter.
Here is our first test:
// cypress/integration/form.spec.js
describe('The form', () => {
it('shows success message when submitted correctly', () => {
cy.visit("http://localhost:3000");
cy.get('[name="first_name"]').type("Adrian");
cy.get('[name="last_name"]').type("Pilarczyk");
cy.get('[type="submit"').click();
cy.get('form').should('contain', 'Success!')
});
});
Breaking it down:
// cypress/integration/form.spec.js
// the name of the test suite
describe('The form', () => {
// the description of the individual test
it('shows success message when submitted correctly', () => {
// command: tell Cypress to visit this address
cy.visit("http://localhost:3000");
// command: select an element of name === "first_name"
cy.get('[name="first_name"]')
.type("Adrian"); // <- command: type a string into it
// repeat the above
cy.get('[name="last_name"]').type("Pilarczyk");
// command: find an element of a type === "submit" and click it
cy.get('[type="submit"').click();
// command: find a form element
cy.get('form')
.should('contain', 'Success!') // assertion: check whether it has a content saying "Success!"
});
});
The distinction between “commands” and “assertions” (as Cypress documentation describes it) is the core intuition: first select an element, then either run an action on it or check whether it passes certain conditions.
Notice the last line:
(...).should('contain', 'Success!')
No need to specify the content type. Just tell Cypress it will contain the string “Success!”. The API follows KISS principles throughout.
Should you hit real endpoints or mock them?
In this simple test, we made a design decision that Cypress dedicated an entire article to: we sent the request to an actual endpoint. When test results reach the database, you get a whole new layer to maintain:
- Do you clean the database after tests?
- Do you run against production or staging?
- How do you handle flaky network conditions?
On the other hand, this is as real-world as it gets. No mocking means no stale mock data.
Cypress provides intercept to mock network requests when you need speed:
describe('The form', () => {
it('shows success message when submitted correctly', () => {
cy.intercept('POST', '**/api/ping', {
statusCode: 200,
body: {},
})
cy.visit('http://localhost:3000')
cy.get('[name="first_name"]').type('Adrian')
cy.get('[name="last_name"]').type('Pilarczyk')
cy.get('[type="submit"').click()
cy.get('form').should('contain', 'Success!')
})
})
This version runs faster because it skips the 3-second server delay.
4. How do you integrate Cypress with GitHub?
The integration starts with the Cypress Dashboard service. The process is documented in the Cypress docs.
Once authorized, add the projectId to cypress.json that Cypress created on its first run. The result should appear in the GitHub “Integrations” section of your repository settings.
5. How do you run Cypress in GitHub Actions?
GitHub Actions runs our Cypress suite on each pull request. The workflow file:
# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
cypress-run:
name: Cypress run
runs-on: ubuntu-16.04
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Cypress run
uses: cypress-io/github-action@v2
with:
build: npm run build
start: npm run start
wait-on: http://localhost:3000
That is the entire configuration. The cypress-io/github-action handles dependency installation, caching, and test execution. For a broader look at how CI pipelines improve code quality beyond E2E tests, see our guide on CI for Rails applications.
How do you add video recording of failed tests?
Add 4 lines to upload test recordings as artifacts:
# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
cypress-run:
name: Cypress run
runs-on: ubuntu-16.04
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Cypress run
uses: cypress-io/github-action@v2
with:
build: npm run build
start: npm run start
wait-on: http://localhost:3000
- uses: actions/upload-artifact@v1
if: always() # or if: failure() <- more suitable for production
with:
name: cypress-videos
path: cypress/videos
Additional options available through the Cypress GitHub Action plugin:
Combine these with GitHub Actions events and you get a tailored E2E testing workflow.
Practical Implementation: The USEO Approach
At USEO, we adopted this exact Cypress + GitHub Actions pattern on the Triptrade travel platform, where the booking flow involved multiple third-party APIs (payment providers, availability services, currency conversion). Mocking all of them in unit tests was fragile because the API contracts changed frequently. E2E tests with cy.intercept let us define the contract at the HTTP level and catch breaking changes before they hit staging.
One lesson we learned the hard way: do not record video on every run. On Triptrade, our CI minutes tripled before we switched to if: failure() instead of if: always(). Video on failure only gives you the debugging value without the cost.
On the Yousty HR portal, we extended this setup with a matrix strategy to run tests across Chrome, Firefox, and Electron in parallel. The total wall-clock time stayed under 4 minutes for 45 tests because GitHub Actions runs matrix jobs concurrently. The key insight: parallelizing across browsers is nearly free if your tests are already deterministic.