Once the Cypress basics are comfortable, the biggest improvements usually come from learning how Cypress handles retryability, browser network traffic, direct API calls, reusable commands, Node-side tasks, environment configuration, authentication state and continuous integration. These capabilities let QA engineers build faster, more deterministic end-to-end suites while avoiding common anti-patterns such as fixed waits, repeated UI setup and uncontrolled dependencies on backend data.
Advanced Cypress: the main ideas
Advanced Cypress is less about learning more commands and more about understanding where code runs, what Cypress retries and which layer should own a particular test responsibility.
Retry-aware assertions
Use Cypress queries and assertions so dynamic applications can reach the expected state without arbitrary sleeps.
Network control
Observe, wait for, modify or stub browser requests with cy.intercept().
Direct API access
Use cy.request() for API checks and fast test-data setup without navigating the UI.
Browser ↔ Node bridge
Use cy.task() for operations that belong outside the browser, such as database scripts or filesystem work.
Reusable sessions
Cache authentication state with cy.session() instead of logging in through the UI before every test.
CI execution
Run deterministic Cypress suites automatically on pushes, pull requests or deployment pipelines.
Retryability first: .should() vs .then()
Cypress applications often re-render asynchronously. Understanding the difference between .should() and .then() is therefore one of the most important advanced concepts.
Use .then() when you want to work with the yielded value once
cy.get('[data-cy="card"]')
.then(($cards) => {
expect($cards.eq(0)).to.contain.text('milk')
expect($cards.eq(1)).to.contain.text('bread')
expect($cards.eq(2)).to.contain.text('juice')
})
The callback receives the current subject and executes once. If the DOM is temporarily in the wrong state and then becomes correct a moment later, the assertions inside .then() are not automatically retried.
Use .should(callback) for retryable assertions
cy.get('[data-cy="card"]')
.should(($cards) => {
expect($cards.eq(0)).to.contain.text('milk')
expect($cards.eq(1)).to.contain.text('bread')
expect($cards.eq(2)).to.contain.text('juice')
})
Cypress retries the relevant query chain and callback assertions until they pass or the command timeout is reached.
.should(). Use .then() when you genuinely need one-time transformation, branching or access to a yielded value.Simple built-in assertion syntax is still excellent
cy.get('[data-cy="status"]')
.should('be.visible')
.and('contain.text', 'Ready')
Cypress bundles Chai assertion capabilities, so you can mix readable built-in assertion strings with callback-style expect() assertions when a scenario needs more control.
Understand Cypress retry boundaries
Cypress retries queries and assertions; commands that change application state generally execute once. This distinction helps avoid flakiness.
| Operation | Typical behaviour | Example |
|---|---|---|
| Query | Retried as part of a query chain. | cy.get(), .find(), .contains() |
| Assertion | Retried until passing or timing out. | .should(), .and() |
| Action | Executed once after actionability checks succeed. | .click(), .type() |
| One-time callback | Runs once with the yielded subject. | .then() |
Prefer state conditions over fixed waits
// Avoid
cy.wait(3000)
cy.get('[data-cy="list"]').should('not.exist')
// Prefer waiting for the application event that matters
cy.intercept('GET', '/api/lists*').as('getLists')
cy.visit('/board/1')
cy.wait('@getLists')
cy.get('[data-cy="list"]').should('not.exist')
Fixed waits are occasionally useful while debugging, but they are a weak synchronisation strategy because they are either longer than necessary or still too short under slower conditions.
Intercept browser API requests
cy.intercept() lets Cypress spy on or modify HTTP traffic generated by the application in the browser. It is one of the strongest tools for synchronisation, behavioural assertions and edge-case testing.
Wait for a request instead of waiting for time
cy.intercept(
'GET',
'/api/lists?boardId=1'
).as('getLists')
cy.visit('/board/1')
cy.wait('@getLists')
.its('response.statusCode')
.should('eq', 200)
Register the intercept before the request can occur
If page load triggers the request, define the intercept before cy.visit(). If a button click triggers it, define the intercept before .click().
cy.intercept({
method: 'DELETE',
url: '/api/lists/*',
}).as('deleteList')
cy.contains('Delete list').click()
cy.wait('@deleteList')
.its('response.statusCode')
.should('eq', 200)
Match dynamic URLs with glob patterns
cy.intercept('DELETE', '/api/lists/*')
cy.intercept('GET', '/api/boards/**')
cy.intercept({ method: 'GET', pathname: '/api/cards' })
Use the smallest matcher that expresses the intent. Overly broad intercepts can accidentally capture unrelated traffic and make tests difficult to diagnose.
Assert on intercepted request and response data
cy.wait('@alias') yields an interception object containing request and response information.
cy.intercept('POST', '/api/boards').as('createBoard')
cy.get('[data-cy="new-board-input"]')
.type('Release testing{enter}')
cy.wait('@createBoard')
.then(({ request, response }) => {
expect(request.body.name).to.eq('Release testing')
expect(response.statusCode).to.eq(201)
expect(response.body).to.have.property('id')
})
This is especially useful when the UI only proves part of a business interaction. The browser can confirm the user-visible result while the intercepted request verifies the frontend sent the correct payload.
Mock and stub network responses
Interception becomes even more powerful when Cypress provides the response instead of the real backend. This is commonly called stubbing or network mocking.
Simulate an empty state
cy.intercept('GET', '/api/boards', {
statusCode: 200,
body: [],
})
cy.visit('/')
cy.contains('Create your first board')
.should('be.visible')
Provide controlled application data
cy.intercept('GET', '/api/boards', {
statusCode: 200,
body: [
{
id: 11,
name: 'TAU board',
starred: true,
},
],
})
Return data from a fixture
cy.intercept('GET', '/api/boards', {
fixture: 'boards.json',
})
Simulate a server error
cy.intercept('POST', '/api/boards', {
statusCode: 500,
body: {
message: 'Unexpected server error',
},
})
cy.visit('/')
cy.get('[data-cy="new-board-input"]')
.type('My board{enter}')
cy.get('[data-cy="error-message"]')
.should('be.visible')
.and('contain.text', 'server error')
Failure states are often difficult or dangerous to reproduce against a real backend. Stubbing makes them deterministic and easy to exercise.
When mocking helps — and when it does not
| Scenario | Mocking useful? | Why |
|---|---|---|
| Empty-state UI | Yes | Easy to control without deleting shared data. |
| 500 / 503 response handling | Yes | Deterministic failure without damaging backend services. |
| Slow-response loading state | Yes | Allows controlled delay and loading verification. |
| Rare edge-case payload | Yes | Creates data that may be hard to produce through normal workflows. |
| True frontend-backend integration | No, not exclusively | A mocked response cannot prove the real API contract still works. |
| Production API compatibility | No | Requires integration, contract or environment-level testing against the real service. |
Keep a clear distinction between frontend behaviour with controlled data and end-to-end integration with real services. Both are useful, but they provide different evidence.
API testing with cy.request()
cy.request() sends an HTTP request directly from Cypress' Node process. It can be used for API tests, health checks, authentication and fast test-data setup.
Create data directly
cy.request({
method: 'POST',
url: '/api/boards',
body: {
name: 'Created by API',
},
})
.its('status')
.should('eq', 201)
Make richer API assertions
cy.request({
method: 'GET',
url: '/api/boards',
headers: {
Accept: 'application/json',
},
}).then((response) => {
expect(response.status).to.eq(200)
expect(response.body).to.be.an('array')
expect(response.body[0].id).to.be.a('number')
})
Test expected error responses
cy.request() fails automatically on non-2xx/3xx status codes by default. For a negative API test, disable that behaviour explicitly.
cy.request({
method: 'GET',
url: '/api/boards/not-found',
failOnStatusCode: false,
}).then((response) => {
expect(response.status).to.eq(404)
expect(response.body).to.have.property('message')
})
cy.request() and cy.intercept() are different tools
This distinction is easy to miss:
cy.intercept()observes or stubs requests that the application makes from the browser.cy.request()sends a request from the Cypress Node process.- A
cy.request()call therefore does not pass throughcy.intercept(). cy.request()calls also do not appear in the browser DevTools Network tab.
cy.intercept(). “Does this API endpoint return the expected result?” → cy.request().Use APIs for test setup
One of the best uses of direct requests in end-to-end testing is preparing state without navigating through unrelated UI flows.
Slower UI setup
cy.visit('/')
cy.contains('Create board').click()
cy.get('[data-cy="board-name"]').type('Regression')
cy.contains('Save').click()
cy.contains('Regression').click()
// Now the actual test can begin...
Faster API setup
beforeEach(() => {
cy.request('POST', '/api/test/reset')
cy.request('POST', '/api/boards', {
name: 'Regression',
})
})
it('adds a card to an existing board', () => {
cy.visit('/board/1')
// Test only the behaviour that matters here.
})
This keeps the UI test focused on the UI behaviour under test and reduces execution time.
it() block creates data that a later test requires. Each test should normally be runnable independently.Custom commands
Custom commands are useful when the suite contains a repeated Cypress workflow that deserves a clear domain-oriented name.
A simple selector helper
Cypress.Commands.add('getByCy', (value) => {
return cy.get(`[data-cy="${value}"]`)
})
// Usage
cy.getByCy('add-list-input')
.type('Regression{enter}')
A workflow-oriented command
Cypress.Commands.add('createBoardByApi', (name) => {
return cy.request('POST', '/api/boards', { name })
})
// Usage
cy.createBoardByApi('Release validation')
A custom login command
Cypress.Commands.add('login', (email, password) => {
cy.session([email], () => {
cy.request('POST', '/api/login', {
email,
password,
}).then(({ body }) => {
window.localStorage.setItem('authToken', body.token)
})
})
})
Prefer commands that express a reusable testing capability. Avoid wrapping every native Cypress command simply to make the framework look different.
Add useful command logging
Custom commands can create their own entries in the Cypress Command Log.
Cypress.Commands.add('getByCy', (value) => {
Cypress.log({
name: 'getByCy',
message: value,
consoleProps: () => ({
selector: `[data-cy="${value}"]`,
}),
})
return cy.get(`[data-cy="${value}"]`, {
log: false,
})
})
Good custom logs improve debugging. They should add useful context rather than simply duplicating built-in logging.
Custom queries: a modern retryable alternative
If a reusable abstraction is fundamentally about finding DOM state and should behave like Cypress' built-in retryable queries, consider a custom query instead of a normal custom command.
Cypress.Commands.addQuery('getByCyQuery', function (value) {
return () => {
return Cypress.$(`[data-cy="${value}"]`)
}
})
Custom queries are synchronous, retryable and expected to be idempotent. They are useful when you need a selector abstraction with native-style retryability.
Move work from the browser to Node with cy.task()
Cypress test code runs in the browser, but some operations belong in Node.js: filesystem access, database scripts, external processes or other trusted test-environment utilities.
cy.task() provides a bridge between the browser-side test and a task registered inside setupNodeEvents().
Register a task
import { defineConfig } from 'cypress'
import { seedDatabase } from './scripts/seed-database'
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
on('task', {
seedDatabase(data) {
return seedDatabase(data)
},
})
return config
},
},
})
Call it from a test
cy.task('seedDatabase', {
boards: [],
cards: [],
lists: [],
users: [],
})
Pass several values inside one object
cy.task('createTestUser', {
email: 'qa@example.com',
role: 'admin',
enabled: true,
})
The argument must be serialisable. If you need multiple logical parameters, package them into one object.
Always return something
on('task', {
log(message) {
console.log(message)
// Explicitly return null if no useful value is needed.
return null
},
})
A task handler must not finish with undefined. Returning null clearly signals successful handling when no result is needed.
Good and bad uses of cy.task()
| Use | Recommendation | Reason |
|---|---|---|
| Seed a dedicated test database | Good | Fast, deterministic environment setup. |
| Read a file that may not exist | Good | Node filesystem access is appropriate. |
| Run an approved test utility | Good | Suitable browser-to-Node boundary. |
| Persist small Node-side state between specs | Sometimes useful | Can help specialised workflows but should remain predictable. |
| Start the application web server | Avoid | Server lifecycle should normally be managed outside the test command. |
| Hide normal application behaviour behind tasks | Avoid | Tests become unrealistic and harder to understand. |
Switch configuration between environments
A Cypress project often runs against local, QA, staging and other environments. Configuration should be easy to switch without editing test files.
Keep a default base URL
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on, config) {
return config
},
},
})
Override configuration from the command line
npx cypress run \
--config baseUrl=https://qa.example.com
Use a simple environment selector
npx cypress run --env target=staging
The resolved value is available through the Cypress configuration/environment mechanisms and can also be inspected in project settings.
Environment-specific configuration files
For a small internal project, separate versioned files can be a readable way to map an environment name to public configuration.
cypress/
└── config/
├── local.json
├── qa.json
└── staging.json
Example qa.json
{
"baseUrl": "https://qa.example.com",
"apiUrl": "https://api.qa.example.com"
}
Resolve the environment in setupNodeEvents()
import fs from 'node:fs'
import path from 'node:path'
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
const target = config.env.target || 'local'
const filePath = path.resolve(
'cypress',
'config',
`${target}.json`
)
const environmentConfig = JSON.parse(
fs.readFileSync(filePath, 'utf8')
)
config.baseUrl = environmentConfig.baseUrl
config.env.apiUrl = environmentConfig.apiUrl
return config
},
},
})
config object from setupNodeEvents() so Cypress receives the changes.Do not store secrets in environment JSON files
Environment selection and secret management are separate concerns. Public hosts, feature settings and test labels may be safe in versioned configuration. Passwords, API keys and tokens should normally come from the operating system or CI secret store.
export default defineConfig({
env: {
apiUrl: 'https://api.qa.example.com',
apiKey: process.env.QA_API_KEY,
},
})
In CI, configure QA_API_KEY as a protected secret rather than committing it to the repository.
Current Cypress configuration note
Modern Cypress supports JavaScript and TypeScript config files in both ESM and CommonJS projects. Match your import/export or require/module.exports syntax to the project's module system rather than mixing styles accidentally.
Authentication: avoid UI login before every test
Logging in through the UI is valuable when the login journey itself is the behaviour under test. It is wasteful when login is only a prerequisite for hundreds of unrelated scenarios.
The repetitive approach
beforeEach(() => {
cy.visit('/login')
cy.get('[data-cy="email"]').type('qa@example.com')
cy.get('[data-cy="password"]').type('secret')
cy.get('[data-cy="login-submit"]').click()
})
it('shows private boards', () => {
cy.visit('/')
...
})
The code may be wrapped in a custom command, but the browser still performs the same expensive login flow before every test.
Cache authentication with cy.session()
cy.session() caches and restores cookies, localStorage and sessionStorage so the suite can reuse a known authentication state.
UI-based session setup
Cypress.Commands.add('login', (email, password) => {
cy.session(
['user', email],
() => {
cy.visit('/login')
cy.get('[data-cy="email"]').type(email)
cy.get('[data-cy="password"]').type(password)
cy.get('[data-cy="login-submit"]').click()
cy.url().should('include', '/dashboard')
}
)
})
Faster API-based setup
Cypress.Commands.add('loginByApi', (email, password) => {
cy.session(
['api-user', email],
() => {
cy.request('POST', '/api/login', {
email,
password,
}).then(({ body }) => {
window.localStorage.setItem(
'authToken',
body.token
)
})
}
)
})
For most tests, API-based session setup is faster and avoids coupling every spec to login-page markup.
Validate cached sessions
A cached token can expire or become invalid. Add a validate() function so Cypress checks the session before reusing it.
Cypress.Commands.add('loginByApi', (email, password) => {
cy.session(
['api-user', email],
() => {
cy.request('POST', '/api/login', {
email,
password,
}).then(({ body }) => {
window.localStorage.setItem(
'authToken',
body.token
)
})
},
{
validate() {
cy.request('/api/me')
.its('status')
.should('eq', 200)
},
cacheAcrossSpecs: true,
}
)
})
What cacheAcrossSpecs really means
- The session can be reused by multiple specs during the same Cypress run.
- The cache exists only for that run on that machine.
- Parallel CI machines do not share the same in-memory session cache.
- All specs should use the same shared session helper rather than duplicating slightly different
cy.session()definitions.
Test isolation and authentication
With normal Cypress test isolation, browser state is cleared between tests. cy.session() works with that model by restoring the required session data when needed.
A clean authenticated test
beforeEach(() => {
cy.loginByApi('qa@example.com', Cypress.env('password'))
cy.visit('/')
})
it('shows the private board', () => {
cy.contains('Private QA board')
.should('be.visible')
})
Do not assume cy.session() leaves the application page loaded for the next test. Visit the page needed by the test after restoring the session.
GitHub Actions: run Cypress in CI
A Cypress suite becomes much more valuable when it runs automatically. GitHub Actions workflows live under .github/workflows/.
Basic current workflow
name: Cypress E2E
on:
push:
pull_request:
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Cypress run
uses: cypress-io/github-action@v7
with:
start: npm start
wait-on: 'http://localhost:3000'
browser: chrome
The official Cypress GitHub Action handles common installation and cache behaviour and can start the application before running Cypress.
Run a focused spec
- name: Cypress smoke tests
uses: cypress-io/github-action@v7
with:
start: npm start
wait-on: 'http://localhost:3000'
spec: cypress/e2e/smoke/**/*.cy.ts
Pass CI secrets safely
- name: Cypress run
uses: cypress-io/github-action@v7
with:
start: npm start
wait-on: 'http://localhost:3000'
env:
CYPRESS_TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
Never place real credentials directly in workflow YAML committed to the repository.
Upload useful failure artifacts
When a CI failure happens, the fastest path to diagnosis is usually a combination of Cypress output, screenshots and application logs.
Upload screenshots after failure
- name: Upload Cypress screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: cypress-screenshots
path: cypress/screenshots
if-no-files-found: ignore
Upload video only if your project records it
- name: Upload Cypress videos
if: always()
uses: actions/upload-artifact@v4
with:
name: cypress-videos
path: cypress/videos
if-no-files-found: ignore
Video recording is not enabled by default in current Cypress, so only upload videos if your project explicitly records them.
CI strategy beyond one job
A larger suite can use different CI layers instead of running every test on every event.
| Pipeline stage | Example Cypress scope | Goal |
|---|---|---|
| Pull request | Fast smoke + changed-area tests | Give developers quick feedback. |
| Merge to main | Full E2E regression | Protect the shared branch. |
| Deployment to QA | Environment smoke + integration | Validate deployed configuration and services. |
| Nightly | Cross-browser / broader matrix | Increase coverage without slowing every commit. |
| Release candidate | Critical business journeys | Provide explicit release confidence. |
Build a balanced network-test strategy
Advanced Cypress gives you several ways to test the same feature. Choose the mechanism according to the evidence you need.
| Question | Best starting tool |
|---|---|
| Did the browser send the correct request? | cy.intercept() + alias + request assertions |
| Does the frontend handle a 500 error? | cy.intercept() with a stubbed 500 response |
| Does the real endpoint return the correct payload? | cy.request() |
| Do I need fast deterministic setup? | cy.request() or cy.task() |
| Does UI + real backend work together? | Normal browser flow without stubbing the relevant endpoint |
| Do I need to simulate an otherwise rare payload? | cy.intercept() fixture or static response |
Advanced data-management patterns
Many flaky Cypress suites are actually data-management problems. Advanced commands are most valuable when they help each test begin from a known state.
Pattern 1: reset through an approved test API
beforeEach(() => {
cy.request('POST', '/api/test/reset')
})
Pattern 2: seed through a Node task
beforeEach(() => {
cy.task('seedDatabase', {
users: [...],
boards: [...],
})
})
Pattern 3: create only the data the test needs
beforeEach(() => {
cy.request('POST', '/api/boards', {
name: 'Board under test',
})
})
Pattern 4: stub only the dependency relevant to the scenario
cy.intercept('GET', '/api/recommendations', {
fixture: 'recommendations/empty.json',
})
Advanced debugging workflow
When an advanced Cypress test fails, debug from the lowest level first rather than immediately adding waits or retries.
- Read the command log. Which command actually failed?
- Check the yielded subject. Did the selector return what you expected?
- Inspect the browser Network panel. Did the frontend call the expected endpoint?
- Check intercept registration timing. Was the intercept defined before the request happened?
- Inspect the alias result. Request body, response status and response body often reveal the issue.
- Check retryability. Is an assertion hidden inside a non-retrying
.then()? - Check state setup. Did a prior test or stale database record affect the scenario?
- Check session validation. Is the cached authentication state still valid?
- Check environment resolution. Is the suite using the intended base URL and credentials?
- Only then adjust timeouts. A larger timeout should solve a known latency requirement, not hide uncertainty.
Common advanced Cypress mistakes
| Mistake | Why it hurts | Better approach |
|---|---|---|
Assertions in .then() for dynamic DOM state | No automatic assertion retry. | Use .should(callback). |
cy.wait(5000) everywhere | Slow and still race-prone. | Wait on network calls or observable application state. |
Defining cy.intercept() after the action | The request may already have happened. | Register intercept first. |
Assuming cy.intercept() catches cy.request() | They run through different paths. | Use each tool for its intended responsibility. |
| Mocking every backend request | UI tests no longer prove real integration. | Keep a mix of mocked and real integration coverage. |
| Creating custom commands for every selector | Adds unnecessary abstraction. | Use native Cypress queries unless the abstraction adds value. |
| Using a normal custom command where query retryability is required | Custom DOM lookup may not behave like a native query. | Consider a custom query. |
Using cy.task() as a generic escape hatch | Tests become opaque and unrealistic. | Reserve tasks for Node-side concerns. |
| Committing secrets in config files | Security exposure. | Inject secrets through CI/OS environment variables. |
| UI login before every test | Large execution penalty. | Use API login + cy.session(). |
Sharing test state between it() blocks | Tests become order-dependent. | Make each test self-contained. |
| Running only mocked tests in CI | Backend integration regressions escape. | Retain real service coverage for critical flows. |
A practical advanced Cypress architecture
cypress/
├── e2e/
│ ├── smoke/
│ ├── regression/
│ ├── api/
│ └── network/
├── fixtures/
│ ├── boards/
│ └── errors/
├── support/
│ ├── commands.ts
│ └── e2e.ts
└── config/
├── local.json
├── qa.json
└── staging.json
scripts/
├── seed-database.ts
└── cleanup-test-data.ts
.github/
└── workflows/
└── cypress.yml
cypress.config.ts
The exact structure is less important than keeping responsibilities obvious: specs describe behaviour, fixtures hold controlled static data, support files contain shared Cypress capabilities and Node-side scripts stay separate from browser test logic.
Example: a robust board-creation test
The following example combines several advanced concepts without making the test unnecessarily complicated.
describe('Board creation', () => {
beforeEach(() => {
cy.task('seedDatabase', {
users: [
{
id: 1,
email: 'qa@example.com',
},
],
boards: [],
})
cy.loginByApi('qa@example.com', Cypress.env('password'))
})
it('creates a board and sends the correct request', () => {
cy.intercept('POST', '/api/boards')
.as('createBoard')
cy.visit('/')
cy.getByCy('new-board-input')
.type('Release validation{enter}')
cy.wait('@createBoard')
.should(({ request, response }) => {
expect(request.body.name)
.to.eq('Release validation')
expect(response.statusCode)
.to.eq(201)
})
cy.contains('Release validation')
.should('be.visible')
})
})
What this test proves
- The test begins with controlled data.
- Authentication does not waste time on repeated UI login.
- The intercept exists before the browser triggers the request.
- The request payload is correct.
- The backend accepted the request.
- The final user-visible board appears.
That is useful integration coverage because each assertion contributes different evidence. If the scenario were only about the frontend error component, stubbing the backend would be more appropriate.
Advanced Cypress checklist
- Dynamic assertions use retryable Cypress patterns where appropriate.
.then()is not being used as a substitute for retryable.should()assertions.- Hard-coded waits are rare and justified.
- Network intercepts are registered before the request can happen.
- Intercept aliases have clear names describing the application event.
- Network assertions focus on behaviour that matters rather than implementation trivia.
- Mocked responses cover important empty, error and edge states.
- Critical frontend-backend flows also have non-mocked integration coverage.
cy.request()is used for direct API validation and efficient setup where appropriate.- The team understands that
cy.request()is not intercepted bycy.intercept(). - Tests do not depend on state created by previous tests.
- Custom commands add meaningful domain-level reuse.
- DOM abstractions that need retryability consider the custom-query API.
cy.task()is limited to legitimate Node-side operations.- Task arguments are serialisable and handlers return a value or
null. - Environment hosts are configurable without editing specs.
- Secrets come from protected environment or CI configuration.
- Authentication setup is cached with
cy.session()when login itself is not under test. - Session validation detects expired or invalid authentication.
- Cross-spec session caching is used only with a shared consistent session helper.
- CI uses the supported Cypress GitHub Action version.
- Failure artifacts are preserved where they help diagnosis.
- PR jobs remain fast enough to provide useful developer feedback.
- Broader regression and cross-browser execution happen at appropriate pipeline stages.
Advanced Cypress cheat sheet
| Goal | Example |
|---|---|
| Retry multiple assertions | .should(($el) => { expect(...) }) |
| Use yielded value once | .then((value) => { ... }) |
| Spy on browser request | cy.intercept('GET', '/api/items') |
| Alias request | .as('getItems') |
| Wait for request | cy.wait('@getItems') |
| Assert response status | .its('response.statusCode').should('eq', 200) |
| Stub empty response | cy.intercept('GET', '/api/items', []) |
| Stub fixture | { fixture: 'items.json' } |
| Stub error | { statusCode: 500 } |
| Direct HTTP request | cy.request('GET', '/api/items') |
| Test error status | failOnStatusCode: false |
| Add custom command | Cypress.Commands.add('name', fn) |
| Add retryable custom query | Cypress.Commands.addQuery('name', fn) |
| Run Node-side task | cy.task('seedDatabase', data) |
| Register task | on('task', { seedDatabase }) |
| Modify runtime config | setupNodeEvents(on, config) { ... return config } |
| Override base URL | --config baseUrl=https://qa.example.com |
| Pass environment selector | --env target=qa |
| Cache login session | cy.session(id, setup, options) |
| Share session across specs | cacheAcrossSpecs: true |
| Run headless | npx cypress run |
| GitHub Actions | cypress-io/github-action@v7 |
Key takeaways
- Understanding Cypress retryability is more valuable than adding arbitrary waits.
.should(callback)is retryable; assertions inside.then()execute once.cy.intercept()is the main tool for observing and controlling browser network traffic.- Network mocking makes frontend edge cases deterministic but does not replace real integration coverage.
cy.request()is excellent for API testing and fast test-data preparation.cy.request()runs from Node and does not pass throughcy.intercept().- Custom commands should simplify meaningful repeated workflows, not hide every native Cypress command.
- Custom queries are useful when a reusable DOM lookup needs Cypress-style retryability.
cy.task()bridges Cypress tests to trusted Node-side utilities.- Environment configuration should be flexible while credentials stay outside source control.
cy.session()can remove enormous repeated-login overhead in large suites.- A CI pipeline should preserve fast feedback while retaining enough real integration and browser coverage to detect meaningful regressions.
Useful links
- Cypress Retry-ability ↗ — queries, assertions, retry boundaries and timeouts.
- cy.then() ↗ — working with yielded subjects and the difference from retryable assertions.
- cy.intercept() ↗ — spying, stubbing and modifying application requests.
- cy.request() ↗ — direct HTTP requests and API testing.
- Custom Commands ↗ — adding reusable Cypress commands.
- Custom Queries ↗ — creating retryable application queries.
- cy.task() ↗ — executing Node-side task handlers.
- Node Events ↗ — using
setupNodeEvents()and Node event hooks. - Cypress Configuration ↗ — configuration files, runtime overrides and environment handling.
- Environment Variables & Secrets ↗ — configuration and secret-management options.
- cy.session() ↗ — caching and validating browser authentication state.
- Cypress GitHub Action ↗ — official GitHub Actions integration.