Cypress doesn’t run in the browser like Selenium; it runs next to the browser, giving it a fundamentally different and more powerful relationship with the application under test.
Let’s see what that looks like. Imagine you’re testing a simple login form.
HTML:
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
</head>
<body>
<div id="app">
<input id="username" type="text" placeholder="Username">
<input id="password" type="password" placeholder="Password">
<button id="login-button">Login</button>
<div id="error-message" style="color: red;"></div>
</div>
<script>
document.getElementById('login-button').addEventListener('click', () => {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (username === 'testuser' && password === 'password123') {
document.getElementById('error-message').textContent = 'Login successful!';
document.getElementById('error-message').style.color = 'green';
} else {
document.getElementById('error-message').textContent = 'Invalid credentials.';
document.getElementById('error-message').style.color = 'red';
}
});
</script>
</body>
</html>
Cypress Test (cypress/integration/login.spec.js):
describe('Login Form', () => {
it('allows a user to log in with valid credentials', () => {
cy.visit('http://localhost:8080/login.html'); // Assuming you serve this HTML locally
cy.get('#username').type('testuser');
cy.get('#password').type('password123');
cy.get('#login-button').click();
cy.get('#error-message').should('contain', 'Login successful!');
cy.get('#error-message').should('have.css', 'color', 'rgb(0, 128, 0)'); // Green
});
it('shows an error message with invalid credentials', () => {
cy.visit('http://localhost:8080/login.html');
cy.get('#username').type('wronguser');
cy.get('#password').type('wrongpass');
cy.get('#login-button').click();
cy.get('#error-message').should('contain', 'Invalid credentials.');
cy.get('#error-message').should('have.css', 'color', 'rgb(255, 0, 0)'); // Red
});
});
When you run this with npx cypress open, Cypress spins up its own test runner. It launches a browser (like Chrome or Firefox) and injects its commands into that browser’s execution context. This means Cypress is the JavaScript running in your application’s page, alongside your app’s code. It can directly call functions, access variables, and manipulate the DOM without needing to serialize and deserialize data or rely on complex network protocols.
Selenium, on the other hand, operates as an external process. It sends commands over HTTP to a separate WebDriver executable (like chromedriver or geckodriver), which then translates those commands into browser actions. This indirection introduces latency and makes it harder to debug and synchronize.
The problem Cypress solves is the inherent flakiness and debugging complexity that arises from the asynchronous, out-of-process nature of traditional browser automation. It’s built to address the realities of modern, highly dynamic web applications.
Here’s how it works internally:
- Test Runner: This is the Node.js process where your Cypress tests are written and executed. It orchestrates the entire testing process.
- Command Queue: When you write
cy.get(...)orcy.click(), these aren’t executed immediately. They are added to a queue of commands. - App Interface: Cypress injects a script into your application’s page. This script communicates with the test runner and receives commands.
- Browser Execution: The injected script executes the commands directly within the browser’s JavaScript environment. This allows for direct DOM manipulation, function calls, and event handling.
- Automatic Synchronization: Cypress automatically waits for DOM elements to appear, for network requests to settle, and for animations to complete. This eliminates the need for manual
sleep()calls or complexWebDriverWaitlogic that plagues Selenium. If an element isn’t ready, Cypress retries the command automatically. - Time Travel Debugging: Because Cypress controls the entire lifecycle of the command execution within the browser, it can record snapshots of the DOM and application state at each step. This allows you to visually step back and forth through your test execution, inspecting the DOM and network requests at any point.
The core benefit is that Cypress commands are executed asynchronously within the browser. When cy.get('#username').type('testuser') is called, Cypress doesn’t just tell the browser to type. It tells the browser’s own JavaScript engine to find the element and then type into it. The test runner waits for that operation to complete within the browser before moving to the next command. This tight coupling means Cypress knows exactly when an action is finished and the DOM is in a stable state, leading to significantly more reliable tests.
When you’re debugging a test in Cypress, you’re not just looking at logs; you’re seeing the browser update in real-time, and you can use your browser’s developer tools to inspect the application as if you were manually interacting with it. The test runner itself is running in Node.js, but its commands are executed within the browser’s context, giving it a unique "inside-out" view that Selenium, running "outside-in," can’t replicate.
The most surprising thing most people don’t realize is that Cypress doesn’t use the WebDriver protocol at all. Selenium’s entire architecture is built around the W3C WebDriver standard, which is essentially a set of HTTP API calls. Cypress bypasses this entirely, communicating directly with the browser via WebSockets and in-browser JavaScript. This is why Cypress tests are often orders of magnitude faster and more stable than Selenium tests, especially for modern, single-page applications where the DOM is constantly changing.
The next rabbit hole you’ll likely fall down is understanding how Cypress handles routing and stubbing network requests.