Comprehensive Guide to E2E Testing with Cypress
Introduction to E2E Testing
End-to-end (E2E) testing is a methodology used to test the entire application flow from start to finish. It tests the integration of all components of an application and simulates real user scenarios to ensure the application works correctly from the user's perspective.
Unlike unit tests that focus on individual functions or components, E2E tests verify that all parts of your application work together as expected. This includes frontend UI, API calls, database interactions, and any other external services your application depends on.
Cypress is a powerful JavaScript-based end-to-end testing framework designed specifically for modern web applications. It runs directly in the browser, giving it access to the entire DOM and application, which makes it ideal for testing web applications thoroughly.
Why Choose Cypress?
Time Travel Debugging
Cypress takes snapshots as your tests run, allowing you to see exactly what happened at each step.
Real-Time Reloads
Cypress automatically reloads when you make changes to your tests, speeding up your development workflow.
Automatic Waiting
Cypress automatically waits for commands and assertions before moving on, eliminating the need for explicit waits.
Network Traffic Control
Cypress can stub network requests, allowing you to control and test how your app behaves with different responses.
Cypress also provides a clean, intuitive syntax that makes tests easy to write and understand. Its comprehensive documentation and active community make it an excellent choice for teams implementing E2E testing.
Setting Up Cypress
Installation
To get started with Cypress, you need to install it as a development dependency in your project:
npm install cypress --save-dev
Or if you are using Yarn:
yarn add cypress --dev
Project Structure
After installing Cypress, you can open it for the first time to generate the default folder structure:
npx cypress open
This command will create a cypress
directory in your project with the following structure:
cypress/
├── e2e/ # End-to-end test files
├── fixtures/ # Test data, mocks, and fixtures
├── support/ # Support files for tests
│ ├── commands.js # Custom Cypress commands
│ └── e2e.js # Imported in each end-to-end test file
└── videos/ # Recorded test videos (when enabled)
Configuration
Cypress creates a cypress.config.js
file in your project root. This file allows you to customize various settings like the base URL, viewport size, timeout values, and more:
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 5000,
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});
Writing Your First Cypress Test
Let us create a simple test to verify that our application's homepage loads correctly and contains the expected elements.
Create a new file in the cypress/e2e
directory called homepage.cy.js
:
// cypress/e2e/homepage.cy.js
describe('Homepage', () => {
beforeEach(() => {
// Visit the homepage before each test
cy.visit('/');
});
it('should display the main heading', () => {
cy.get('h1')
.should('exist')
.and('contain', 'Welcome to Our App');
});
it('should have a navigation menu', () => {
cy.get('nav')
.should('be.visible');
cy.get('nav a')
.should('have.length.at.least', 3);
});
it('should navigate to the about page', () => {
cy.get('nav a')
.contains('About')
.click();
cy.url()
.should('include', '/about');
cy.get('h1')
.should('contain', 'About Us');
});
});
Understanding Test Structure
describe
: Groups related tests togetherbeforeEach
: Runs before each test in the block, used for setupit
: Defines an individual test casecy.visit
: Navigates to a URLcy.get
: Selects elements on the pageshould
: Makes assertions about the selected elements
Running Tests
You can run your tests in two ways:
1. Interactive Mode:
npx cypress open
This opens the Cypress Test Runner, where you can select and run tests interactively.
2. Headless Mode:
npx cypress run
This runs all tests in headless mode, suitable for CI/CD pipelines.
Advanced Cypress Concepts
Custom Commands
Custom commands allow you to extend Cypress with your own functionality. They're perfect for encapsulating repeated tasks like login:
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('input[name=email]').type(email);
cy.get('input[name=password]').type(password);
cy.get('button[type=submit]').click();
// Verify login was successful
cy.url().should('include', '/dashboard');
});
// Usage in a test
cy.login('user@example.com', 'password123');
Fixtures and Test Data
Fixtures let you store and reuse test data across tests:
// cypress/fixtures/users.json
{
"admin": {
"email": "admin@example.com",
"password": "admin123"
},
"customer": {
"email": "customer@example.com",
"password": "customer123"
}
}
// Usage in a test
cy.fixture('users').then((users) => {
cy.login(users.admin.email, users.admin.password);
});
Network Interception
Cypress can intercept and modify network requests, allowing you to test how your app handles different API responses:
it('should display error message when API fails', () => {
// Stub the API response
cy.intercept('GET', '/api/products', {
statusCode: 500,
body: { error: 'Internal Server Error' }
}).as('productsRequest');
cy.visit('/products');
cy.wait('@productsRequest');
// Verify error message is displayed
cy.get('.error-message')
.should('be.visible')
.and('contain', 'Failed to load products');
});
Testing Authentication
Testing authenticated areas often requires bypassing the UI login flow for efficiency:
// Programmatically log in without using the UI
beforeEach(() => {
// Set auth token directly
cy.window().then((window) => {
window.localStorage.setItem('authToken', 'your-auth-token');
});
// Visit protected page
cy.visit('/dashboard');
});
Best Practices for E2E Testing
Test Structure
- Keep tests independent of each other
- Follow the Arrange-Act-Assert pattern
- Group related tests together
Selectors
- Use data attributes for testing (e.g.,
data-testid="submit-button"
) - Avoid selecting elements by CSS classes that might change
- Be specific but resilient in your selectors
Test Coverage
- Focus on critical user flows first
- Do not duplicate what unit tests already cover
- Cover edge cases and error states
Performance
- Be mindful of test execution time
- Use
cy.session()
to preserve login state between tests - Stub network requests when possible
CI/CD Integration
Integrating Cypress tests into your CI/CD pipeline ensures tests are run automatically with every code change, catching issues early.
GitHub Actions Example
// .github/workflows/cypress.yml
name: Cypress Tests
on: [push, pull_request]
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Start application
run: npm start & npx wait-on http://localhost:3000
- name: Run Cypress tests
uses: cypress-io/github-action@v5
with:
browser: chrome
headed: false
- name: Upload artifacts
uses: actions/upload-artifact@v3
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
CircleCI Example
// .circleci/config.yml
version: 2.1
orbs:
cypress: cypress-io/cypress@3
workflows:
build-and-test:
jobs:
- cypress/run:
start-command: 'npm start'
wait-on: 'http://localhost:3000'
store_artifacts: true
Real-World Testing Strategies
Testing Form Submissions
describe('Contact Form', () => {
beforeEach(() => {
cy.visit('/contact');
});
it('should submit successfully with valid data', () => {
// Intercept form submission
cy.intercept('POST', '/api/contact', {
statusCode: 200,
body: { message: 'Form submitted successfully' }
}).as('formSubmit');
// Fill out the form
cy.get('input[name=name]').type('John Doe');
cy.get('input[name=email]').type('john@example.com');
cy.get('textarea[name=message]').type('This is a test message');
cy.get('button[type=submit]').click();
// Wait for submission and verify success
cy.wait('@formSubmit');
cy.get('.success-message')
.should('be.visible')
.and('contain', 'Thank you for your message');
});
it('should show validation errors for invalid data', () => {
// Try to submit without filling required fields
cy.get('button[type=submit]').click();
// Verify error messages
cy.get('.field-error')
.should('have.length.at.least', 2)
.first()
.should('contain', 'This field is required');
});
});
Testing User Workflows
describe('E-commerce Checkout Flow', () => {
beforeEach(() => {
// Setup: Login and add item to cart
cy.login('customer@example.com', 'password');
cy.visit('/products/1');
cy.get('.add-to-cart-button').click();
cy.get('.cart-icon').click();
});
it('should complete checkout process', () => {
// Start checkout
cy.get('.checkout-button').click();
// Fill shipping details
cy.get('input[name=address]').type('123 Test Street');
cy.get('input[name=city]').type('Test City');
cy.get('input[name=zipCode]').type('12345');
cy.get('.continue-button').click();
// Select payment method
cy.get('input[value=credit-card]').check();
cy.get('.continue-button').click();
// Review order and submit
cy.get('.order-summary').should('be.visible');
cy.get('.place-order-button').click();
// Verify order confirmation
cy.url().should('include', '/order-confirmation');
cy.get('.order-number').should('be.visible');
cy.get('.thank-you-message').should('contain', 'Thank you for your order');
});
});
Troubleshooting Common Issues
Elements Not Found
Problem: Cypress can not find elements that should exist on the page.
Solutions:
- Check if your selectors are correct
- Use
cy.contains()
to find text on the page - Increase command timeout in configuration
- Check if elements are inside iframes (requires special handling)
Flaky Tests
Problem: Tests pass sometimes and fail other times with the same code.
Solutions:
- Use explicit waits with
cy.wait()
for async operations - Add retry logic with
cy.should()
- Use more specific selectors
- Check for race conditions in your app
CORS Errors
Problem: Cross-Origin Resource Sharing (CORS) issues when testing.
Solutions:
- Configure your server to allow Cypress domain in CORS headers
- Use
cy.intercept()
to stub responses - Run Cypress with
--disable-web-security
flag (not recommended for production)
Conclusion
End-to-end testing with Cypress offers a powerful way to ensure your application works correctly from the user's perspective. By simulating real user interactions and verifying expected behaviors, you can catch issues before they reach production.
While E2E tests may run slower than unit or integration tests, they provide a higher level of confidence in your application's functionality. A balanced testing strategy often includes all three types: many unit tests, some integration tests, and a few critical E2E tests.
As you continue your E2E testing journey, remember that the goal is not just to write tests, but to improve your application's quality and reliability for your users.