Introduction
In this blog, I will walk you through setting up a bulletproof testing setup using React + Cypress. We'll cover everything from the basics to testing those tricky async API calls that always seem to break at the worst possible moment.
Getting Started
Firstly, we are going to create a React app using Vite. As CRA is deprecated, Vite is the standard way for creating a React project. Open your terminal and run the following commands:
npm create vite@latest my-react-app -- --template react-ts cd my-react-app npm install
To start the project on localhost:
npm run dev
Head over to http://localhost:5173
and you should see your React app.
Cypress Setup
Here's where things get interesting. Cypress isn't just another testing framework—it's like having a tester that clicks through your app exactly like a real user would.
Let's get Cypress installed:
npm install --save-dev cypress
Now run the following command:
npx cypress open
This will create a cypress/
folder and some initial files. Don't worry about all the folders that appear. It's all for the setup.
Cypress Configuration
Time for a little configuration. Create a cypress.config.ts
file in your project root:
import { defineConfig } from 'cypress' export default defineConfig({ e2e: { baseUrl: 'http://localhost:5173', supportFile: 'cypress/support/e2e.ts', specPattern: 'cypress/e2e/**/*.cy.{js,ts,jsx,tsx}', }, })
Here's a pro tip that'll save you some headaches: install start-server-and-test
. This command will start your dev server and run tests automatically:
npm install --save-dev start-server-and-test
Update your package.json
scripts:
"scripts": { "dev": "vite", "test:e2e": "start-server-and-test dev http://localhost:5173 'cypress open'" }
Now you can run npm run test:e2e
and everything just works.
First Test
Let's write a test that actually does something. Create cypress/e2e/homepage.cy.ts
:
describe('Homepage', () => { it('should load the homepage and display welcome text', () => { cy.visit('/') cy.contains('Vite + React').should('be.visible') }) })
Run your tests with:
npm run test:e2e
If you see that test pass, congratulations! You just wrote your first E2E test. It might seem simple, but you've just verified that your app actually loads and displays content.
Testing Real User Behaviour
Static content is nice, but let's test some actual interactions. Remember that counter button in the default Vite template? Let's make sure it actually counts:
describe('Counter', () => { it('should increment the count when clicked', () => { cy.visit('/') cy.get('button').contains('count is').click() cy.get('button').should('contain.text', 'count is 1') }) })
This test clicks the button and verifies that the count goes up.
Testing Async Code and APIs
Most real-world apps don't just display static content—they fetch data from APIs, and that's where bugs are.
Let's say you have a component that fetches users when it mounts:
// ExampleComponent.tsx import { useEffect, useState } from 'react' export function ExampleComponent() { const [users, setUsers] = useState<string[]>([]) const [loading, setLoading] = useState(true) useEffect(() => { fetch('/api/users') .then(res => res.json()) .then(data => { setUsers(data) setLoading(false) }) .catch(() => setLoading(false)) }, []) if (loading) return <div>Loading...</div> return ( <ul data-cy="user-list"> {users.map(user => ( <li key={user} data-cy="user-item">{user}</li> ))} </ul> ) }
Now here's the interesting part—Cypress can intercept and mock API calls:
describe('User List', () => { it('should display users from the API', () => { // Mock the API response cy.intercept('GET', '/api/users', { statusCode: 200, body: ['John', 'Jane', 'Robert'] }).as('getUsers') cy.visit('/') // Wait for the API call to complete cy.wait('@getUsers') // Verify the users are displayed cy.get('[data-cy="user-list"]').should('be.visible') cy.get('[data-cy="user-item"]').should('have.length', 3) cy.contains('Alice').should('be.visible') }) it('should handle API failures gracefully', () => { cy.intercept('GET', '/api/users', { statusCode: 500, body: { error: 'Server error' } }).as('getUsersError') cy.visit('/') cy.wait('@getUsersError') // Test your error handling cy.contains('Something went wrong').should('be.visible') }) })
The cy.intercept()
method is amazing. You can test both success and failure scenarios without needing a real API.
Best Practices That Actually Matter
Use data-cy attributes religiously.. CSS classes change, text content gets translated, but data-cy="submit-button"
stays constant:
// Good cy.get('[data-cy="submit-button"]').click() // Fragile cy.get('.btn-primary').click() cy.contains('Submit').click()
Reset your state before each test. Use beforeEach()
to clear out any leftover state:
beforeEach(() => { cy.clearLocalStorage() cy.clearCookies() // Reset any global state your app might have })
Think like a user, not a developer. Test the journey, not the implementation. Don't test that a function was called—test that the user sees what they expect.
Mock everything you don't control. Real APIs are slow, unreliable, and might not have the data you need for testing. Mock them for testing.
Conclusion
Testing feels daunting, we all know, but here's the thing: every minute you spend writing tests now saves you hours of debugging later. And with Cypress, those tests actually run in a real browser, so you can trust that they're testing what your users will experience. If you like this blog and want to learn more about Frontend Development and Software Engineering, you can follow me on Dev.to.
Top comments (0)