In this guide
Your unit tests pass. Your API returns the right JSON. And yet the login button does nothing when clicked, because the frontend sends username while the backend expects email. Unit tests cannot catch this — each side was tested in isolation, and both were "correct."
End-to-end (E2E) tests catch it: they drive a real browser, click real buttons, and verify what a real user would see. Cypress is the most approachable E2E tool for students. This guide shows you how to write tests that actually protect your app.
Where E2E fits in the testing pyramid
| Layer | What it tests | Speed | Example tool |
|---|---|---|---|
| Unit | Single functions | Milliseconds | Jest |
| Integration | Components working together | Seconds | Jest, Supertest |
| E2E | Whole app in a real browser | Tens of seconds | Cypress, Playwright |
The pyramid shape matters: many unit tests, fewer integration tests, few E2E tests. E2E tests are the slowest and most brittle, so you reserve them for critical user journeys — the flows where failure means real damage: signup, login, checkout, submitting the project form.
Note: E2E tests verify behavior through the UI. They are not a substitute for unit tests (too slow, too coarse) — they are the final safety net above them. See Testing JavaScript with Jest for the base of the pyramid.
Your first Cypress test
Install Cypress and open its interactive runner:
npm install --save-dev cypress
npx cypress open
The interactive runner is Cypress's killer feature for learning: you watch the test execute in a real browser, inspect each step, and time-travel through what happened. Write your first spec in cypress/e2e/login.cy.js:
describe('login flow', () => {
it('logs in with valid credentials', () => {
cy.visit('/login');
cy.get('[data-cy=email]').type('student@example.com');
cy.get('[data-cy=password]').type('correct-password');
cy.get('[data-cy=submit]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome back').should('be.visible');
});
it('shows an error with wrong password', () => {
cy.visit('/login');
cy.get('[data-cy=email]').type('student@example.com');
cy.get('[data-cy=password]').type('wrong-password');
cy.get('[data-cy=submit]').click();
cy.contains('Invalid credentials').should('be.visible');
});
});
Read it like a user story: visit, type, click, verify. cy.get() finds elements, .type() and .click() interact, .should() asserts. Cypress automatically retries assertions until they pass or time out — no manual sleep() calls waiting for the page.
Select with data-cy, not CSS classes
Notice [data-cy=email] instead of .login-input or #email-field. Dedicated data-cy attributes are the professional convention:
- CSS classes change during redesigns;
data-cyattributes exist only for tests - Text content changes with copy edits;
data-cystays stable - Tests break only when behavior actually changes — not when a designer renames a class
Add them to the handful of elements your tests touch. It takes seconds and saves hours of brittle-test maintenance.
Controlling the backend: cy.intercept
E2E tests hitting a real backend are slow and flaky. cy.intercept() lets you stub network responses:
it('shows the user dashboard', () => {
cy.intercept('GET', '/api/profile', {
statusCode: 200,
body: { name: 'Test Student', projects: 3 },
}).as('getProfile');
cy.visit('/dashboard');
cy.wait('@getProfile');
cy.contains('Test Student').should('be.visible');
});
This tests your frontend against a known backend response — deterministic and fast. The judgment call: stub for frontend-logic tests; use the real backend (with a seeded test database) for the few critical journeys where you want true end-to-end confidence.
Test data and isolation
E2E tests must not depend on whatever data happens to be in the database:
- Seed before, clean after. A
beforeEachhook that resets the test database (via an API endpoint or direct DB seed) keeps tests independent. - Never test against production data. Use a dedicated test environment with its own database.
- Unique data per run. Timestamps or random suffixes in test emails/usernames prevent collisions when tests run in parallel.
A test that passes only because yesterday's test created the right user is worse than no test — it fails mysteriously and teaches you to ignore failures.
Running in CI
Cypress runs headlessly in CI — no visible browser needed:
- name: Run Cypress E2E tests
uses: cypress-io/github-action@v6
with:
start: npm start
wait-on: 'http://localhost:3000'
The official GitHub Action starts your app, waits for it to respond, then runs the suite. Run E2E in CI on pull requests (not necessarily every commit — they are slow). Failures block the merge, which is exactly the protection you want before demo day. Pair this with the pipeline setup in the GitHub Actions guide.
What to test E2E (short list)
- Authentication: login, logout, protected routes redirecting
- The one core journey your app exists for (placing an order, submitting a project, booking a slot)
- Critical forms: validation errors show, successful submit confirms
- Payment-adjacent flows (with sandbox/test mode — never real charges)
Everything else belongs in unit or integration tests. Ten focused E2E tests beat a hundred flaky ones.
Debugging failing tests
Cypress's debugging experience is genuinely good — use it:
- Time travel. In the interactive runner, hover over each command in the left panel to see exactly what the page looked like at that moment. Most failures become obvious: the button was not there yet, the modal covered it, the text differs.
- Automatic evidence. Every failed run captures screenshots; with video enabled you get a full recording. In CI, upload these as artifacts — debugging a failure you cannot see is guesswork.
- The usual suspects when a test flakes: timing (fixed with proper assertions and network aliases, never sleeps), test isolation (leaked state from a previous test), and selectors (a
data-cyattribute beats an nth-child selector every time). cypress openvscypress run. Develop and debug withopen(interactive, watch it happen); run headlessly withrunin CI. If a test passes inopenbut fails inrun, suspect viewport size or timing differences — set an explicit viewport in config.
A failing E2E test is either a real bug (celebrate — the suite earned its keep) or a test bug (fix immediately — a suite nobody trusts is a suite nobody runs).
Common mistakes
- Testing everything E2E. Slow suite → developers stop running it → suite rots. Respect the pyramid.
- Using CSS selectors or text as hooks. One redesign breaks fifty tests. Use
data-cyattributes. - Hard
cy.wait(5000)sleeps. They make suites slow and still flaky. Cypress's automatic retrying pluscy.wait('@alias')for network calls is the correct pattern. - Shared, unseeded test data. Tests that depend on leftover state fail randomly and erode trust in the suite.
- Testing third-party widgets deeply. Your tests should verify your integration point (the payment button triggers checkout), not re-test Stripe's UI.
- Ignoring flaky tests. A test that fails "sometimes" is a bug in the test or the app — investigate immediately. Teams that tolerate flakes end up ignoring the whole suite.
Quick checklist
- Cypress installed;
data-cyattributes on tested elements - Critical journeys covered: auth, core flow, key forms
- Backend stubbed with
cy.interceptwhere appropriate; real seeded DB for true E2E paths - Test database reseeded before each run; no production data touched
- No hard sleeps; network waits via aliases
- Suite runs in CI on pull requests and gates merging
Where to go from here
- Base of the pyramid: Testing JavaScript with Jest.
- Automate the runs: GitHub Actions: Build a CI/CD Pipeline.
- Test the app you ship: Progressive Web Apps for students.
- Security angle on your flows: OWASP Top 10 for student web apps.
- More frontend topics in the Web Development branch hub.