Cypress E2E testing means driving your real application in a real browser the way a user would: visit a page, type into fields, click buttons, and assert on what appears. Cypress runs inside the browser next to your app, waits for elements automatically and retries assertions until they pass or time out. That built-in retrying is what makes Cypress tests stable when they're written correctly, and flaky when they're not.
This guide covers the core of Cypress 13 and later: where E2E tests fit among other test types, test structure with Mocha, assertions, retry-ability and the should vs then distinction, the commands you'll use daily, custom commands, configuration with baseUrl and environment variables, and calling APIs with cy.request.
Where E2E tests fit
| Test type | What it checks | Speed | Typical tool |
|---|---|---|---|
| Unit | One function or module in isolation | Milliseconds | Vitest, Jest |
| Component | One UI component rendered in a browser | Fast | Cypress component testing, Testing Library |
| Integration | Two or more parts working together, such as a service and its database or two services | Medium | Test containers, API tests |
| End-to-end (E2E) | A full user journey through the deployed stack | Slowest | Cypress, Playwright |
E2E tests give the most confidence per test and cost the most to run and maintain. Keep them for critical journeys: sign-up, login, checkout, the flows that would wake someone up if they broke. Push detailed edge cases down to unit and component tests. For the component layer, see Cypress component testing.
Setting up a Cypress project
npm install --save-dev cypress
npx cypress open # interactive runner, scaffolds config on first run
npx cypress run # headless, for CISince Cypress 10, configuration lives in cypress.config.ts (or .js), not cypress.json. A minimal E2E setup:
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.ts',
defaultCommandTimeout: 4000,
},
})With baseUrl set, cy.visit('/login') and cy.request('/api/health') resolve against it. You change the target environment in one place instead of editing every test, and Cypress avoids an extra reload when the first test starts.
Structuring tests with describe, context and it
Cypress uses Mocha's BDD syntax. describe groups tests, context is an alias for describe that reads well for scenarios, and it is a single test:
describe('Login page', () => {
context('with valid credentials', () => {
it('redirects to the dashboard', () => {
cy.visit('/login')
cy.get('[data-cy=email]').type('[email protected]')
cy.get('[data-cy=password]').type('correct-horse{enter}')
cy.location('pathname').should('eq', '/dashboard')
})
})
context('with a wrong password', () => {
it('shows an error message', () => {
cy.visit('/login')
cy.get('[data-cy=email]').type('[email protected]')
cy.get('[data-cy=password]').type('wrong{enter}')
cy.contains('[role=alert]', 'Invalid email or password').should('be.visible')
})
})
})Selecting by data-cy attributes keeps tests stable when CSS classes or markup change. Selecting by accessible role and label is often better still, and Cypress Testing Library shows how.
Mocha hooks
Hooks run setup code around tests:
describe('Cart', () => {
before(() => {
// once before all tests in this block, e.g. seed a product
})
beforeEach(() => {
// before every test: the usual place for login and visit
cy.visit('/cart')
})
afterEach(() => {
// after every test
})
after(() => {
// once after all tests
})
})Cypress recommends resetting state in beforeEach rather than cleaning up in after or afterEach. If a test fails halfway, cleanup hooks may never finish, and the next run starts dirty. Since Cypress 12, test isolation is on by default: the page, cookies and local storage are cleared between tests, so each test must set up what it needs.
Running a subset with only and skip
it.only('runs just this test', () => { /* ... */ })
describe.skip('skips this whole block', () => { /* ... */ }).only is handy while debugging, but a committed .only silently disables the rest of the suite. Add a lint rule such as mocha/no-exclusive-tests to catch it.
Cypress assertions
Cypress bundles Chai, Chai-jQuery and Sinon-Chai. Most assertions are written with .should() and chained with .and():
cy.get('[data-cy=todo-item]')
.should('have.length', 3)
.first()
.should('have.class', 'completed')
.and('contain', 'Buy milk')
cy.get('button[type=submit]').should('be.disabled')
cy.get('input[name=email]').should('have.value', '[email protected]')
cy.get('a.docs').should('have.attr', 'href', '/docs')
cy.url().should('include', '/dashboard')have.class, have.attr and be.visible come from Chai-jQuery. The underlying libraries are also exposed directly: Cypress.$ is jQuery and Cypress._ is Lodash, which is useful for generating test data like Cypress._.times(5, ...).
Retry-ability: should vs then
This is the concept that most affects test stability. Cypress queries such as cy.get(), .find(), .contains() and .its() are retried together with the assertion that follows them, until the assertion passes or defaultCommandTimeout (4 seconds by default) expires. When data loads late, you don't add waits. The assertion simply keeps retrying.
// Retries until the API responds and three rows render
cy.get('table tbody tr').should('have.length', 3).then() is different. Its callback runs once with whatever the previous command yielded, and it is not retried:
// Flaky: if the list hasn't rendered yet, this checks an empty result once and fails
cy.get('ul li').then(($items) => {
expect($items).to.have.length(3)
})
// Stable: the callback is retried until the expectation passes
cy.get('ul li').should(($items) => {
expect($items).to.have.length(3)
expect($items.first()).to.contain('Buy milk')
}).should() | .then() | |
|---|---|---|
| Retries | Yes, until pass or timeout | No, runs once |
| Can run Cypress commands inside | No | Yes |
| Yields | The same subject | The callback's return value, or the same subject |
| Use for | Assertions | Working with a value once it's stable |
A good pattern: make the state stable with .should() first, then use .then() to read values and issue further commands. Avoid cy.wait(2000). A fixed wait is either too short and flaky, or too long and slow. Wait for a specific condition or a network alias instead.
Commands you'll use every day
Actions
cy.get('[data-cy=search]').clear().type('keyboard{enter}')
cy.get('[data-cy=terms]').check()
cy.get('select[name=country]').select('Thailand')
cy.contains('button', 'Save').click()Cypress checks that an element is visible, not disabled and not covered before it acts. If an action fails with "element is covered by another element", fix the page or the test rather than reaching for { force: true }.
Checking the URL
cy.location('pathname').should('eq', '/orders/42')
cy.location('search').should('include', 'page=2')
cy.url().should('match', /\/orders\/\d+$/)Scoping with within
.within() scopes every query inside the callback to a parent element, which avoids ambiguous selectors on pages with several similar forms:
cy.get('form#shipping').within(() => {
cy.get('input[name=city]').type('Bangkok')
cy.get('input[name=zip]').type('10110')
})Looping with each
const expected = ['Draft', 'Paid', 'Shipped']
cy.get('[data-cy=status]').each(($el, index) => {
cy.wrap($el).should('have.text', expected[index])
})wrap, its and invoke
cy.wrap()puts a value, jQuery element or promise into the Cypress chain so you can use Cypress commands and retrying assertions on it. That's why the.each()example wraps$el..its()reads a property:cy.wrap(user).its('address.city'), orcy.get('li').its('length')..invoke()calls a method:.invoke('text'),.invoke('val'),.invoke('attr', 'href').
cy.get('[data-cy=total]')
.invoke('text')
.should('match', /\d+\.\d{2}$/)
cy.window().its('localStorage.token').should('exist')Custom commands
When the same steps repeat across specs, move them into a custom command in cypress/support/commands.ts:
// cypress/support/commands.ts
Cypress.Commands.add('getByTestId', (id: string) => {
return cy.get(`[data-cy="${id}"]`)
})
Cypress.Commands.add('login', (email: string, password: string) => {
cy.session([email], () => {
cy.request('POST', '/api/login', { email, password })
.its('status')
.should('eq', 200)
})
})For TypeScript to know about the new commands, extend the Cypress.Chainable interface:
declare global {
namespace Cypress {
interface Chainable {
getByTestId(id: string): Chainable<JQuery<HTMLElement>>
login(email: string, password: string): Chainable<void>
}
}
}
export {}cy.session() caches cookies and storage for a given key, so logging in through the UI happens once instead of before every test. The login command above logs in through the API, which is faster still. Keep one E2E test that exercises the real login form. How global declarations like this work is covered in TypeScript declaration files.
Environment variables in Cypress
Keep environment-specific values out of test code. Cypress reads them from several places, and values from the command line or CYPRESS_-prefixed OS variables override values in the config file:
// cypress.config.ts
export default defineConfig({
e2e: { baseUrl: 'http://localhost:3000' },
env: { apiUrl: 'http://localhost:8080' },
})# CI: override without touching files
CYPRESS_apiUrl=https://api.staging.example.com npx cypress run
npx cypress run --env apiUrl=https://api.staging.example.com
npx cypress run --config baseUrl=https://staging.example.comA git-ignored cypress.env.json works for local values. Read them in tests with Cypress.env('apiUrl'). Values passed through Cypress.env are available to code running in the browser, so treat test credentials as low-privilege accounts, not real secrets.
Calling APIs with cy.request
cy.request() sends an HTTP request from Cypress, outside the browser page, with no CORS restrictions. Use it to seed data, reset state, log in, or check an endpoint directly:
beforeEach(() => {
cy.request('POST', `${Cypress.env('apiUrl')}/test/reset`)
cy.request({
method: 'POST',
url: `${Cypress.env('apiUrl')}/orders`,
body: { sku: 'KB-001', qty: 2 },
}).its('body.id').as('orderId')
})
it('shows the new order', function () {
cy.visit(`/orders/${this.orderId}`)
cy.contains('h1', `Order #${this.orderId}`).should('be.visible')
})By default cy.request fails the test on any 4xx or 5xx response. Pass failOnStatusCode: false to assert on error responses yourself. Note the function () syntax in the test: aliases on this don't work with arrow functions.
cy.request talks to a real server. To stub or observe the requests your app makes from the browser, use cy.intercept(), covered in mocking APIs in Cypress with intercept and Faker.
FAQ
What is the difference between should and then in Cypress?
.should() retries its assertion until it passes or times out. .then() runs its callback once and doesn't retry. Use should for assertions and then to work with a value after the page is stable.
Where did cypress.json go?
Cypress 10 replaced it with cypress.config.ts or cypress.config.js, with separate e2e and component sections. Running npx cypress open on an old project offers to migrate it.
How do I run a single Cypress test?
Add .only to an it or describe, or run one spec file with npx cypress run --spec cypress/e2e/login.cy.ts.
How do I avoid logging in before every Cypress test?
Wrap your login steps in cy.session() inside a custom command. Cypress restores the cached session instead of repeating the login.
Should I use cy.wait with a number?
Rarely. Wait for a condition with .should() or for an intercepted request with cy.wait('@alias') instead of a fixed delay.
Cypress E2E checklist
- Cover critical user journeys with E2E, and push edge cases down to unit and component tests.
- Set
baseUrland read environment-specific values fromCypress.env. - Select elements by
data-cyattributes or accessible roles, not CSS classes. - Reset state in
beforeEach, and usecy.requestto seed data quickly. - Assert with
.should()so Cypress can retry, and avoid fixedcy.waitdelays. - Move repeated steps into typed custom commands, and cache logins with
cy.session. - Lint against committed
.only.
A small suite that follows these rules stays fast and trustworthy as the app grows. If you want help setting up an E2E testing strategy for your web app, Vectorkub builds and tests production web applications.
