Lesson 5 of 12 · Automation Frameworks
Cypress Basics
How Cypress runs tests inside the browser, the command queue and retry-ability, selectors, cy.intercept for network control, cy.request and cy.session for fast logins.
beginner 7 min read Updated
You will learn to
- Explain Cypress's in-browser architecture and how it differs from WebDriver tools
- Write tests that use the command queue and retry-ability correctly, without async/await
- Spy on and stub network traffic with cy.intercept
- Speed up suites with cy.request and cy.session for programmatic login
Prerequisites
- JavaScript Essentials
- Choosing an Automation Tool
Cypress made front-end testing approachable for many JavaScript teams: install one package, open an interactive runner, and watch your test drive the app with time-travel snapshots of every step. Its design is quite different from Selenium and Playwright, and most Cypress confusion (why async/await does not work, why a variable is undefined) comes from not knowing how it runs.
This lesson covers Cypress 14 and 15 with TypeScript. Practice API examples use https://sdetrole.com/api/mock.
How Cypress runs
Selenium and Playwright control the browser from outside, over a protocol. Cypress runs inside the browser: your test code executes in the same run loop as the application, in an iframe next to it, while a Node.js process handles tasks the browser cannot do (file access, proxying network traffic, launching the browser).
Consequences:
- Direct access to the app’s
window, DOM, local storage and network, without serialising every call. - Automatic waiting is built into commands and assertions.
- JavaScript or TypeScript only, because tests run in the browser.
- One browser tab per test. Multi-tab flows are not supported; you test them by removing
target="_blank"or checking the link’shref. - Visiting a second origin in one test needs
cy.origin(). - Supported browsers are Chrome-family browsers (Chrome, Edge, Electron) and Firefox; WebKit support is experimental.
Setup
npm install --save-dev cypress
npx cypress open # interactive runner
npx cypress run # headless, for CI
cypress open scaffolds a cypress/ folder and a config file:
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.ts',
retries: { runMode: 2, openMode: 0 },
defaultCommandTimeout: 6000,
},
});
With baseUrl set, cy.visit('/login') and cy.request('/api/...') are relative to it.
The command queue
This is the concept to get right. Cypress commands do not run when called. Calling cy.get(...) adds a command to a queue and returns immediately; Cypress runs the queue after your test function has finished defining it.
describe('login', () => {
it('shows the dashboard after a valid login', () => {
cy.visit('/login');
cy.get('[data-cy=email]').type('ana@example.com');
cy.env(['TEST_PASSWORD']).then(({ TEST_PASSWORD }) => {
cy.get('[data-cy=password]').type(TEST_PASSWORD, { log: false });
});
cy.get('[data-cy=submit]').click();
cy.location('pathname').should('eq', '/dashboard');
cy.contains('h1', 'Dashboard').should('be.visible');
});
});
Secrets are read with cy.env([...]), a chained command that yields only the keys you ask for. It replaced the synchronous Cypress.env(), which was deprecated in Cypress 15.10 and removed in 16. Public, non-secret settings use the synchronous Cypress.expose('key'), configured under expose instead of env.
Because commands are queued, these common mistakes do not work:
// Wrong: cy.get does not return the element
const text = cy.get('[data-cy=total]').text();
// Wrong: commands are not promises; do not await them
await cy.get('[data-cy=total]');
To use a value from the page, continue the chain with .then(), or give it an alias:
cy.get('[data-cy=total]').invoke('text').then((total) => {
cy.get('[data-cy=checkout]').click();
cy.get('[data-cy=summary-total]').should('have.text', total);
});
Retry-ability and assertions
Queries (cy.get, cy.find, cy.contains) are retried together with the assertions chained to them until the assertions pass or defaultCommandTimeout expires (4 seconds unless configured). That is why there is no explicit wait in the test above.
cy.get('[data-cy=cart-count]').should('have.text', '1'); // retries until true
cy.get('[data-cy=row]').should('have.length', 10);
cy.get('[data-cy=spinner]').should('not.exist');
cy.get('[data-cy=save]').should('be.enabled').click();
Actions such as .click() and .type() are not retried, but they wait until the element is actionable (visible, not covered, not disabled, not animating).
Selectors
Cypress recommends dedicated test attributes such as data-cy or data-testid, because they do not change when styling or copy changes. cy.contains('button', 'Save') is also good when the visible text is the behaviour under test. Avoid long CSS chains tied to layout.
Controlling the network with cy.intercept
cy.intercept lets a test watch, wait for, modify or replace any HTTP request the app makes.
Spy and wait on a real request:
cy.intercept('POST', '**/api/mock/users').as('createUser');
cy.get('[data-cy=name]').type('Test User');
cy.get('[data-cy=email]').type('test.user@example.com');
cy.get('[data-cy=create]').click();
cy.wait('@createUser').then(({ request, response }) => {
expect(request.body).to.include({ name: 'Test User' });
expect(response?.statusCode).to.eq(201);
});
cy.contains('[data-cy=toast]', 'User created').should('be.visible');
Stub a response to make UI tests deterministic, or to reach states that are hard to produce for real:
// Fixed list from cypress/fixtures/users-page-1.json
cy.intercept(
{ method: 'GET', pathname: '/api/mock/users' },
{ fixture: 'users-page-1.json', headers: { 'x-total-count': '25' } },
).as('getUsers');
cy.visit('/admin/users');
cy.wait('@getUsers');
cy.get('[data-cy=user-row]').should('have.length', 10);
// Server error: does the UI show a helpful message?
cy.intercept('GET', '**/api/mock/users*', {
statusCode: 503,
headers: { 'retry-after': '1' },
body: { title: 'Service Unavailable', status: 503 },
}).as('usersDown');
cy.visit('/admin/users');
cy.wait('@usersDown');
cy.contains('[role=alert]', 'try again').should('be.visible');
// Slow network: is there a loading state?
cy.intercept('GET', '**/api/mock/users*', (req) => {
req.on('response', (res) => {
res.setDelay(2000);
});
});
Stubbing is powerful, but a fully stubbed suite never proves the front end and back end work together. Keep a set of tests against the real API too, and use stubs for error states and edge cases.
API calls with cy.request
cy.request sends HTTP requests from the Node process, so it is not subject to browser CORS rules. Use it for setup, cleanup and API checks:
const api = 'https://sdetrole.com/api/mock';
it('logs in through the API and reads the profile', () => {
cy.request('POST', `${api}/auth/login`, { username: 'tester', password: 'practice123' })
.its('body.accessToken')
.then((token) => {
cy.request({ url: `${api}/auth/me`, headers: { Authorization: `Bearer ${token}` } })
.its('body')
.should('include', { username: 'tester', role: 'tester' });
});
});
it('rejects a missing token', () => {
cy.request({ url: `${api}/auth/me`, failOnStatusCode: false }).then((res) => {
expect(res.status).to.eq(401);
expect(res.headers['www-authenticate']).to.contain('Bearer');
});
});
cy.request fails the test on any status outside 2xx and 3xx unless you pass failOnStatusCode: false, which you need for every negative test.
Fast, cached logins with cy.session
Logging in through the UI before every test is the biggest waste of time in most suites. Combine a programmatic login with cy.session, which caches cookies, local storage and session storage after the first run and restores them for later tests:
// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
cy.session(
['login', email],
() => {
// the app's login endpoint sets a session cookie
cy.request('POST', '/api/login', { email, password });
},
{
validate() {
cy.getCookie('session_id').should('exist');
},
cacheAcrossSpecs: true,
},
);
});
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
}
}
}
export {};
beforeEach(() => {
cy.env(['TEST_PASSWORD']).then(({ TEST_PASSWORD }) => cy.login('ana@example.com', TEST_PASSWORD));
cy.visit('/dashboard');
});
The first test runs the setup; later tests restore the cached session, and validate re-runs setup if the session has expired. Keep one test that logs in through the real UI so the login page itself is still covered.
Practice exercise
An admin page at /admin/users loads GET /api/mock/users?page=1&limit=10 and shows one row per user ([data-cy=user-row]) and a “Next” button ([data-cy=next-page]) that is disabled on the last page. Write two tests:
- With the real Practice API: wait for the request, assert its status is 200 and that 10 rows render.
- With a stub: return an empty
dataarray,total: 0andtotalPages: 1, and assert an empty state message “No users found” is shown and “Next” is disabled.
Check the real response shape in the API Playground first.
Show a model answer
describe('admin users list', () => {
it('renders the first page from the real API', () => {
cy.intercept({ method: 'GET', pathname: '/api/mock/users' }).as('getUsers');
cy.visit('/admin/users');
cy.wait('@getUsers').its('response.statusCode').should('eq', 200);
cy.get('[data-cy=user-row]').should('have.length', 10);
cy.get('[data-cy=next-page]').should('be.enabled');
});
it('shows an empty state when there are no users', () => {
cy.intercept(
{ method: 'GET', pathname: '/api/mock/users' },
{
statusCode: 200,
headers: { 'x-total-count': '0' },
body: { data: [], page: 1, limit: 10, total: 0, totalPages: 1 },
},
).as('getUsers');
cy.visit('/admin/users');
cy.wait('@getUsers');
cy.contains('No users found').should('be.visible');
cy.get('[data-cy=user-row]').should('not.exist');
cy.get('[data-cy=next-page]').should('be.disabled');
});
});Matching on pathname means the intercept catches the request regardless of its query string. The stubbed body copies the real response’s shape, so the test still exercises the same parsing code; a stub that drifts from the real API makes tests pass against an API that no longer exists.
Key takeaways
- Cypress runs inside the browser, which gives it direct access and automatic waiting, but limits it to JavaScript, one tab and Chrome-family or Firefox browsers.
- Commands are queued, not awaited. Use
.then(), aliases and assertions; neverawaita Cypress command. - Queries retry with their assertions; avoid
cy.wait(ms)and wait for DOM state or aliased requests. cy.interceptspies on and stubs network traffic; use stubs for error and edge states, and keep some tests on the real API.cy.requestpluscy.sessiongives fast, cached logins; passfailOnStatusCode: falsefor negative API tests.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.
Practise it
Related interview questions
- Why can't you use async/await with Cypress commands?
- How do you use cy.intercept to wait on, spy on and stub network requests?
- Explain Browser, BrowserContext and Page in Playwright. Why do contexts matter for test isolation?
- How does test isolation work in Cypress, and how do you cache login with cy.session?