JavaScript Cypress: E2E Testing Framework
Cypress has fundamentally changed how teams approach end-to-end testing. Unlike Selenium-based tools that operate outside the browser via WebDriver protocols, Cypress runs directly inside the...
Key Insights
- Cypress runs directly in the browser alongside your application, eliminating the network latency and synchronization headaches that plague Selenium-based testing frameworks.
- Automatic waiting and retry mechanisms handle most async operations without explicit waits, but understanding when to use
cy.intercept()for network control is essential for reliable tests. - The biggest ROI comes from investing in custom commands and data-testid selectors early—they transform brittle, unmaintainable tests into a robust regression safety net.
Introduction to Cypress
Cypress has fundamentally changed how teams approach end-to-end testing. Unlike Selenium-based tools that operate outside the browser via WebDriver protocols, Cypress runs directly inside the browser. This architectural decision eliminates an entire class of problems: network latency between test commands, synchronization issues, and the infamous “element not found” errors that plague traditional E2E frameworks.
The framework automatically waits for elements to appear, animations to complete, and network requests to finish. You don’t write sleep(2000) or explicit wait conditions—Cypress handles this intelligently with built-in retry logic.
When should you choose Cypress? It excels for single-origin web applications where you need fast, reliable feedback. If you’re testing across multiple domains, need Safari support, or require mobile device testing, consider Playwright instead. Cypress owns its niche: JavaScript/TypeScript web apps where developer experience matters.
Setting Up Your First Cypress Project
Installation is straightforward. Add Cypress to an existing project or start fresh:
npm init -y
npm install cypress --save-dev
npx cypress open
The first run creates the default folder structure:
cypress/
├── e2e/ # Your test files live here
├── fixtures/ # Static test data (JSON files)
├── support/ # Custom commands and global config
│ ├── commands.js
│ └── e2e.js
└── downloads/ # Files downloaded during tests
cypress.config.js # Main configuration file
Configure Cypress for your application in cypress.config.js:
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
video: false, // Enable in CI for debugging failures
screenshotOnRunFailure: true,
setupNodeEvents(on, config) {
// Register plugins here
return config;
},
},
});
Create your first test file at cypress/e2e/smoke.cy.js:
describe('Application Smoke Test', () => {
it('loads the homepage successfully', () => {
cy.visit('/');
cy.get('h1').should('be.visible');
});
});
Run it with npx cypress open for the interactive runner or npx cypress run for headless execution.
Writing Your First E2E Test
Cypress commands chain together fluently. Each command yields a subject that the next command operates on. Here’s a complete login flow test:
describe('Authentication', () => {
beforeEach(() => {
cy.visit('/login');
});
it('allows a user to log in with valid credentials', () => {
cy.get('[data-testid="email-input"]')
.type('user@example.com');
cy.get('[data-testid="password-input"]')
.type('securePassword123');
cy.get('[data-testid="login-button"]')
.click();
// Assert successful login
cy.url().should('include', '/dashboard');
cy.get('[data-testid="welcome-message"]')
.should('contain', 'Welcome back');
});
it('displays an error for invalid credentials', () => {
cy.get('[data-testid="email-input"]')
.type('wrong@example.com');
cy.get('[data-testid="password-input"]')
.type('wrongPassword');
cy.get('[data-testid="login-button"]')
.click();
cy.get('[data-testid="error-message"]')
.should('be.visible')
.and('contain', 'Invalid credentials');
cy.url().should('include', '/login');
});
});
Notice there are no explicit waits. Cypress automatically retries cy.get() until the element appears (up to defaultCommandTimeout) and retries assertions until they pass or timeout.
Working with Selectors and Best Practices
CSS selectors tied to styling break when designers refactor. Class names like .btn-primary or structural selectors like div > form > input:first-child are maintenance nightmares.
Use data-testid attributes instead:
<!-- In your application code -->
<button data-testid="submit-order" class="btn btn-primary">
Place Order
</button>
// In your test
cy.get('[data-testid="submit-order"]').click();
This creates a contract between your tests and your application that survives CSS refactoring.
For repeated operations, create custom commands in cypress/support/commands.js:
Cypress.Commands.add('login', (email, password) => {
cy.session([email, password], () => {
cy.visit('/login');
cy.get('[data-testid="email-input"]').type(email);
cy.get('[data-testid="password-input"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
});
});
Cypress.Commands.add('getByTestId', (testId) => {
return cy.get(`[data-testid="${testId}"]`);
});
Cypress.Commands.add('shouldBeAccessible', () => {
// Integrate with cypress-axe for accessibility testing
cy.injectAxe();
cy.checkA11y();
});
Now your tests become more readable:
describe('Order Flow', () => {
beforeEach(() => {
cy.login('user@example.com', 'password123');
cy.visit('/products');
});
it('allows adding items to cart', () => {
cy.getByTestId('product-card').first().click();
cy.getByTestId('add-to-cart').click();
cy.getByTestId('cart-count').should('contain', '1');
});
});
The cy.session() wrapper caches the login state, dramatically speeding up test suites by avoiding repeated login flows.
Handling Async Operations and Network Requests
While Cypress handles most waiting automatically, network requests require explicit control for reliable tests. Use cy.intercept() to stub API responses:
describe('Product Listing', () => {
it('displays products from the API', () => {
cy.intercept('GET', '/api/products', {
fixture: 'products.json'
}).as('getProducts');
cy.visit('/products');
cy.wait('@getProducts');
cy.getByTestId('product-card').should('have.length', 3);
});
it('handles loading states', () => {
cy.intercept('GET', '/api/products', {
fixture: 'products.json',
delay: 1000
}).as('getProducts');
cy.visit('/products');
cy.getByTestId('loading-spinner').should('be.visible');
cy.wait('@getProducts');
cy.getByTestId('loading-spinner').should('not.exist');
});
it('displays error state when API fails', () => {
cy.intercept('GET', '/api/products', {
statusCode: 500,
body: { error: 'Internal server error' }
}).as('getProducts');
cy.visit('/products');
cy.wait('@getProducts');
cy.getByTestId('error-message')
.should('be.visible')
.and('contain', 'Failed to load products');
cy.getByTestId('retry-button').should('be.visible');
});
});
Create the fixture file at cypress/fixtures/products.json:
[
{ "id": 1, "name": "Widget", "price": 29.99 },
{ "id": 2, "name": "Gadget", "price": 49.99 },
{ "id": 3, "name": "Doohickey", "price": 19.99 }
]
This approach gives you complete control over test data and lets you verify edge cases that are difficult to reproduce with a real backend.
CI/CD Integration and Reporting
Running Cypress in CI requires headless mode and proper caching. Here’s a GitHub Actions workflow:
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Start application
run: npm run start &
env:
NODE_ENV: test
- name: Wait for app
run: npx wait-on http://localhost:3000
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
wait-on: 'http://localhost:3000'
browser: chrome
record: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
- name: Upload screenshots on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
For larger teams, Cypress Cloud (formerly Dashboard) provides test analytics, parallelization, and flake detection. The record: true flag sends results to the cloud service.
Common Pitfalls and Debugging Tips
Flaky tests usually stem from three causes: timing issues, test isolation failures, or external dependencies.
For timing issues, avoid arbitrary waits. Instead of cy.wait(2000), wait for specific conditions:
// Bad
cy.wait(2000);
cy.get('.modal').click();
// Good
cy.get('.modal').should('be.visible').click();
// Even better for animations
cy.get('.modal')
.should('be.visible')
.and('not.have.class', 'animating')
.click();
For test isolation, ensure each test cleans up after itself:
beforeEach(() => {
cy.clearLocalStorage();
cy.clearCookies();
// Reset database state via API
cy.request('POST', '/api/test/reset');
});
Never test external sites you don’t control. If your app integrates with Stripe, mock the Stripe API—don’t navigate to stripe.com.
Avoid conditional testing. Tests should be deterministic:
// Anti-pattern: conditional logic in tests
cy.get('body').then(($body) => {
if ($body.find('.popup').length) {
cy.get('.popup .close').click();
}
});
// Better: control the state explicitly
cy.intercept('GET', '/api/popups', { body: [] }); // No popups
cy.visit('/');
Use Cypress’s time-travel debugger by clicking on any command in the test runner. You’ll see the exact DOM state at that moment, including before/after snapshots for commands that modify the page.
The investment in proper E2E testing pays compound interest. Start with critical user flows—login, checkout, core features—and expand coverage as your test infrastructure matures. A small suite of reliable tests beats a large suite of flaky ones every time.