Cypress
Cypress is an open-source tool for end-to-end testing of web applications. You write tests in JavaScript or TypeScript, and Cypress runs them inside a real browser, next to the application under test. For example, cy.get("[data-cy=cart-count]").should("have.text", "3") finds the cart badge and keeps checking it until it shows 3 or the time limit expires.
Why it matters
A tool’s design decides which tests are easy and which are painful. Cypress retries queries and assertions automatically and shows every command against a snapshot of the page, so many failures are quick to diagnose. The same design sets limits: one tab per test, JavaScript only, and commands that do not behave like ordinary JavaScript. Ignore them and you write fixed sleeps and tests that fail for the wrong reason.
For the product, a Cypress suite on every pull request catches regressions in key journeys before users do. For you, extending an existing Cypress suite is a practical skill on any team with a JavaScript front end.
How Cypress works: inside the browser, through a command queue
Selenium (Java, Python, C#, JavaScript, Ruby) sends W3C WebDriver commands over HTTP to a driver such as chromedriver or safaridriver; you write explicit waits and switch windows by handle. Playwright (TypeScript/JavaScript, Python, Java, .NET) drives Chromium, Firefox and WebKit over its own connection, with auto-waiting actions and retrying assertions, and can use several pages in one test. Both run tests in a separate process. Cypress runs your spec inside the browser, in the application’s run loop; a Node.js process behind it starts the browser and runs the Node tasks, defined in the config file, that a test calls with cy.task(). Cypress also controls the network, which makes cy.intercept() possible.
Selenium WebDriver
Test code outside the browser
- 1Your test processW3C WebDriver commands over HTTP
- 2Browser driver
chromedriver, safaridriver - 3Browser
- Test languages:
- Java, Python, C#, JavaScript, Ruby
- Waiting:
- Explicit waits you write
- Tabs in one test:
- Several windows
- Browsers named in this lesson:
- Chrome, Safari and others, each through its driver
Playwright
Test code outside the browser
- 1Your test processPlaywright’s own connection
- 2BrowserChromium, Firefox, WebKit
- Test languages:
- TypeScript/JavaScript, Python, Java, .NET
- Waiting:
- Auto-waiting actions, retrying assertions
- Tabs in one test:
- Several pages and browser contexts
- Browsers named in this lesson:
- Chromium, Firefox, WebKit
CypressThis lesson
Test code inside the browser
- 1Cypress Node.js processStarts the browser, runs the tasks a test calls with
cy.task() - 2Browser: your spec next to the appSame run loop; the network is under Cypress control
- Test languages:
- JavaScript, TypeScript
- Waiting:
- Queries and assertions retried until
defaultCommandTimeout - Tabs in one test:
- One tab; a plugin for a second one
- Browsers named in this lesson:
- Chrome, Edge, Firefox
Commands are queued, not run when their line executes: cy.visit(), cy.get() and .click() join a queue that Cypress runs after the test function returns. They are not promises, so you cannot await them or store their result; chain .then() or .should() instead.
The core idea is retry-ability: queries such as cy.get() and cy.contains() are retried with their chained assertions until the assertions pass or defaultCommandTimeout expires (4 seconds by default). Actions such as .click() run once, after waiting until the element is actionable: visible, enabled and not covered. Assert the state you expect instead of adding cy.wait(5000).
cy.visit('/cart')
cy.get('[data-cy=add-item]').click() // action: waits until actionable, runs once
cy.get('[data-cy=cart-count]') // query: retried...
.should('have.text', '1') // ...until this passes or the timeout expires
// Wrong: commands are queued, they return no value you can store
// const count = cy.get('[data-cy=cart-count]').text()
// Right: work with the yielded value inside .then()
cy.get('[data-cy=cart-count]').invoke('text').then((text) => {
cy.log(`The badge shows ${text}`)
})Install, project layout and the config file
Install Cypress per project with npm install --save-dev cypress; the first npx cypress open starts the Launchpad, where choosing E2E Testing creates the config file and the cypress/ folder. The config file below uses Node’s require() and module.exports (CommonJS), the older equivalent of import and export, because Node loads it; spec, support and page-object files can still use import and export, because Cypress bundles them before running them in the browser.
In the config file, baseUrl points cy.visit("/orders") at an environment; timeouts limit queries, page loads and responses; retries is set separately for cypress run and cypress open; reporter picks the output, such as JUnit XML for CI. The browser is chosen at run time with --browser chrome, edge or firefox; defaultBrowser sets the one used without the flag (otherwise cypress run uses the bundled Electron browser, deprecated since Cypress 16), and the browsers list can be filtered in setupNodeEvents.
cypress/e2e/: spec files such asorders.cy.js.cypress/fixtures/: stub data such asorders.json, and files to upload.cypress/support/:e2e.jsruns before every spec and importscommands.js, which holds custom commands such ascy.login(); page objects often live beside it.cypress/screenshots/andcypress/downloads/: run output, kept out of version control.
// cypress.config.js
const { defineConfig } = require('cypress')
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000', // cy.visit('/orders') opens this host
specPattern: 'cypress/e2e/**/*.cy.js',
},
defaultBrowser: 'chrome', // used when --browser is not given
defaultCommandTimeout: 6000, // queries and assertions (default 4000)
pageLoadTimeout: 60000, // page loads after cy.visit()
responseTimeout: 30000, // cy.request() and cy.wait() responses
retries: { runMode: 1, openMode: 0 }, // one rerun in cypress run only
reporter: 'junit',
reporterOptions: { mochaFile: 'results/junit-[hash].xml' },
screenshotOnRunFailure: true,
video: false,
})Run one test, debug it, and run the suite in CI
While writing, use npx cypress open: pick a browser and a spec, and it reruns on every save. Clicking a command in the Command Log shows the page as it was at that moment (time travel). From the command line and in CI:
- One spec:
npx cypress run --spec cypress/e2e/orders.cy.js --browser chrome. - One test:
it.only(while you work; remove it before committing. - Headed mode:
cypress runhides the browser; add--headedto watch. - Debugging:
cy.pause()stops the queue in the open runner,.debug()stops at a breakpoint when DevTools are open, andDEBUG=cypress:*(Linux and macOS syntax) prints Cypress’s own logs when it will not start or launch a browser. - CI: the official image
cypress/browsershas Node.js and browsers (cypress/includedadds a fixed Cypress version). The job below runs every spec in Chrome, takes the password from repository secrets and keeps the JUnit results and failure screenshots.
# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
cypress:
runs-on: ubuntu-latest
container:
image: cypress/browsers:latest # pin an exact tag in a real pipeline
steps:
- uses: actions/checkout@v7
- run: npm ci
- run: npx cypress run --browser chrome --config baseUrl=https://test.example.com
env:
CYPRESS_BUYER_PASSWORD: ${{ secrets.BUYER_PASSWORD }}
- uses: actions/upload-artifact@v7
if: always()
with:
name: cypress-results
path: |
results
cypress/screenshotsSessions, network stubs, dialogs, tabs and uploads
Test isolation is on by default: each test starts with a blank page and no cookies or storage, so each test must sign in. cy.session() runs the login once, caches the resulting cookies and storage, and restores them for later tests with the same id; validate checks that a restored session still works. Visit a page afterwards, because the page is blank.
cy.intercept() matches requests by method and URL. Given a static response such as { fixture: "orders.json" } or { statusCode: 500 } it stubs the server, so empty states and errors are reachable on demand; without one it only watches, and cy.wait("@alias") waits for the request so you can assert on it.
- Dialogs: Cypress accepts
alert()andconfirm()automatically. Passcy.on("window:alert", …)a stub, a stand-in function that records its calls, and assert on its alias, so the test also fails when no alert appears; awindow:confirmstub that returnsfalsechooses Cancel. - New tabs: Cypress drives one tab. For a
target="_blank"link, checkhrefandtarget, then visit the URL in the same tab or removetargetbefore clicking; the documentation points to the@cypress/puppeteerplugin when a second tab is unavoidable. - Upload:
.selectFile("cypress/fixtures/receipt.pdf")on a file input; add{ action: "drag-drop" }for a drop zone.
// cypress/support/commands.js: sign in once per user, reuse the session
Cypress.Commands.add('login', (email) => {
cy.session(email, () => {
cy.env(['BUYER_PASSWORD']).then(({ BUYER_PASSWORD }) => {
cy.request({
method: 'POST',
url: '/api/login',
body: { email, password: BUYER_PASSWORD },
log: false, // keep the password out of the log
})
})
}, {
validate() {
cy.request('/api/me').its('status').should('eq', 200)
},
})
})
// In a spec: watch a request, check an alert, upload a file
cy.login('buyer@example.com')
cy.intercept('POST', '/api/receipts').as('upload') // watch only, no stub
cy.on('window:alert', cy.stub().as('alert')) // record every alert
cy.visit('/account/receipts')
cy.get('[data-cy=receipt-file]').selectFile('cypress/fixtures/receipt.pdf')
cy.get('[data-cy=upload]').click()
cy.wait('@upload').its('response.statusCode').should('eq', 201)
cy.get('@alert').should('have.been.calledOnceWith', 'Receipt uploaded')Try it: Mock API Response Generator Try it: Mock Error Response Generator
Worked example: order history in four tests with a page object
A first Cypress test often signs in, checks the list, cancels an order and checks an error in one it. When an early step fails, the later checks never run. Split it by behaviour.
Requirement: “The order history page lists the buyer’s orders. With none it shows You have no orders yet; if loading fails it shows Could not load your orders. Cancelling asks for confirmation; choosing Cancel keeps the order.”
The page object holds the selectors and makes no assertions. The fixture orders.json holds three orders; A-1002 has the status Processing. Each test stubs its own data, reuses the cached session and checks one behaviour, so a failing title names what broke.
// cypress/support/pages/ordersPage.js: knows the screen, makes no assertions
export const ordersPage = {
visit: () => cy.visit('/account/orders'),
rows: () => cy.get('[data-cy=order-row]'),
row: (id) => cy.get(`[data-cy=order-row][data-order-id="${id}"]`),
message: () => cy.get('[data-cy=orders-message]'),
cancel: (id) => ordersPage.row(id).find('[data-cy=cancel-order]').click(),
}
// cypress/e2e/orders.cy.js: one behaviour per test
import { ordersPage } from '../support/pages/ordersPage'
describe('Order history', () => {
beforeEach(() => {
cy.login('buyer@example.com')
})
it('lists the orders returned by the API', () => {
cy.intercept('GET', '/api/orders', { fixture: 'orders.json' }).as('orders')
ordersPage.visit()
cy.wait('@orders')
ordersPage.rows().should('have.length', 3)
})
it('shows the empty state when there are no orders', () => {
cy.intercept('GET', '/api/orders', { body: [] })
ordersPage.visit()
ordersPage.message().should('have.text', 'You have no orders yet')
})
it('shows an error when the orders cannot be loaded', () => {
cy.intercept('GET', '/api/orders', { statusCode: 500, body: {} })
ordersPage.visit()
ordersPage.message().should('have.text', 'Could not load your orders')
})
it('keeps the order when the buyer cancels the confirmation', () => {
cy.intercept('GET', '/api/orders', { fixture: 'orders.json' })
cy.on('window:confirm', cy.stub().as('confirm').returns(false)) // choose Cancel
ordersPage.visit()
ordersPage.cancel('A-1002')
cy.get('@confirm').should('have.been.calledOnceWith', 'Cancel order A-1002?')
ordersPage.row('A-1002').should('contain', 'Processing')
})
})Common mistakes
- Adding
cy.wait(5000)instead of asserting the expected state or waiting for an aliased request. - Treating commands as plain JavaScript: storing
cy.get()in a variable, usingawait, or branching withifon state the queue has not reached yet. - Signing in through the UI in every test instead of using
cy.session(). - Stubbing every request, so the suite stays green while the front end and the API disagree.
- Using
retriesto hide flaky tests; a retry is containment, not a fix. - Committing
it.only, which silently skips the rest of the spec, or real passwords instead of CI secrets.
The QA perspective
A tester meets Cypress when choosing which journeys deserve an end-to-end test (a few valuable ones; the rest belong lower in the pyramid), when writing and reviewing specs with developers, and when reading CI failures. Agree on data-cy attributes early, as the Cypress best-practices guide recommends.
When a test fails in CI, start from the error message and the failure screenshot, and decide whether it is a product defect, a test defect or an environment problem before rerunning. The bug report names the spec, test, browser, Cypress version and build, and whether the data was stubbed.
Practice exercise
Plan a Cypress spec for a coupon field
The cart total is 200.00 SAR before any coupon. Requirement: “A valid code applies a 10% discount and shows Coupon applied. An expired code shows This coupon has expired. If the coupon service fails, the page shows Coupons are unavailable, try again later and the total does not change.” The page sends the request below.
Design a cart page object and one test per behaviour, each stubbing its response with cy.intercept(). Paper is enough.
POST /api/coupons/apply
Content-Type: application/json
{"code": "SAVE10"}
Possible responses
200 OK {"code": "SAVE10", "discountPercent": 10, "total": 180.00}
422 Unprocessable Content {"error": "COUPON_EXPIRED"}
503 Service Unavailable {"error": "SERVICE_UNAVAILABLE"}- List the behaviours and the response each one needs.
- Write the page object: selectors and actions, no assertions.
- Write one
itper behaviour with its stub and assertions, including the total. - Note what the stubs cannot prove and how you would cover it.
Show the model answer
Behaviours: a valid code (200, total: 180.00), an expired code (422) and a service failure (503). An empty code needs no stub: Apply stays disabled or a validation message appears, as the team agreed.
Page object: visit(), apply(code) (types into [data-cy=coupon-code] and clicks [data-cy=apply-coupon]), message() and total(); no should() inside.
Tests: (1) stub the 200 as @apply, apply SAVE10, and check that cy.wait("@apply").its("request.body") equals { code: "SAVE10" }, the message reads Coupon applied and the total 180.00. (2) Stub the 422; expect This coupon has expired and 200.00. (3) Stub the 503; expect Coupons are unavailable, try again later and 200.00.
The stubs do not prove that the real service accepts SAVE10 and calculates 10% correctly; add an API test and one end-to-end test with seeded coupon data. Ask the product owner whether the discount applies before VAT and delivery, and whether coupons combine.
Frequently asked questions
Should I learn Cypress or Playwright?
Learn the one your team uses; locators, waits, assertions and page objects transfer between them. Cypress suits teams that write JavaScript or TypeScript and like its interactive runner. Playwright supports more languages and can drive several pages and browser contexts in one test, which matters when a product’s flows span tabs or windows.
Why can’t I use async/await with Cypress commands?
Because commands are queued rather than executed when their line runs, and they are not promises. Cypress runs the queue after the test function returns, retrying queries as it goes. Use .then() to work with a yielded value, aliases to reuse it, and .should() to assert on it with retries.
Can Cypress test APIs?
cy.request() sends HTTP requests from a test. It is handy for setup, such as creating data or signing in, and for a few API checks next to UI tests. Cypress still needs a browser to run, so a large API suite is usually simpler in a dedicated API testing tool or library.
Should I turn on retries?
One or two retries in cypress run can keep a pipeline usable while a flaky test is being fixed, but every pass after a retry must be visible in the report and treated as a defect in the test or the product. Keep openMode retries at 0 so you see flakiness while you write tests.
References
- Cypress DocumentationOfficial documentation for end-to-end and component testing in the browser with Cypress.
- Best Practices — CypressCypress guidance on selecting elements with dedicated attributes, avoiding fixed waits and keeping tests independent.
- GitHub Actions documentationHow workflows, jobs, runners and artifacts are defined in GitHub Actions — where automated tests run on each push or pull request.
Practise with Mutqan tools
Generate complete mock API responses (status, headers, JSON body) for success, list, detail, error and empty cases.
Generate structured error responses (validation, auth, not found, rate limit, server error) in popular formats.
Validate JSON syntax and get precise error locations with helpful fix suggestions.
Format and validate YAML with consistent indentation and quoting.
Beautify JavaScript and TypeScript code with consistent indentation and spacing.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.