What is Cypress?
Cypress is an end-to-end testing tool that runs inside the browser alongside your application. That architectural choice gives it an unusually good developer experience: an interactive runner where you watch commands execute, the ability to inspect the DOM at every step, and a time-travel debugger that replays each action.
It is one of the most popular browser-testing tools and is particularly loved for its approachability. Commands queue automatically, assertions retry until they pass, and the runner makes it obvious what happened when a test fails.
Writing a test
Cypress tests use a chainable command API and a describe/it structure.
// cypress/e2e/todos.cy.js
describe("todos", () => {
beforeEach(() => {
cy.visit("/todos");
});
it("adds a todo", () => {
cy.get('[data-cy="new-todo"]').type("Write tests");
cy.get('[data-cy="add"]').click();
cy.get('[data-cy="todo"]').should("have.length", 1);
cy.contains("Write tests").should("be.visible");
});
});
Cypress commands are queued and run in order, even though they return immediately. That is why you chain .then or use aliases instead of assigning a command’s result to a variable.
Commands and assertions
The core commands cover navigation, queries and interaction:
cy.visit(url)opens a page.cy.get(selector)queries by CSS selector.cy.contains(text)finds an element by its text..type(),.click(),.select(),.check()interact with elements..should()asserts and retries.
// assertions.cy.js
cy.get("form").should("be.visible");
cy.get('[data-cy="count"]').should("have.text", "3");
cy.get("button").should("be.disabled");
cy.get('[data-cy="row"]').should("have.length", 5);
Because .should() retries until it passes or times out, you rarely need to wait explicitly. This is the same retry-based approach that makes Playwright tests resilient, applied to a chained API.
Network stubbing with cy.intercept
cy.intercept is how you make tests deterministic. It can stub responses, spy on real traffic, modify requests and control timing.
// intercept.cy.js
cy.intercept("GET", "/api/users", {
statusCode: 500,
body: { message: "Server error" },
}).as("users");
cy.visit("/users");
cy.wait("@users");
cy.contains("Something went wrong").should("be.visible");
Using an alias with cy.wait("@users") lets you assert on the request and wait for the response without guessing at timing. Stubbing error states this way is far more reliable than trying to provoke them from a real backend.
Custom commands and fixtures
Repeated flows belong in a custom command, which keeps tests readable.
// cypress/support/commands.js
Cypress.Commands.add("login", (email, password) => {
cy.visit("/login");
cy.get('[data-cy="email"]').type(email);
cy.get('[data-cy="password"]').type(password);
cy.get('[data-cy="submit"]').click();
});
// usage
cy.login("[email protected]", "secret");
Fixtures load static data from cypress/fixtures, and cy.fixture("users.json").then(...) gives tests predictable inputs. Combined with cy.intercept, fixtures are the standard way to test without a live backend.
Selecting elements
Cypress works with CSS selectors, so the temptation to bind tests to styling is real. Avoid it. Prefer, in order:
- Accessible queries via
@testing-library/cypress(findByRole,findByLabelText). cy.containsfor visible text.- A dedicated
data-cyattribute when there is no user-facing identity.
A data-cy attribute is explicit and stable, so tests survive redesigns. Class-based selectors do not.
Where Cypress fits
Cypress is the end-to-end layer. Keep the pyramid balanced: fast unit tests with Vitest or Jest, component tests with Testing Library, and a focused set of end-to-end journeys in Cypress or Playwright. Push edge cases down to the cheaper layers and reserve e2e for the paths that must work end to end.
Best practices
- Stub the network with
cy.interceptfor deterministic tests. - Use
data-cyor accessible queries instead of styling selectors. - Keep tests independent so they can run in parallel.
- Encapsulate repeated flows in custom commands.
- Log in through a custom command or API call rather than the UI every time.
- Assert on user-visible outcomes, not internal state.
- Run headless in CI and keep the interactive runner for local debugging.
Common mistakes
- Waiting with
cy.wait(milliseconds)instead of on a network alias or an assertion. - Coupling selectors to CSS classes and structure.
- Sharing state between tests and causing order-dependent failures.
- Trying to store command results in variables without
.thenor aliases. - Testing everything end-to-end instead of at the right layer.
- Logging in through the UI before every test and slowing the suite down.
Where to go next
Cypress is a friendly, powerful way to test real user journeys. Compare it with Playwright for the cross-browser alternative, and build the lower layers with Vitest and Testing Library. Then add a custom login command and one critical flow, and grow the suite from there.