Conditional Testing in Cypress: Best Practices
Learn when Cypress conditional tests are reliable, how to control the state behind a branch, and how to handle optional steps, skips, and failures.
Conditional testing in Cypress is reliable only when the state that selects a branch is already known and will not change while the test runs. Prefer controlling that state before visiting the page, or reading it from a stable source such as a server response, session cookie, or guaranteed DOM attribute. A one-time DOM check is safe only after rendering has settled and cannot change.
This guide covers deterministic branching, conditional element and text checks, early exits, skips, failed commands, and practical debugging. See the official Cypress conditional testing guide alongside these examples.
1. The core rule: branch on state you can trust
Conditional testing means choosing one action when condition X is true and another when it is false. The challenge is not JavaScript’s if statement; it is whether the observed condition remains true while the test proceeds.
A page can continue changing after its load event. Client-side rendering, network responses, timers, intervals, messages, and other asynchronous work can add, remove, or update elements. A DOM snapshot taken once may therefore lead to different branches on different runs. Cypress’s rule is that a DOM-based branch is safe only when the application state has settled and cannot change. Server-rendered pages with no later DOM updates may satisfy that condition; most client-rendered pages do not satisfy it merely because the page loaded.
A useful decision checklist:
- Can the test choose the scenario before visiting the page?
- Is there a stable source of truth for the state, such as a server endpoint or session cookie?
- Could asynchronous work still change the DOM after the check?
- Should the alternate outcome pass, fail, skip, or simply omit optional commands?
If you cannot know the state accurately, changing the JavaScript branching pattern will not make the test deterministic. Cypress summarizes this point in its conditional testing guide.
2. Prefer setting the scenario before the page loads
The most reliable branch is often no branch at all. If a test can ask the application to use a specific state, write separate tests for each expected scenario. Cypress’s campaign example uses a query parameter to request a specific A/B variant rather than discovering a random assignment and adapting assertions after the fact.
describe('campaign landing page', () => {
it('shows campaign A', () => {
cy.visit('/?campaign=A');
cy.get('[data-cy=campaign-a]').should('be.visible');
});
it('shows campaign B', () => {
cy.visit('/?campaign=B');
cy.get('[data-cy=campaign-b]').should('be.visible');
});
});
The query parameter must be an application-supported test mechanism. Do not assume adding an arbitrary parameter will change production behavior. Other options are a test fixture, server-controlled scenario, or a test-only setup endpoint. Keep each test independent and control its starting state; see Cypress guidance on test isolation.
3. Read a stable source of truth for assigned state
Sometimes the application assigns a campaign or feature after the test begins and the purpose is to verify whichever assignment occurred. In that case, read the assignment from a contract that identifies the underlying state, rather than infer it from a transient rendering.
Read assignment from an application endpoint
For example, if the app exposes a test-readable endpoint that returns the assigned campaign, use that value to select the expected UI. Adjust the endpoint and response shape to your application.
it('renders the campaign assigned to this session', () => {
cy.request('/api/test/current-campaign').then(({ body }) => {
expect(['A', 'B']).to.include(body.campaign);
cy.visit('/');
cy.get(`[data-cy=campaign-${body.campaign.toLowerCase()}]`)
.should('be.visible');
});
});
This is a runnable Cypress pattern when the indicated endpoint exists and returns the shown JSON. The application should define the endpoint’s behavior and ensure it describes the same session that the page uses.
Read a session cookie
If the server records the assignment in a cookie, Cypress can read it and branch on its value. Replace the cookie name and values with your application’s contract.
it('checks the server-assigned campaign', () => {
cy.getCookie('campaign').then((cookie) => {
expect(cookie, 'campaign cookie').to.exist;
const campaign = cookie.value;
expect(['A', 'B']).to.include(campaign);
cy.visit('/');
cy.get(`[data-cy=campaign-${campaign.toLowerCase()}]`)
.should('be.visible');
});
});
Do not branch on a cookie that may not yet have been established. Arrange the session first, or use the application’s supported session setup flow.
Use a guaranteed DOM contract only when appropriate
An always-present attribute can expose the assigned value, for example <main data-campaign="A">. This works only if the attribute is guaranteed to exist and accurately reflects the state on every run. A marker that appears asynchronously has the same timing problem as the content it is intended to explain.
4. Check whether an element exists only for settled, synchronous state
Cypress documents a narrow case where a synchronous DOM query inside .then() is appropriate: a synchronous action immediately appends one of two elements. Since the action finishes the relevant DOM change before the callback queries the body, the callback can choose a selector.
it('uses the editor control appended by the synchronous action', () => {
cy.get('[data-cy=choose-editor]').click();
cy.get('body').then(($body) => {
if ($body.find('[data-cy=editor-input]').length) {
cy.get('[data-cy=editor-input]').type('Draft text');
} else if ($body.find('[data-cy=editor-textarea]').length) {
cy.get('[data-cy=editor-textarea]').type('Draft text');
} else {
throw new Error('The editor control was not added');
}
});
});
This example assumes the click synchronously inserts one control. It is not a general recipe for asynchronous rendering. The .then() callback runs once; it does not retry its synchronous jQuery query until an element appears. When an element is expected asynchronously, query the expected element with Cypress’s retryable assertions instead:
it('waits for the asynchronously rendered editor', () => {
cy.get('[data-cy=open-editor]').click();
cy.get('[data-cy=editor-input]').should('be.visible').type('Draft text');
});
If either of two asynchronous controls may appear, expose which outcome was selected through a stable application contract and assert that outcome. Do not sample the body once and mistake absence at that instant for proof that the other outcome is final.
5. Branch on text only when its source is stable
Checking whether body text includes a phrase has the same timing constraint as checking element existence. A one-time text read is only sound after the page is known to be settled and unable to change. If the text comes from an async request, control the response or read the underlying state through a stable interface.
For a deterministic page where the text is already present and cannot change, a synchronous check can be written as:
it('chooses an action from settled page text', () => {
cy.get('body').then(($body) => {
const text = $body.text();
if (text.includes('Account active')) {
cy.get('[data-cy=continue]').click();
} else {
cy.get('[data-cy=activate-account]').click();
}
});
});
When the app fetches account status asynchronously, use a controlled test account or response and assert the expected text with Cypress retryability. A fixed sleep is not evidence that every pending update has finished.
6. Handle optional work and early exits correctly
Cypress tests end as passed, failed, or pending/skipped; there is no separate “passed, but stopped early” result. To omit optional commands, put them inside the callback branch so Cypress never enqueues them when the condition says they are unnecessary.
it('completes optional onboarding when it is present', () => {
cy.get('[data-cy=onboarding-state]').then(($state) => {
if ($state.attr('data-required') === 'true') {
cy.get('[data-cy=onboarding-next]').click();
cy.get('[data-cy=onboarding-finish]').click();
}
});
cy.get('[data-cy=dashboard]').should('be.visible');
});
The marker in this example must be available and stable when queried. If whether onboarding is required is decided asynchronously, arrange a known account state or obtain the state from the server first. Returning from a .then() callback does not cancel commands that were already queued elsewhere; keep branch-dependent commands inside the branch.
Skip is different from a successful early exit
Use Mocha’s runtime this.skip() only when the correct result is genuinely “skipped.” The test callback must be a regular function so Mocha binds this; an arrow function has no Mocha-bound this.
it('runs only when the feature is enabled', function () {
cy.get('[data-cy=feature-state]').then(($state) => {
if ($state.attr('data-enabled') !== 'true') {
this.skip();
}
});
cy.get('[data-cy=feature-panel]').should('be.visible');
});
Use a skip for a condition that makes the test inapplicable, not to hide an unexpected missing element. Throwing an error fails the test; use that when the observed state violates a required contract.
7. Why failed commands cannot be used as fallback branches
Cypress commands are queued for later execution; they are not ordinary Promises that can be awaited and recovered with a conventional .catch(). A failed Cypress command stops subsequent test commands and fails the test. Cypress explicitly rejects patterns that try to attach a normal catch handler to a failed command and then issue an alternate query.
// Do not use this pattern:
cy.get('[data-cy=maybe-present]').then(/* ... */).catch(() => {
cy.get('[data-cy=fallback]').click();
});
Decide from controlled state or a stable source before enqueueing dependent commands. If the element is required, let Cypress retry the query and fail with a useful assertion when it does not appear.
8. Choose a strategy by determinism and failure meaning
| Strategy | Best fit | Main condition | Typical outcome |
|---|---|---|---|
| Set a query parameter or fixture | Testing known variants | The app supports the scenario input | Separate, deterministic assertions |
| Read server or session state | Verifying an assigned variant | The source describes the same session as the page | Branch on a stable contract |
| Read an always-present DOM marker | State deliberately exposed to tests | Marker is guaranteed and cannot become stale | Branch on explicit page metadata |
| Synchronously inspect the DOM | A synchronous action adds one of known elements | No later async update can change the result | Choose the matching control |
| Retry an expected query | One specific element should appear | Presence is an assertion, not a choice between states | Wait up to configured timeout, then pass or fail |
| Runtime skip | The test does not apply in this environment | Skip is an honest result for the test suite | Pending/skipped |
For maintainable selectors, Cypress recommends using data-* attributes rather than selectors coupled to CSS styling or implementation details. Keep tests isolated so prior test state does not silently influence the branch. See the official best practices and test isolation guides.
9. Troubleshooting common conditional test failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Test takes the wrong branch intermittently | The DOM changes after the one-time check, often due to async rendering or a response. | Set the scenario before visiting, or branch on a stable server/session contract. |
| Fallback runs even though the element appears moments later | A synchronous query observed the DOM before async rendering completed. | Do not treat immediate absence as a final state. Control the state or assert the expected element with a retryable Cypress query. |
.catch is not a function or fallback never runs |
A Cypress command chain is being treated like a native Promise. | Remove the catch fallback. Cypress command failures fail the test; choose the path before dependent commands are queued. |
| A fixed wait still flakes | The delay does not prove that all network, timer, or application work has settled. | Wait for a meaningful application signal or control the response/state. Avoid arbitrary sleeps as a synchronization strategy. |
Runtime skip errors because this is undefined |
The test uses an arrow function, which does not receive Mocha’s callback context. | Declare the test callback as function () {} when calling this.skip(). |
| Test passes without exercising either expected path | Optional work was silently omitted or the condition was too broad. | Assert the state contract and make the intended pass, failure, or skip explicit. |
| Selector breaks after a visual redesign | The test relies on CSS classes or structural details. | Use stable data-* selectors and keep the selector contract intentional. |
10. Performance, reliability, and maintenance
Deterministic setup usually makes a suite easier to reason about because each test knows which path it will exercise. A branch based on an incidental page snapshot can produce intermittent outcomes and make failures harder to reproduce. Avoid adding fixed delays to compensate: they add waiting time while still failing to establish that the state cannot change.
Keep branch logic small and keep the source of truth explicit. If the application cannot expose or accept the state tests need, consider a testability change such as a supported scenario parameter or a server/session contract. Isolate tests and use selectors intended for testing. Cypress’s command model documentation explains why commands do not behave like immediately executed Promise calls.
11. Or skip the browser setup
If your next step is capturing a page for a visual record, audit, or AI workflow rather than exercising interactive test behavior, ScreenshotNeo can return a screenshot or PDF with one GET request. It does not replace Cypress for assertions or application interaction; it handles page capture.
See the ScreenshotNeo API documentation. cURL:
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Python:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
- Cookie banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Responses include page verdict and billing headers.
- An MCP server lets AI agents use
take_screenshot,get_page_info, andcapture_pdf. - 1,000 screenshots a month are free with no card; paid plans start at $5 for 3,000. Every feature is on every plan.
Create a free ScreenshotNeo account to get 1,000 screenshots a month with no card.
12. FAQ
Can I use if inside a Cypress test?
Yes, when the condition is known and stable at the point where the branch runs. JavaScript syntax does not solve uncertainty about when the app state changes.
Does .then() wait for an element to appear?
No. The callback runs after the preceding Cypress commands, but a synchronous DOM query inside it is a one-time observation. Use Cypress queries and assertions for expected asynchronous elements.
Should I skip a test when a feature is disabled?
Only if the test is genuinely inapplicable and a skipped result communicates that accurately. If the feature is expected, assert it and let a mismatch fail.
Can Cypress recover from every failed command?
No. A failed command normally ends the test. Design the branch before issuing commands that depend on one outcome.


