1) What a stable E2E test should prove
End-to-end (E2E) UI tests for web apps validate that a real user can complete critical flows in a real browser, with the app running as it would in production-like conditions. The scope is intentionally user-visible: what the user can do and what the user can observe.
What Cypress is actually verifying
- User-visible behavior: pages render, navigation works, forms accept input, validation messages appear, and success states are shown.
- Critical flows: the smallest set of journeys that keep the business working (for example: sign in, search, add to cart, checkout, submit a contact form).
- Integration across layers: UI + routing + API calls + state management, as experienced through the browser.
What “stable” means in practice
A stable E2E test proves outcomes, not mechanics. It should answer: “If a user performs these actions, do they get the expected result?” It should avoid depending on fragile details like DOM structure, CSS classes used for layout, or internal framework state.
- Prefer asserting outcomes: URL changes, visible text, enabled/disabled state, presence of a confirmation message, item count updated.
- Prefer interacting like a user: click buttons/links, type into inputs, select options.
- Keep the scope tight: one test should validate one meaningful behavior; multiple behaviors should be split into separate tests.
2) Guided walkthrough of the Cypress runner UI and test output
Cypress runs your tests in a dedicated test runner that controls a real browser. Understanding what you see in the runner helps you debug failures and write more reliable assertions.
How Cypress executes a spec (practical model)
- Spec files: your test files (for example,
cypress/e2e/login.cy.js) containdescribe()/it()blocks and Cypress commands likecy.visit(). - Test runner: the UI that lists specs, runs them, and shows command logs and snapshots.
- Browser context: tests execute inside a real browser (Chromium/Electron/Chrome/Firefox depending on your setup). The app is loaded as a user would load it, but Cypress injects hooks to observe and control the page.
- Command queue: Cypress commands (anything starting with
cy.) are enqueued and executed in order. They are not executed immediately when the line is reached; Cypress schedules them and runs them deterministically. - Assertions and retry-ability: assertions like
should()automatically retry for a period of time until they pass or time out. This is key to stability: you typically do not need manual waits when you assert the right thing.
What you see in the runner UI
- Spec list: pick a spec to run. This is useful for running one flow while developing.
- Test results panel: shows each
describe/itand whether it passed/failed. - Command log: a step-by-step list of Cypress commands (visit, get, click, type, should). Clicking a command shows details (selector used, yielded element, assertion result).
- Snapshots/time travel: Cypress captures DOM snapshots at each command. When you click a command in the log, the app preview rewinds to that moment so you can see the state that produced the failure.
- Errors with context: failures usually show the assertion that failed, what was expected vs what was found, and often the element Cypress was acting on.
How to read the output when a test fails (step-by-step)
- Step 1: Identify which
it()failed and read the failure message (expected vs actual). - Step 2: In the command log, click the last successful command, then the failing command, and compare the snapshots.
- Step 3: Check whether the failure is about timing (element not yet visible) or about correctness (wrong text, wrong navigation).
- Step 4: If timing-related, prefer adding a better assertion (for example,
should('be.visible')on the element that indicates readiness) rather than adding arbitrary waits.
3) A minimal example test (visit, interact, assert)
The smallest useful E2E test has three parts: navigate to a page, perform a user action, and assert a user-visible outcome.
Example: login flow with a visible success indicator
describe('Login', () => { it('lets a user sign in and see their dashboard', () => { cy.visit('/login'); cy.get('[data-cy=email]').type('user@example.com'); cy.get('[data-cy=password]').type('correct-horse-battery-staple'); cy.get('[data-cy=submit]').click(); cy.url().should('include', '/dashboard'); cy.get('[data-cy=welcome]').should('contain.text', 'Welcome'); });});What each line is proving
cy.visit('/login'): the login page can load.cy.get(...).type(...): the user can interact with the form controls.click(): the user can submit the form.cy.url().should('include', '/dashboard'): the app navigates to the expected destination (a user-visible outcome).should('contain.text', 'Welcome'): the destination page shows a meaningful indicator that the user is signed in.
Why this stays stable
- It asserts on outcomes (URL + visible welcome area) rather than internal state.
- It uses a dedicated, test-friendly selector strategy (
data-cy) instead of brittle CSS or DOM traversal. - It relies on Cypress’ built-in retry behavior:
should()will wait for the URL and the welcome element to match expectations.
4) Common misconceptions (testing implementation details, over-asserting)
Misconception: “E2E tests should verify everything on the page”
E2E tests are expensive compared to unit/component tests. Their job is to confirm that key user journeys work. If you assert every label, every CSS class, and every pixel-level detail, you create brittle tests that fail for harmless UI changes.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
- Prefer: one or two assertions that prove the flow succeeded.
- Avoid: asserting every field placeholder, every icon, or exact full-page text dumps.
Misconception: “If it’s in the DOM, it’s fair game to assert”
Many DOM details are not user-visible or are incidental to implementation (wrapper divs, framework-generated attributes, layout classes). Asserting on them couples your test to the current markup structure.
- Prefer: visible text, ARIA roles/labels where appropriate, and stable test IDs.
- Avoid: selectors like
div > :nth-child(2) > .btn.primaryor framework-specific attributes that may change.
Misconception: “Cypress commands run immediately like normal JavaScript”
Cypress commands are queued and executed later. This affects how you structure tests and why Cypress can provide time-travel debugging. It also explains why mixing synchronous code with Cypress commands can be confusing if you expect immediate values.
- Prefer: chaining Cypress commands and assertions (for example,
cy.get(...).should(...)). - Avoid: trying to store values from Cypress commands in plain variables and using them synchronously without Cypress’ chaining mechanisms.
Misconception: “Adding waits makes tests stable”
Hard waits (for example, waiting 2 seconds) often make tests slower and still flaky across machines and network conditions. Cypress is designed around retrying commands and assertions until the UI reaches the expected state.
- Prefer: waiting on a meaningful condition via assertions (element visible, request finished, URL updated).
- Avoid: arbitrary sleeps to “let the page load.”
Checklist: what to validate vs what to avoid
Validate (good E2E scope)
- Critical user journeys complete end-to-end (navigation, form submission, confirmation).
- Key UI states are visible: success messages, error messages, empty states, loading-to-ready transitions.
- Routing outcomes: URL changes, protected routes require auth, redirects happen as expected.
- Data-impacting actions show the expected result in the UI (item added, profile updated, order created).
- Accessibility-relevant behavior at a basic level: interactive elements are reachable and produce visible outcomes (when your app uses accessible labels/roles).
Avoid (common sources of flakiness and low value)
- Asserting on internal implementation details (framework state, private APIs, component internals).
- Brittle selectors (deep CSS paths,
:nth-child, layout classes). - Over-asserting cosmetic details (exact copy everywhere, pixel-perfect layout, non-critical icons).
- Hard waits instead of condition-based assertions.
- Combining too many behaviors into one long test that is hard to diagnose when it fails.