AI Computer Institute
Expert-curated CS & AI curriculum aligned to CBSE standards. A bharath.ai initiative. About Us

End-to-End Testing with Cypress

📚 Testing⏱️ 22 min read🎓 Grade 9
✍️ AI Computer Institute Editorial Team Updated: August 2026 CBSE-aligned · Peer-reviewed · 22 min read
Content curated by subject matter experts with IIT/NIT backgrounds. All chapters are fact-checked against official CBSE/NCERT syllabi.

Suppose you and three classmates build Rail Sahayak, a practice train-ticket booking site for a school Informatics Practices project, modelled loosely on IRCTC. You split the work sensibly. One of you writes the login page and a test for it: type a valid email and password, click "Sign In," check that the dashboard loads. It passes. Another writes the train-search page and its test: enter "Delhi" to "Jaipur" on a given date, check that a list of trains appears. It passes. A third writes the payment page and its test: enter a fare amount, click "Pay," check that a confirmation message appears. It also passes. All three tests are green. You proudly tell your teacher the booking flow works.

Then your teacher sits down and actually tries to book a ticket the way a real student would: log in, search a train, pick one, enter passenger details, and pay. Halfway through, the app throws an error — the search page saved the selected train's ID as a number, but the payment page expected it as a text string, and when the mismatch hits, the booking silently loses the selected train and crashes on the payment step. None of the three individual tests caught this, because none of them ever ran the pages in sequence, in one continuous browser session, the way a real user would. Each test only proved that its own page worked in isolation, with hand-fed input. Nobody had tested the actual journey from start to finish.

This gap — between "every piece works when tested alone" and "the whole product actually works when someone uses it" — is precisely what end-to-end (E2E) testing exists to close. And Cypress is the tool this chapter uses to close it in practice.

Three Levels of Testing: Unit, Integration, End-to-End

To see exactly what E2E testing adds, it helps to place it next to the two testing levels that usually come before it.

A unit test checks one small piece of code — a single function, in isolation, with everything else faked or removed. For example, a function calculateFare(distanceKm, classType) that returns a ticket price can be unit-tested by calling it directly with a few numbers and checking the returned value, without ever opening a browser or touching a database. Unit tests are fast — you can run thousands of them in a few seconds — and they pinpoint bugs precisely, because each one exercises only a few lines of code.

An integration test checks that a small group of pieces work correctly together — for instance, that the search component correctly calls the fare-calculation function and passes its result to the display component. It tests a seam between parts, still usually without a real browser or a real user clicking anything.

An end-to-end test checks the entire application the way an actual human would use it: it opens a real (or real-like) browser, loads the real page, clicks real buttons, types into real input boxes, and verifies what actually appears on screen — often against a real or realistic backend. It does not care how the code is organized internally. It only cares whether the visible, usable product behaves correctly across a complete task, from the first click to the last.

These three levels are usually drawn as a pyramid, because of how many tests of each kind a well-tested project typically has, and how each kind behaves.

The Testing Pyramid Unit Tests tests one function, alone Integration Tests tests a few parts together E2E (Cypress) Hundreds of tests, runs in milliseconds Dozens of tests, runs in seconds A handful of tests, runs full journeys, slower but realistic Rail Sahayak's three separate page tests only covered the bottom two layers. The crash your teacher hit could only have been caught by the top layer.

Notice what the pyramid shape is telling you: you want many cheap, fast unit tests catching small bugs early, and only a few expensive, slower E2E tests, reserved for the handful of journeys that matter most — login, search-and-book, payment. A rough rule of thumb some teams use is roughly ten integration tests for every one E2E test, and roughly ten unit tests for every one integration test — not a law, just a reminder that E2E tests are the most realistic layer but also the most expensive one to run, so you don't want your whole test suite made of them.

Meet Cypress

Cypress is a testing tool built specifically for writing and running end-to-end tests for web applications, using JavaScript. What makes it worth learning as your first E2E tool is not just its command syntax — it is where its test code actually runs, which is genuinely different from how older browser-automation tools like Selenium work.

Selenium (and tools built on the WebDriver standard) run your test script as a completely separate program — often written in Java or Python — that talks to the browser over a network protocol called WebDriver. Every single command, even "click this button," is packaged as an HTTP request, sent across that protocol, executed by the browser, and the result sent back. It works, but every command pays a small network round-trip cost, and the test script has no direct view into what the browser is doing — it can only ask and wait for an answer.

Cypress takes a different approach. It loads your application inside the same browser window as its own test runner, in an iframe, and executes your test code in that browser alongside your app — not as a separate remote process. Because the test code and the application share the same browser tab and the same JavaScript run loop, Cypress can directly observe the DOM, intercept network requests, and control timers, without sending anything over a network protocol to get there.

Where the test code actually runs Selenium / WebDriver Test Script (separate process) WebDriver protocol (HTTP, over the network) Browser + Your App Every command crosses a network boundary Cypress one browser window, one run loop Cypress Test Code (runs in-browser) direct DOM access Your Application (loaded in an iframe) Test and app share one JS engine — no network hop

This architecture is also the source of a real limitation you should know about honestly, not just Cypress's strengths: because a Cypress test and the page under test share one browser tab, Cypress has historically struggled with anything that needs more than one origin (domain) or more than one browser tab open at once — for example, a payment flow that redirects to a completely different domain like a bank's OTP page. Newer versions of Cypress added a command, cy.origin(), specifically to handle controlled visits to a second domain within a test, but working across multiple tabs simultaneously is still limited compared to Selenium. That trade-off — speed and direct control in exchange for some architectural restrictions — is worth remembering rather than treating Cypress as a strictly better tool in every situation.

Writing Your First Cypress Test

A Cypress test file is built from three pieces borrowed from a testing framework called Mocha: describe groups related tests under a name, it defines one individual test with a description of what it should do, and inside it, you write a chain of Cypress commands. Here is a complete test for the Rail Sahayak login page:

describe('Login Page', () => {
  it('lets a valid student log in', () => {
    cy.visit('https://railsahayak.example/login')
    cy.get('[data-cy="email-input"]').type('asha@example.com')
    cy.get('[data-cy="password-input"]').type('MySecurePass123')
    cy.get('[data-cy="login-button"]').click()
    cy.url().should('include', '/dashboard')
    cy.contains('Welcome back, Asha').should('be.visible')
  })
})

Read it exactly the way Cypress executes it, one command at a time:

  • cy.visit(...) opens the given URL in Cypress's controlled browser and waits until the page finishes loading before moving on.
  • cy.get('[data-cy="email-input"]') searches the current page's DOM for an element carrying the attribute data-cy="email-input" — you will see shortly why this attribute, rather than a CSS class or an ID, is the recommended way to select elements in Cypress tests.
  • .type('asha@example.com') simulates a real student typing each character into that element, firing the same keyboard events a browser would fire for real keystrokes.
  • The same pattern repeats for the password field, then .click() simulates an actual mouse click on the login button.
  • cy.url().should('include', '/dashboard') reads the browser's current address bar URL and asserts that the string /dashboard appears somewhere in it — proof that the app actually navigated after login, not just that the button was clickable.
  • cy.contains('Welcome back, Asha').should('be.visible') searches the rendered page for that exact text and asserts it is visible on screen, not just present but hidden somewhere in the markup.

Six lines, and this single test has already verified something none of Rail Sahayak's three separate unit tests could: that typing credentials, clicking, and landing on a personalised dashboard all genuinely work together, in one continuous run of the real application.

Misconception: cy.get() Does Not Hand You a DOM Element

Common misconception: because cy.get() reads like a normal JavaScript function call, many beginners assume it works like document.querySelector() — that it searches the page immediately and returns the matching DOM element right there on that line. It does not.

const button = cy.get('[data-cy="login-button"]')
console.log(button.text)   // undefined - button is not a DOM element
button.click()              // this still works, but not for the reason you think

Every Cypress command, including cy.get(), does not execute right away. It gets added to an internal command queue and returns a special chainable object immediately — a kind of promise-like , not the result. Cypress then works through the queue command by command, and only when a command's turn arrives does it actually search the DOM, click the button, or type the text. This is why .click() in the snippet above still works — Cypress recognises it as the next queued command and runs it once cy.get() has resolved — but reading button.text directly, as ordinary JavaScript, gives you undefined, because button was never a real element to begin with.

If you genuinely need to inspect or compute something from an element mid-test, Cypress gives you .then(), which runs its callback only after the preceding command has actually resolved, handing you the real jQuery-wrapped element as an argument:

cy.get('[data-cy="login-button"]').then(($btn) => {
  cy.log($btn.text())
})

Understanding this queue is not a minor technicality — it explains almost every confusing bug beginners hit with Cypress, such as writing an if statement around a Cypress command and having it behave nothing like they expected, because the condition runs before the queued command has actually produced a result.

Assertions That Retry Themselves

Real web pages are rarely instant. Rail Sahayak's dashboard, after login, might make a network call to fetch "Asha" from a database before it can render "Welcome back, Asha" — a delay of, say, 2500 milliseconds (2.5 seconds). A plain JavaScript check written naively, like if (document.body.innerText.includes("Welcome")) run immediately after the click, would run at time 0 milliseconds, find nothing yet, and incorrectly report failure — even though the app is working correctly and the text is only 2.5 seconds away from appearing.

Cypress's .should() assertions are built to avoid exactly this false failure. By default, Cypress retries a failing assertion automatically, re-checking the DOM repeatedly, for up to 4000 milliseconds (4 seconds) before it finally gives up and reports the test as failed. So when cy.contains('Welcome back, Asha').should('be.visible') runs: Cypress checks at roughly 0 ms — not there yet, no failure reported — and keeps re-checking as the page updates, and when the text finally renders at 2500 ms, well inside the 4000 ms budget, the assertion succeeds and the test passes. The arithmetic is simple but the consequence is significant: 2500 is less than 4000, so the real, slightly-delayed behaviour of a real application is correctly recognised as success, rather than being flagged as a bug by an impatient one-shot check.

A Complete Worked Example: A To-Do List

To see these ideas working together across a full feature, here is a test suite for a simple to-do list app — the kind of small project many Grade 9 students build early on:

describe(' List', () => {
  beforeEach(() => {
    cy.visit('/todos')
  })

  it('adds a new task to the list', () => {
    cy.get('[data-cy="new-task-input"]').type('Revise Chapter 7 - Trigonometry{enter}')
    cy.get('[data-cy="task-list"] li').should('have.length', 1)
    cy.contains('li', 'Revise Chapter 7 - Trigonometry').should('exist')
  })

  it('marks a task as complete', () => {
    cy.get('[data-cy="new-task-input"]').type('Submit CS project{enter}')
    cy.contains('li', 'Submit CS project')
      .find('[data-cy="complete-checkbox"]')
      .check()
    cy.contains('li', 'Submit CS project').should('have.class', 'completed')
  })

  it('deletes a task', () => {
    cy.get('[data-cy="new-task-input"]').type('Buy graph notebook{enter}')
    cy.contains('li', 'Buy graph notebook')
      .find('[data-cy="delete-button"]')
      .click()
    cy.get('[data-cy="task-list"] li').should('have.length', 0)
  })
})

Trace the first test carefully. beforeEach runs before every single it block in this file, so each test starts from a fresh visit to /todos — none of the three tests can accidentally see leftover tasks from another test, which matters because E2E tests that quietly depend on each other's leftover state become unreliable and hard to debug. Inside the first test, .type(...) is given the string 'Revise Chapter 7 - Trigonometry{enter}': everything up to the curly braces is typed as literal characters, and {enter} is a special Cypress typing directive that simulates pressing the physical Enter key — the app is presumably wired to submit the new-task form on Enter. The next line asserts the task list now contains exactly one li element — proof the submission actually added an item, not just cleared the input box. The final line searches specifically for an li containing that exact text and asserts it exists.

The second test reuses the same add-task pattern, then demonstrates chaining: cy.contains('li', 'Submit CS project') finds the specific list item containing that text, .find('[data-cy="complete-checkbox"]') narrows the search to the checkbox inside that particular item only (not any other task's checkbox), and .check() simulates ticking it. The assertion afterward re-finds the same list item and checks that the app added a CSS class named completed to it — a reasonable way for a to-do app to visually mark finished tasks, and a behaviour only observable by actually running the full add-then-check sequence in order.

Choosing Selectors That Won't Break

Every example above selects elements using a data-cy attribute rather than a CSS class or an ID. This is a deliberate, well-established Cypress convention, and it solves a real problem: CSS classes and IDs usually exist for styling or JavaScript logic, and both change often as a project evolves — a designer might rename .btn-primary to .btn--main during a redesign that has nothing to do with what the button actually does. If tests are written against those styling classes, an unrelated visual redesign silently breaks the entire test suite.

// Fragile - breaks the moment the styling class changes
cy.get('.btn.btn-primary.mt-3').click()

// Robust - exists only for tests, untouched by redesigns
cy.get('[data-cy="submit-button"]').click()

A data-cy attribute (the name is a convention, not a Cypress requirement — any custom data attribute works) exists in the HTML for one purpose only: to give tests a stable hook. Nobody touches it while restyling a button, so the test keeps working through redesigns that would have broken a class-based selector.

Where This Fits in the CBSE Software Testing Picture

Software engineering distinguishes two questions you can ask about a finished system: verification asks "did we build the product right?" — does each component correctly implement its specification — and validation asks "did we build the right product?" — does the finished system actually do what the user needs when used as a whole. Unit and integration tests lean heavily toward verification: they check that individual pieces of code do what their internal design says they should. End-to-end tests lean toward validation: Cypress does not know or care how Rail Sahayak's code is organised internally; it only checks whether a real login-search-book-pay journey, exactly as a student would run it, produces the correct outcome on screen.

E2E suites are also central to regression testing — re-running an existing test suite after every code change to confirm that new work has not silently broken an old, previously-working journey. This is precisely the discipline that would have caught Rail Sahayak's train-ID mismatch before a teacher ever found it manually: an E2E test covering the full login-to-payment journey, run automatically every time the code changed, would have failed the moment the mismatch was introduced — days or weeks before a human tried the same sequence by hand.

Practice

  1. A team writes unit tests for a search function and a payment function separately, both pass, yet the checkout still crashes for real users. Explain, in your own words, exactly what kind of bug this situation reveals and why unit tests structurally cannot catch it.
  2. Trace this line by line and state what each command does, in order: cy.visit('/cart'); cy.get('[data-cy="qty-input"]').type('3'); cy.get('[data-cy="update-button"]').click(); cy.contains('Total: Rs. 450').should('be.visible');
  3. A classmate writes const price = cy.get('[data-cy="price"]'); if (price.text() > 500) { ... } and it fails immediately with an error about .text being . Diagnose the mistake using the command-queue model, and rewrite it correctly using .then().
  4. A page's confirmation message appears after a 3200 millisecond network delay. Using Cypress's default retry budget, explain, with the actual numbers, why cy.contains('Order Confirmed').should('be.visible') still passes, while a single immediate JavaScript check would not.
  5. Explain why cy.get('[data-cy="submit-button"]') is preferred over cy.get('.btn-submit-primary') in a real project, with a concrete scenario where the second one breaks a test for a reason unrelated to any actual bug.

Summary

End-to-end testing verifies an entire user journey through a real (or realistic) browser, exactly as a genuine user would perform it — distinct from unit tests, which check one function alone, and integration tests, which check a few components together; the testing pyramid captures this as many fast unit tests, fewer integration tests, and a small number of slower but highly realistic E2E tests. Cypress implements E2E testing by running your test code inside the same browser window and JavaScript run loop as the application under test, giving it direct DOM access instead of Selenium's network-based WebDriver protocol — a genuine architectural difference that brings speed and control at the cost of some multi-origin and multi-tab limitations. Cypress commands like cy.get() do not execute immediately or return real DOM elements; they are queued and resolved in order, which is why .then(), not plain JavaScript, is the correct way to inspect a resolved value mid-test. Its assertions retry automatically for a default budget of 4000 milliseconds, correctly tolerating realistic network delays that a single immediate check would wrongly flag as failure. Finally, selecting elements through dedicated data-cy attributes, rather than CSS classes built for styling, keeps a test suite stable through redesigns — one of several habits that separate an E2E suite a team can trust from one that breaks for the wrong reasons.

Think About It

Think about this: How would you explain end-to-end testing with cypress to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.

← Integration Testing: Testing Multiple ComponentsPerformance Profiling: Finding Bottlenecks →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn