Skip to content
·10 min read

Cypress End-to-End Testing for Apps You Vibe Coded

Write automated browser tests that catch broken flows before your users do, even when you did not write the code yourself

Share

Cypress end to end testing lets you write scripts that open a real browser, click through your app, fill out forms, and assert the right things happen. If a flow breaks after an AI makes changes, Cypress catches it before your users do. Setup takes under 30 minutes.

The core problem with AI-built apps is the missing mental model. A human who writes every line of code builds a feel for which changes break what. When you prompt an AI to add a feature, refactor a component, or fix a bug, you get working-looking code but no intuition about side effects. You click around a bit, it looks fine, and you ship. Then a user emails you that the login form stopped working on Firefox or the checkout button does nothing after you added a discount code field.

Cypress end to end testing is the layer that substitutes for that intuition. You write the expected behavior once, run it on every push, and the suite tells you whether your AI's latest change broke anything.

Why Cypress Works Well for AI-Built Apps

Cypress was built specifically for web apps. Unlike Selenium, which was designed for general browser automation, Cypress runs directly inside the browser alongside your app. That makes it faster, more reliable, and significantly easier to debug when something goes wrong.

The interactive test runner is the killer feature. When a test fails in development, Cypress shows you a live replay of everything that happened: every click, every assertion, every network request. You can time-travel through the test by hovering over steps in the command log and see exactly what the DOM looked like at the moment an assertion failed. For developers who did not write the underlying code, this is invaluable. The failure is not just a line number and an error message. It is a visual record of what went wrong.

Cypress also has strong documentation and a massive community, which matters when you are debugging a setup problem at 11pm before a launch. The tradeoff compared to Playwright is that Cypress only runs tests in Chromium-based browsers by default (Firefox support exists but is less mature). For most indie projects and production apps, that covers enough ground.

EXPLAINER DIAGRAM: Two-column comparison between Cypress and Playwright. Left column header reads CYPRESS with a green background. Rows: Runs inside the browser (checkmark), Visual time-travel debugger (checkmark), Chromium plus partial Firefox (note), Large community and docs (checkmark), Best for teams new to E2E testing (checkmark). Right column header reads PLAYWRIGHT with a blue background. Rows: Runs outside the browser (note), Trace viewer for debugging (checkmark), Chromium plus Firefox plus WebKit (checkmark), Growing community (checkmark), Best for cross-browser coverage (checkmark). Bottom annotation reads Both are excellent choices. Pick Cypress if debugging UX matters. Pick Playwright for cross-browser requirements.

Installing and Configuring Cypress

You need Node.js and an existing project. From your project root, run:

npm install cypress --save-dev

Then open the Cypress launcher to complete the setup:

npx cypress open

Cypress walks you through choosing between E2E Testing and Component Testing. Pick E2E Testing. It then asks which browser to use and generates a cypress.config.ts file in your project root. After the setup wizard finishes, close the launcher.

Open cypress.config.ts and set your base URL so you do not have to type the full URL in every test:

import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    setupNodeEvents(on, config) {
      // Add plugins here if needed
    },
  },
});

Your project now has a cypress/ folder containing:

  • e2e/ where your test files live
  • fixtures/ for static test data (JSON files your tests can load)
  • support/ for shared setup code that runs before each test

Run your dev server in one terminal, then in another terminal run:

npx cypress run

This runs all tests headlessly and prints pass/fail results. Use npx cypress open when you want the interactive runner during development.

Key Takeaway

Cypress needs your dev server running before it starts tests. In local development you run both yourself. In CI you start the dev server as a background process before the Cypress step. The start-server-and-test package handles this cleanly in CI by waiting for the server to be ready before executing the test command.

Writing Your First Cypress Test

Delete the example tests Cypress generates in cypress/e2e/ and create your first real test file. Start with the most important flow in your app, which for most apps is authentication.

// cypress/e2e/login.cy.ts
describe('Login flow', () => {
  it('logs in with valid credentials', () => {
    cy.visit('/login');

    cy.get('[data-cy="email-input"]').type('test@example.com');
    cy.get('[data-cy="password-input"]').type('password123');
    cy.get('[data-cy="login-button"]').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-input"]').type('test@example.com');
    cy.get('[data-cy="password-input"]').type('wrongpassword');
    cy.get('[data-cy="login-button"]').click();

    cy.contains('Invalid email or password').should('be.visible');
    cy.url().should('include', '/login');
  });
});

The data-cy attributes are custom HTML attributes you add to your elements. They exist purely for testing and do not affect styling or behavior. Using them makes your selectors stable: they survive CSS refactors, class renames, and component restructuring. Add them to any element your tests need to interact with:

<input
  data-cy="email-input"
  type="email"
  name="email"
  placeholder="you@example.com"
/>

Breaking down the key Cypress commands:

  • cy.visit('/login') navigates to the login page relative to your baseUrl
  • cy.get('[data-cy="email-input"]') finds the element with that attribute
  • .type('text here') types into an input field, clearing it first
  • .click() clicks the element
  • cy.url().should('include', '/dashboard') asserts the URL changed
  • cy.contains('Welcome back') finds any element containing that text
  • .should('be.visible') asserts the element is visible in the viewport

Testing Auth Flows and Form Submissions

The login test above works, but running a full login before every single test is slow and brittle. Cypress handles this with cy.session(), which caches browser cookies and local storage after a login and reuses them across tests without repeating the full UI flow.

Create a custom command in cypress/support/commands.ts:

// cypress/support/commands.ts
Cypress.Commands.add('loginAsTestUser', () => {
  cy.session('testUser', () => {
    cy.visit('/login');
    cy.get('[data-cy="email-input"]').type(Cypress.env('TEST_EMAIL'));
    cy.get('[data-cy="password-input"]').type(Cypress.env('TEST_PASSWORD'));
    cy.get('[data-cy="login-button"]').click();
    cy.url().should('include', '/dashboard');
  });
});

Now any test that needs an authenticated user calls cy.loginAsTestUser() instead of repeating the full login sequence. Cypress restores the cached session on subsequent runs, skipping the UI interaction entirely.

Set the environment variables in cypress.config.ts so they do not get committed to your repo:

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    env: {
      TEST_EMAIL: process.env.TEST_EMAIL,
      TEST_PASSWORD: process.env.TEST_PASSWORD,
    },
  },
});

For form submission tests, here is a realistic example covering a contact form with validation:

// cypress/e2e/contact-form.cy.ts
describe('Contact form', () => {
  beforeEach(() => {
    cy.visit('/contact');
  });

  it('submits successfully with valid data', () => {
    cy.get('[data-cy="name-input"]').type('Jane Smith');
    cy.get('[data-cy="email-input"]').type('jane@example.com');
    cy.get('[data-cy="message-input"]').type('Hello, I have a question about pricing.');
    cy.get('[data-cy="submit-button"]').click();

    cy.contains('Message sent').should('be.visible');
  });

  it('blocks submission when required fields are empty', () => {
    cy.get('[data-cy="submit-button"]').click();

    cy.contains('Name is required').should('be.visible');
    cy.contains('Email is required').should('be.visible');
  });

  it('intercepts the API call and handles server errors', () => {
    cy.intercept('POST', '/api/contact', {
      statusCode: 500,
      body: { error: 'Internal server error' },
    }).as('contactSubmit');

    cy.get('[data-cy="name-input"]').type('Jane Smith');
    cy.get('[data-cy="email-input"]').type('jane@example.com');
    cy.get('[data-cy="message-input"]').type('Test message');
    cy.get('[data-cy="submit-button"]').click();

    cy.wait('@contactSubmit');
    cy.contains('Something went wrong').should('be.visible');
  });
});

The cy.intercept() call in the third test is one of Cypress's most useful features. It lets you intercept any network request and return whatever response you want, including errors. This means you can test your error handling UI without actually having a broken backend. AI-generated error handling is often untested because the happy path works and the errors never happen in development. Intercept lets you force those paths.

Common Mistake

Never use CSS class selectors like .submit-btn or IDs like #email to find elements in Cypress tests. These break the moment you refactor your styles or rename a component. Use data-cy attributes for anything your tests interact with, and use cy.contains() for assertions about visible text. Selectors tied to user-visible content stay stable across redesigns.

Add data-cy attributes to your elements as you build them, not as an afterthought. It takes 10 seconds per element and saves hours of test maintenance later. Treat them as part of the element's contract, the same way you treat aria-label attributes.

EXPLAINER DIAGRAM: A three-panel sequence showing a Cypress test intercepting an API call. Panel one shows a browser with a form being filled, labeled USER FILLS FORM. Panel two shows a Cypress intercept command blocking the network request and replacing it with a fake 500 error response, labeled CYPRESS INTERCEPTS REQUEST with an arrow showing the real API being bypassed and a fake error response being injected. Panel three shows the browser displaying a red error banner reading Something went wrong, labeled APP HANDLES THE ERROR. Caption at bottom reads cy.intercept lets you test error handling without breaking your backend.

Cypress vs Playwright for Vibe Coders

Since we have a Playwright tutorial on this blog, the honest comparison: both tools are excellent and the difference matters less than just picking one and writing tests.

Cypress is the better starting point if you have never written E2E tests before. The interactive runner with time-travel debugging makes it easier to understand why a test failed, especially when you are working with code an AI wrote and you are not sure where to look. The error messages are more verbose and opinionated in a helpful way.

Playwright is better if you need real cross-browser testing (Firefox, WebKit/Safari), if your app targets iOS/Safari users specifically, or if you are comfortable in a programmatic API. Playwright's trace viewer is also excellent for debugging, though the setup curve is slightly steeper.

For most indie hackers and senior devs shipping AI-built apps: start with Cypress. If you hit a limitation, migrating to Playwright is straightforward because the core concepts are identical.

What This Means For You

If you are a founder shipping AI-built features, a Cypress suite is insurance. You write the expected behavior once and stop manually re-clicking every flow after every change. The ROI is immediate after the first time it catches a broken redirect or a form submission that silently fails.

If you are a senior developer on a team using AI-assisted development, a Cypress suite is the specification layer. Tests document what the application is supposed to do. When the AI makes a change that contradicts that specification, the test fails and you have an objective record of the regression.

If you are a career changer or student building a portfolio, a working Cypress suite signals professional competence. It is the difference between "I shipped a project" and "I shipped a project with automated quality checks." Hiring managers and technical co-founders notice.

Start with three tests: successful login, failed login with wrong credentials, and your app's most important form submission. Run them on push with GitHub Actions using the start-server-and-test package. That is a real test suite. It covers more ground than most teams ship with, and it takes under an afternoon to set up.

Want to Automate Your Entire Deployment Pipeline?

Testing is the first step. Learn how CI/CD ties the whole workflow together.

See the full workflow

The moment that makes testing click is when Cypress catches its first real regression, a bug you introduced while making a completely unrelated change. You will get that feeling within a week of setting this up. After that, shipping without tests feels uncomfortably risky, which is exactly the right instinct.

Building Your First Serious App with AI?

Get the full picture on what it takes to ship something production-ready.

Start here
PJ
Pranay Joshi

20+ years building products at scale. VP of Product & Engineering, startup founder, and AI coach. Helping dreamers turn ideas into reality with vibe coding.

The Tuesday Shipping Report

Every Tuesday, one focused email:

  • - The tool or technique that's actually working right now
  • - A real problem from the community (and how to solve it)
  • - What changed this week in the vibe coding landscape

Read by 1,000+ founders, developers, and creators building with AI. Free forever. No spam.