As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let's talk about testing. Not the small, isolated unit tests, but the big picture. The kind of test where you click a button as a user would and see if the whole machine actually works from start to finish. This is what we call end-to-end testing, and for modern web applications, Cypress has become my go-to tool. It feels less like writing tests and more like teaching a very observant, fast-paced colleague how to use your app and tell you when it breaks.
I want to share with you eight practical ways I use Cypress to build tests that are reliable, readable, and actually helpful. We'll move from the foundational steps to some more advanced strategies that have saved me countless hours.
First, you have to think about structure. A messy test suite is a nightmare to maintain. I organize my tests like I'm documenting user stories. I use describe blocks to frame a feature or user journey, and inside, it blocks for each specific scenario. The golden rule here is isolation. Each test should start from a clean slate, as if it's the first and only test running. I use beforeEach and afterEach hooks religiously to set up and tear down the state.
Here's a basic skeleton I might use for a login flow:
describe('The Login Page', () => {
beforeEach(() => {
// Start fresh every time
cy.clearCookies()
cy.clearLocalStorage()
// Visit the login page before each test
cy.visit('/login')
})
it('lets a user with correct details access the dashboard', () => {
cy.get('#email').type('real.user@company.com')
cy.get('#password').type('MySecurePass123')
cy.get('button[type="submit"]').click()
// After clicking login, we should be redirected
cy.url().should('include', '/dashboard')
// And see a welcome message
cy.get('h1.page-title').should('contain.text', 'Dashboard')
})
it('shows a clear error if the password is wrong', () => {
cy.get('#email').type('real.user@company.com')
cy.get('#password').type('WrongPassword')
cy.get('button[type="submit"]').click()
// An alert should pop up
cy.get('.alert-error')
.should('be.visible')
.and('have.text', 'Invalid email or password')
})
})
This structure is simple, but it creates a predictable rhythm. Anyone reading the test can understand the scenario being checked.
The second technique is all about finding things on the page, known as selectors. Relying on CSS classes like .btn-primary is asking for trouble. The front-end team changes those all the time for a redesign, and your tests break even though the feature works. My strategy is to use dedicated data-* attributes, like data-testid or data-cy. They have no styling purpose, so they're stable.
// In your application HTML:
// <button data-testid="login-submit-btn" class="btn btn-primary">Sign In</button>
// In your Cypress test:
cy.get('[data-testid="login-submit-btn"]').click()
This makes your tests resilient to cosmetic changes. For really complex or repeated selectors, I create custom commands. It's like making your own toolkit.
// In cypress/support/commands.js
Cypress.Commands.add('getByTestId', (testId) => {
return cy.get(`[data-testid="${testId}"]`)
})
// Now in any test file, it's cleaner:
cy.getByTestId('login-submit-btn').click()
Third, we must deal with the outside world: network requests. Your app talks to APIs, and you can't always rely on a real backend during testing. Cypress lets you intercept and control these conversations. This is powerful. You can test how your app behaves when the API is slow, returns an error, or sends specific data.
Let's say I'm testing a user profile page.
describe('User Profile', () => {
it('displays the user profile data from the API', () => {
// Intercept the GET request to the profile endpoint
cy.intercept('GET', '/api/user/profile', {
statusCode: 200,
body: {
name: 'Jane Doe',
email: 'jane@example.com',
memberSince: '2020-01-15'
}
}).as('getProfile') // Give this intercept an alias 'getProfile'
cy.visit('/profile')
// Wait for the mock network call to complete
cy.wait('@getProfile')
// Now assert the mocked data is displayed
cy.getByTestId('user-name').should('have.text', 'Jane Doe')
cy.getByTestId('user-email').should('have.text', 'jane@example.com')
})
it('shows a loading state, then an error message on API failure', () => {
// Intercept and force a 500 server error
cy.intercept('GET', '/api/user/profile', {
statusCode: 500,
delay: 1000 // Simulate a slow network for 1 second
}).as('getProfileError')
cy.visit('/profile')
// First, check for a loading spinner
cy.getByTestId('loading-spinner').should('be.visible')
// Wait for the error response
cy.wait('@getProfileError')
// Now the spinner should be gone, replaced by an error
cy.getByTestId('loading-spinner').should('not.exist')
cy.getByTestId('profile-error')
.should('be.visible')
.and('contain', 'Unable to load profile')
})
})
By controlling the network, my tests are fast, predictable, and can simulate edge cases that are hard to reproduce with a real backend.
Fourth, we need to check things properly, which means assertions. Cypress bundles the Chai assertion library, and it's incredibly fluent. You can chain assertions with .and() and check for multiple properties.
cy.get('.success-notification')
.should('be.visible') // Is it on screen?
.and('have.css', 'color', 'rgb(0, 128, 0)') // Is it green?
.and('contain', 'Saved successfully!') // Does text match?
But sometimes you need to check something more complex. You can dive into the element's properties using .then().
cy.get('progress-bar')
.invoke('attr', 'value') // Get the 'value' attribute
.then((valueStr) => {
const value = parseFloat(valueStr) // Convert to a number
expect(value).to.be.greaterThan(50) // Assert it's over 50%
})
The fifth technique is managing test data. This is crucial for consistency. I often use fixture files, which are simple .json files in a cypress/fixtures folder, to hold mock data.
// cypress/fixtures/users.json
[
{
"id": 101,
"name": "Test User",
"email": "test.user@example.org",
"role": "admin"
}
]
In my test, I load this data:
beforeEach(() => {
// Load the fixture data, aliasing it as 'testUser'
cy.fixture('users').then((users) => {
this.testUser = users[0] // Make it available in the test context
})
// Intercept the API to return this fixture data
cy.intercept('GET', '/api/current-user', (req) => {
req.reply(this.testUser)
}).as('getCurrentUser')
})
This keeps my test data separate from my test logic and makes it easy to update.
Sixth is the execution strategy. Running 500 tests sequentially is slow. In a CI/CD pipeline, you want speed. Cypress supports parallel test execution. You can split your test suite across multiple machines. The key is having independent tests (remember our isolation rule!) and a good reporting dashboard. I also configure tests to retry once or twice. Sometimes a test fails just because an element took 1001ms to appear instead of 1000ms. A single retry can fix these "flaky" tests without masking real problems.
// In cypress.config.js
module.exports = defineConfig({
e2e: {
// Retry failed tests up to 2 times
retries: {
runMode: 2, // For `cypress run` (CI)
openMode: 0 // For `cypress open` (GUI)
},
},
})
The seventh technique is one of my favorites: visual testing. It answers the question, "Did anything look wrong?" You can take screenshots or snapshots of your entire page or specific components and compare them to a previously approved "baseline" image. Tools like Applitools or Percy integrate with Cypress. But even Cypress alone can do basic screenshot diffs for the entire viewport.
it('the homepage looks correct', () => {
cy.visit('/')
cy.waitForAllImagesToLoad() // A custom command to ensure stability
// Compare the current screen to a baseline image named 'homepage.png'
cy.matchImageSnapshot('homepage')
})
If a CSS change accidentally moves your sidebar 10 pixels to the left, this test will fail. It catches the kind of bugs that functional tests miss.
Finally, the eighth technique is accessibility testing. This isn't just a "nice-to-have"; it's essential. It ensures people using assistive technologies like screen readers can use your app. I use the cypress-axe plugin. It runs automated checks against the WCAG guidelines right inside my Cypress tests.
// First, install cypress-axe and import it in support/e2e.js
import 'cypress-axe'
// In your test file:
it('has no critical accessibility violations on the dashboard', () => {
cy.visit('/dashboard')
cy.wait('@getDashboardData') // Ensure content is loaded
// Inject the axe-core library
cy.injectAxe()
// Run accessibility checks
cy.checkA11y(
null, // Check the whole page
{
// Specific rules to exclude (maybe a known issue you're fixing)
excludedRules: ['color-contrast'],
},
// A function to handle violation results
(violations) => {
// Create a custom error message
const violationData = violations.map(({ id, impact, description }) => ({
id,
impact,
description,
}))
expect(violationData, 'Accessibility violations').to.be.empty
},
false // Do not fail the test on incomplete runs
)
})
Finding an accessibility issue in a test is far better than a user finding it in production.
Putting it all together, these eight techniques form a robust approach. You start with solid, isolated test structures. You select elements in a stable way. You control the network to create any scenario. You make precise assertions. You manage your data separately. You run tests efficiently and in parallel. You guard against visual regressions, and you build a more accessible product from the start.
This isn't about achieving 100% coverage for its own sake. It's about building a safety net that lets you change code with confidence. When I push a new feature, I know my Cypress tests will tell me within minutes if I broke a login flow, a checkout process, or how the page looks. That confidence is what turns testing from a chore into a cornerstone of reliable development.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)