End-to-End Testing with Cypress
Cypress takes a fundamentally different approach from traditional E2E testing tools. Instead of controlling the browser from outside through a protocol like WebDriver, Cypress runs directly inside the browser alongside your application. This architecture gives Cypress native access to the DOM, the network layer, and the application's JavaScript runtime, enabling features that external tools cannot easily replicate. The trade-off is that Cypress is limited to testing within a single browser tab and supports fewer browser engines than tools like Playwright.
Despite the rise of Playwright, Cypress maintains a strong following among front-end developers who value its interactive development experience, extensive plugin ecosystem, and well-documented API. Teams that primarily target Chrome-based browsers and prioritize developer experience during test authoring often find Cypress to be an excellent fit.
Step 1: Install Cypress
Add Cypress to your project as a development dependency by running npm install -D cypress. Once installed, launch the Cypress app with npx cypress open. On the first run, Cypress presents a setup wizard that lets you choose between E2E testing and component testing. Select E2E testing, and Cypress creates a default configuration file and example spec directory.
The setup wizard also asks you to choose a browser. Cypress detects browsers installed on your system, typically Chrome, Edge, and Firefox. Select your preferred browser, and Cypress opens the test runner window where you can see all available spec files and run them individually or as a suite.
Cypress creates a folder structure under cypress/ including e2e/ for test files, fixtures/ for test data, and support/ for custom commands and global configuration. The cypress.config.js file at the project root controls all settings.
Step 2: Configure Cypress
The cypress.config.js file uses a defineConfig function that accepts an object with settings for E2E testing, component testing, or both. For E2E testing, the most important settings are baseUrl (your application's URL, allowing tests to use relative paths with cy.visit('/')), viewportWidth and viewportHeight (the browser window dimensions), and defaultCommandTimeout (how long Cypress waits for commands to complete before failing).
The specPattern setting controls where Cypress looks for test files. The default is cypress/e2e/**/*.cy.{js,jsx,ts,tsx}, which matches any file with a .cy extension in the e2e directory. You can change this to match your project's conventions.
For environment-specific configuration, Cypress supports environment variables through the env object in the config file, command-line flags (--env key=value), or a cypress.env.json file. This is useful for storing test user credentials, API keys, or environment-specific URLs without hardcoding them in test files.
Step 3: Write Your First Test
Cypress tests use a chainable command API that reads like a sequence of user actions. Each command is queued and executed asynchronously, but you write them as synchronous chains. A test starts with cy.visit('/') to navigate to a page, uses cy.get() to select elements, and chains actions like .click(), .type(), or .select() onto the selected elements.
For example, a login test would look like: cy.visit('/login'), then cy.get('[data-testid="email"]').type('user@example.com'), followed by cy.get('[data-testid="password"]').type('password123'), then cy.get('button[type="submit"]').click(), and finally cy.url().should('include', '/dashboard') to verify the redirect.
Cypress automatically retries assertions until they pass or the timeout expires. The .should() command is the primary assertion method, supporting matchers like 'be.visible', 'have.text', 'contain', 'have.length', and 'exist'. This built-in retry logic handles most timing issues without explicit wait commands.
Tests are organized using describe blocks for groups and it blocks for individual cases, following the Mocha test syntax. The beforeEach hook is commonly used to navigate to a page or set up state before each test in a group.
Step 4: Use the Interactive Test Runner
The Cypress interactive test runner is the framework's signature feature. Running npx cypress open launches a browser window where you can watch each test execute step by step. The command log on the left side shows every Cypress command as it runs, and clicking on any command snapshots the DOM at that point, letting you inspect the page state at each step of the test.
This time-travel capability makes debugging dramatically easier compared to frameworks that only show a final pass or fail result. You can see exactly what the page looked like when a selector failed to find an element, what network requests were in flight during a timeout, and how the DOM changed between test steps.
The test runner also supports hot reloading: when you save changes to a test file, Cypress automatically re-runs the affected tests. This tight feedback loop lets you iterate on tests as quickly as you iterate on application code, which is one of the main reasons developers prefer Cypress for authoring new tests.
Step 5: Intercept Network Requests
Cypress's cy.intercept() command lets you control the network layer during tests. You can intercept HTTP requests to mock API responses, simulate server errors, delay responses to test loading states, and verify that the application sends the correct request data.
A common pattern is stubbing API responses with fixture data. Call cy.intercept('GET', '/api/products', { fixture: 'products.json' }) before the page loads, and Cypress serves the fixture file instead of hitting the real API. This makes tests faster, deterministic, and independent of backend availability.
You can also use cy.intercept to wait for specific requests to complete before making assertions. The cy.wait('@alias') command pauses the test until the intercepted request finishes, which is useful when a page makes multiple API calls and you need to assert after a specific one resolves. This approach is more reliable than arbitrary time-based waits because it responds to actual network activity.
For testing error handling, intercept the request and return an error status: cy.intercept('POST', '/api/checkout', { statusCode: 500, body: { error: 'Server error' } }). This lets you verify that your application displays the correct error message without needing to trigger a real server failure.
Step 6: Run Tests in CI
For CI environments, use npx cypress run instead of cypress open. The run command executes all tests in headless mode and outputs results to the terminal. Cypress automatically captures screenshots on test failure and records video of the entire test run, both of which should be stored as CI pipeline artifacts for debugging.
Cypress officially supports running in Docker using the cypress/included image, which bundles the framework with all browser dependencies. For GitHub Actions, the cypress-io/github-action handles installation, caching, and execution in a single step.
Parallelization in Cypress requires the Cypress Cloud service (formerly Cypress Dashboard), which distributes specs across multiple CI machines using the --record --parallel flags. Unlike Playwright, which includes free parallelization, Cypress charges for this feature through its cloud platform. For teams that need parallel execution without the cloud service, manual spec splitting based on file count is an alternative, though less optimized.
To ensure reliable CI runs, disable video recording for passing tests to save storage, increase the defaultCommandTimeout slightly to account for CI environment variability, and use retries: { runMode: 2 } to retry flaky tests automatically in CI without retrying in development mode.
Cypress provides an exceptional developer experience for front-end E2E testing with its interactive test runner, time-travel debugging, and intuitive command API. It is best suited for teams that primarily target Chrome-based browsers and value rapid test development over cross-browser coverage.